Tune links: put the tuned frequency in the address bar #42
@@ -300,6 +300,39 @@ loopback on Linux, BlackHole on macOS).
|
||||
|
||||
---
|
||||
|
||||
## Tune Links
|
||||
|
||||
Every page of the web UI carries what the radio is doing in its address, so the
|
||||
URL in the address bar is always a link someone else can open:
|
||||
|
||||
```
|
||||
http://receiver.example:8080/?rig=sdr&f=14074000&mode=USB&bw=3000
|
||||
```
|
||||
|
||||
| Parameter | Meaning |
|
||||
|-----------|---------|
|
||||
| `f` | Frequency. Hz by default; `7074k` and `14.074M` also work. |
|
||||
| `mode` | Demodulation mode, e.g. `USB`, `CW`, `WFM`. |
|
||||
| `bw` | Filter bandwidth in Hz. Ignored by rigs without filter control. |
|
||||
| `rig` | Rig to select first, by id, on a multi-rig client. |
|
||||
|
||||
Opening such a link selects the rig, sets the mode, tunes, and applies the
|
||||
bandwidth, in that order — a mode change carries its own default bandwidth, so
|
||||
an explicit `bw` is applied last. Anything the rig cannot do (an unknown mode,
|
||||
a frequency outside its range) is reported and the rest of the link still
|
||||
applies. All four parameters are optional.
|
||||
|
||||
The link button in the top bar copies the current link to the clipboard. The
|
||||
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
||||
the dial does not fill the browser's history.
|
||||
|
||||
Applying a link changes the radio, so it needs the `control` role; an `rx`
|
||||
session opens the page and says the link was not applied. Links describe the
|
||||
rig's own dial — while a tab is listening to a virtual channel the address is
|
||||
left as it was, rather than publishing a frequency the rig is not on.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
The HTTP frontend supports optional passphrase-based authentication with two
|
||||
|
||||
@@ -747,7 +747,12 @@ function elementById(id) {
|
||||
api.applyLayout(savedLayoutName(), { persist: false });
|
||||
}
|
||||
}
|
||||
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
|
||||
const overflowOrder = [
|
||||
".operator-layout-picker",
|
||||
".header-style-pick",
|
||||
"#header-share-btn",
|
||||
"#theme-toggle"
|
||||
];
|
||||
function anchorMenu(button, menu) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
if (menu.parentElement !== document.body) document.body.appendChild(menu);
|
||||
@@ -1555,6 +1560,44 @@ function updateTabHistory(name, replace = false) {
|
||||
if (replace) window.history.replaceState({}, "", nextUrl);
|
||||
else window.history.pushState({}, "", nextUrl);
|
||||
}
|
||||
var FREQ_MULTIPLIER = { k: 1e3, m: 1e6, g: 1e9 };
|
||||
function parseFrequencyParam(raw) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
|
||||
const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
|
||||
if (!match) return null;
|
||||
const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.round(value);
|
||||
}
|
||||
function parseModeParam(raw) {
|
||||
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
||||
return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
|
||||
}
|
||||
function parseTuneLink(search) {
|
||||
const params = new URLSearchParams(search);
|
||||
const rig = (params.get("rig") || "").trim();
|
||||
return {
|
||||
rig: rig || null,
|
||||
freqHz: parseFrequencyParam(params.get("f")),
|
||||
mode: parseModeParam(params.get("mode")),
|
||||
bandwidthHz: parseFrequencyParam(params.get("bw"))
|
||||
};
|
||||
}
|
||||
function tuneLinkSearch(search, link) {
|
||||
const params = new URLSearchParams(search);
|
||||
const set = (key, value) => {
|
||||
if (value == null || value === "") params.delete(key);
|
||||
else params.set(key, value);
|
||||
};
|
||||
const hz = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null;
|
||||
set("rig", link.rig);
|
||||
set("f", hz(link.freqHz));
|
||||
set("mode", parseModeParam(link.mode));
|
||||
set("bw", hz(link.bandwidthHz));
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
// src/features/radio/auto-bandwidth.ts
|
||||
function clampPercent(value) {
|
||||
@@ -1978,6 +2021,7 @@ function applyAuthRestrictions() {
|
||||
function applyCapabilities(caps) {
|
||||
if (!caps) return;
|
||||
lastHasTx = !!caps.tx;
|
||||
bandwidthControlSupported = !!caps.filter_controls;
|
||||
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
|
||||
const pttBtn2 = document.getElementById("ptt-btn");
|
||||
const txPowerCol = document.getElementById("tx-power-col");
|
||||
@@ -2325,6 +2369,7 @@ var jogAngle = 0;
|
||||
var lastClientCount = null;
|
||||
var lastLocked = false;
|
||||
var sdrSquelchSupported = false;
|
||||
var bandwidthControlSupported = false;
|
||||
var previousTuneState = null;
|
||||
function savePreviousTuneState() {
|
||||
previousTuneState = {
|
||||
@@ -4410,6 +4455,85 @@ window.buildAisVesselUrl = function(mmsi) {
|
||||
if (!aisVesselUrlBase || !isFiniteNumber(Number(mmsi))) return null;
|
||||
return `${aisVesselUrlBase}${String(mmsi)}`;
|
||||
};
|
||||
var incomingTuneLink = parseTuneLink(window.location.search);
|
||||
var tuneLinkPhase = "pending";
|
||||
var tuneLinkSyncTimer = null;
|
||||
function currentTuneLink() {
|
||||
const bandwidthHz = bandwidthControlSupported && isFiniteNumber(currentBandwidthHz) && currentBandwidthHz > 0 ? currentBandwidthHz : null;
|
||||
return {
|
||||
rig: lastActiveRigId || null,
|
||||
freqHz: isFiniteNumber(lastFreqHz) ? lastFreqHz : null,
|
||||
mode: lastModeName || null,
|
||||
bandwidthHz
|
||||
};
|
||||
}
|
||||
function tuneLinkUrl() {
|
||||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||
return `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
|
||||
}
|
||||
function scheduleTuneLinkSync() {
|
||||
if (tuneLinkPhase !== "live") return;
|
||||
if (window.trx?.modules.vchan?.isOnVirtual() === true) return;
|
||||
if (tuneLinkSyncTimer != null) return;
|
||||
tuneLinkSyncTimer = window.setTimeout(() => {
|
||||
tuneLinkSyncTimer = null;
|
||||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||
if (search === window.location.search) return;
|
||||
window.history.replaceState({}, "", `${window.location.pathname}${search}${window.location.hash}`);
|
||||
}, 500);
|
||||
}
|
||||
async function applyTuneLink(link) {
|
||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||
if (!wanted) return;
|
||||
if (authRole === "rx") {
|
||||
showHint("Read-only session — link not applied", 2500);
|
||||
return;
|
||||
}
|
||||
if (link.rig && headerRigSwitchSelect && link.rig !== lastActiveRigId && lastRigIds.includes(link.rig)) {
|
||||
headerRigSwitchSelect.value = link.rig;
|
||||
await switchRigFromSelect(headerRigSwitchSelect);
|
||||
}
|
||||
if (link.mode && link.mode !== lastModeName && modeEl) {
|
||||
const known = Array.from(modeEl.options).some((option) => option.value === link.mode);
|
||||
if (known) {
|
||||
modeEl.value = link.mode;
|
||||
await applyModeFromPicker();
|
||||
} else {
|
||||
showHint(`Rig has no ${link.mode} mode`, 2500);
|
||||
}
|
||||
}
|
||||
if (link.freqHz != null) {
|
||||
try {
|
||||
setRigFrequency(link.freqHz);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (link.bandwidthHz != null && bandwidthControlSupported && spectrumBwInput) {
|
||||
spectrumBwInput.value = String(link.bandwidthHz / 1e3);
|
||||
await applyBandwidthFromInput();
|
||||
}
|
||||
}
|
||||
function followTuneLinkOnce() {
|
||||
if (tuneLinkPhase !== "pending") return;
|
||||
tuneLinkPhase = "applying";
|
||||
void applyTuneLink(incomingTuneLink).finally(() => {
|
||||
tuneLinkPhase = "live";
|
||||
scheduleTuneLinkSync();
|
||||
});
|
||||
}
|
||||
async function copyTuneLink() {
|
||||
const url = tuneLinkUrl();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showHint("Link copied", 1500);
|
||||
} catch {
|
||||
window.trxUi?.notify("Clipboard unavailable — the address bar holds the link", { kind: "error" });
|
||||
}
|
||||
}
|
||||
var shareLinkBtn = document.getElementById("header-share-btn");
|
||||
if (shareLinkBtn) shareLinkBtn.addEventListener("click", () => {
|
||||
void copyTuneLink();
|
||||
});
|
||||
function render(update) {
|
||||
if (!update) return;
|
||||
if (update.server_version) serverVersion = update.server_version;
|
||||
@@ -4922,6 +5046,8 @@ function render(update) {
|
||||
swrBar.style.width = "0%";
|
||||
swrValue.textContent = "SWR --";
|
||||
}
|
||||
if (update.status) followTuneLinkOnce();
|
||||
scheduleTuneLinkSync();
|
||||
}
|
||||
function scheduleReconnect(delayMs = 1e3) {
|
||||
if (reconnectTimer) return;
|
||||
|
||||
@@ -99,6 +99,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="phosphor">Phosphor</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="header-share-btn" class="header-bar-btn header-share-btn" type="button" aria-label="Copy a link to this frequency" title="Copy a link to this frequency">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M6.5 9.5a2.6 2.6 0 0 0 3.9.3l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-1 1"/><path d="M9.5 6.5a2.6 2.6 0 0 0-3.9-.3L3.4 8.4a2.6 2.6 0 0 0 3.7 3.7l1-1"/></svg>
|
||||
</button>
|
||||
<button id="theme-toggle" class="header-bar-btn" type="button" aria-label="Toggle dark or light theme">Light</button>
|
||||
<button id="header-auth-btn" class="header-bar-btn" type="button" style="display:none;" aria-label="Login or Logout">Login</button>
|
||||
</div>
|
||||
|
||||
@@ -1345,7 +1345,10 @@ small { color: var(--text-muted); }
|
||||
.top-bar-actions > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.header-bar-btn.header-audio-btn {
|
||||
/* Square icon buttons in the top bar: an icon with no box of its own has no
|
||||
size to draw at, so the button gives it one. */
|
||||
.header-bar-btn.header-audio-btn,
|
||||
.header-bar-btn.header-share-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-height: 0;
|
||||
@@ -1360,10 +1363,15 @@ small { color: var(--text-muted); }
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-audio-btn svg {
|
||||
.header-audio-btn svg,
|
||||
.header-share-btn svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* The link icon is drawn in strokes, not fills, and wants room around it. */
|
||||
.header-share-btn svg {
|
||||
padding: 1px;
|
||||
}
|
||||
.header-audio-btn.audio-active {
|
||||
color: #00d17f;
|
||||
border-color: #00d17f;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs",
|
||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
import { decodeCbor as decodeCborPayload } from "./core/cbor.js";
|
||||
import {
|
||||
TAB_ORDER,
|
||||
type TuneLink,
|
||||
parseTuneLink,
|
||||
tabFromPath as tabFromPathname,
|
||||
tuneLinkSearch,
|
||||
updateTabHistory,
|
||||
} from "./features/navigation/routes.js";
|
||||
import { estimateOccupiedBandwidth } from "./features/radio/auto-bandwidth.js";
|
||||
@@ -609,6 +612,7 @@ function applyAuthRestrictions() {
|
||||
function applyCapabilities(caps: RigCapabilities | null) {
|
||||
if (!caps) return;
|
||||
lastHasTx = !!caps.tx;
|
||||
bandwidthControlSupported = !!caps.filter_controls;
|
||||
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
|
||||
|
||||
// PTT / TX controls
|
||||
@@ -1012,6 +1016,9 @@ let jogAngle = 0;
|
||||
let lastClientCount: number | null = null;
|
||||
let lastLocked = false;
|
||||
let sdrSquelchSupported = false;
|
||||
// Whether this rig takes a bandwidth at all — a CAT rig without filter control
|
||||
// has no use for the `bw` of a tune link, in either direction.
|
||||
let bandwidthControlSupported = false;
|
||||
// ── Previous-state tracking for "B" hotkey ────────────────────────────────────
|
||||
let previousTuneState: PreviousTuneState | null = null;
|
||||
|
||||
@@ -3267,6 +3274,110 @@ window.buildAisVesselUrl = function(mmsi: unknown) {
|
||||
return `${aisVesselUrlBase}${String(mmsi)}`;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tune links
|
||||
//
|
||||
// The address bar is the share link: whatever the dial is on is in the query
|
||||
// string, and a link someone else opens puts the radio there. Read the
|
||||
// incoming link once, at load, before anything of ours rewrites the URL.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const incomingTuneLink: TuneLink = parseTuneLink(window.location.search);
|
||||
// "pending" until the first state arrives, "applying" while the link is being
|
||||
// followed, "live" once the URL is ours to keep up to date.
|
||||
let tuneLinkPhase: "pending" | "applying" | "live" = "pending";
|
||||
let tuneLinkSyncTimer: number | null = null;
|
||||
|
||||
function currentTuneLink(): TuneLink {
|
||||
const bandwidthHz = bandwidthControlSupported && isFiniteNumber(currentBandwidthHz) && currentBandwidthHz > 0
|
||||
? currentBandwidthHz
|
||||
: null;
|
||||
return {
|
||||
rig: lastActiveRigId || null,
|
||||
freqHz: isFiniteNumber(lastFreqHz) ? lastFreqHz : null,
|
||||
mode: lastModeName || null,
|
||||
bandwidthHz,
|
||||
};
|
||||
}
|
||||
|
||||
/** The link as someone else would open it. */
|
||||
function tuneLinkUrl(): string {
|
||||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||
return `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
function scheduleTuneLinkSync() {
|
||||
if (tuneLinkPhase !== "live") return;
|
||||
// A virtual channel is a tab's own slice, not where the rig is pointed —
|
||||
// publishing it as the rig's link would send someone to another frequency.
|
||||
if (window.trx?.modules.vchan?.isOnVirtual() === true) return;
|
||||
if (tuneLinkSyncTimer != null) return;
|
||||
tuneLinkSyncTimer = window.setTimeout(() => {
|
||||
tuneLinkSyncTimer = null;
|
||||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||
if (search === window.location.search) return;
|
||||
// replaceState, not push: tuning is not navigation, and a dial sweep would
|
||||
// otherwise bury the back button under a hundred entries.
|
||||
window.history.replaceState({}, "", `${window.location.pathname}${search}${window.location.hash}`);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function applyTuneLink(link: TuneLink) {
|
||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||
if (!wanted) return;
|
||||
if (authRole === "rx") {
|
||||
showHint("Read-only session — link not applied", 2500);
|
||||
return;
|
||||
}
|
||||
// Rig first: the rest of the link describes state on that rig.
|
||||
if (link.rig && headerRigSwitchSelect && link.rig !== lastActiveRigId && lastRigIds.includes(link.rig)) {
|
||||
headerRigSwitchSelect.value = link.rig;
|
||||
await switchRigFromSelect(headerRigSwitchSelect);
|
||||
}
|
||||
// Mode before frequency: the mode change carries a default bandwidth with
|
||||
// it, which an explicit bw in the link then overrides.
|
||||
if (link.mode && link.mode !== lastModeName && modeEl) {
|
||||
const known = Array.from(modeEl.options).some((option) => option.value === link.mode);
|
||||
if (known) {
|
||||
modeEl.value = link.mode;
|
||||
await applyModeFromPicker();
|
||||
} else {
|
||||
showHint(`Rig has no ${link.mode} mode`, 2500);
|
||||
}
|
||||
}
|
||||
if (link.freqHz != null) {
|
||||
// setRigFrequency throws on a frequency this rig cannot reach, having
|
||||
// already told the user which one it was.
|
||||
try { setRigFrequency(link.freqHz); } catch { /* reported to the user */ }
|
||||
}
|
||||
if (link.bandwidthHz != null && bandwidthControlSupported && spectrumBwInput) {
|
||||
spectrumBwInput.value = String(link.bandwidthHz / 1000);
|
||||
await applyBandwidthFromInput();
|
||||
}
|
||||
}
|
||||
|
||||
function followTuneLinkOnce() {
|
||||
if (tuneLinkPhase !== "pending") return;
|
||||
tuneLinkPhase = "applying";
|
||||
void applyTuneLink(incomingTuneLink).finally(() => {
|
||||
tuneLinkPhase = "live";
|
||||
scheduleTuneLinkSync();
|
||||
});
|
||||
}
|
||||
|
||||
async function copyTuneLink() {
|
||||
const url = tuneLinkUrl();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showHint("Link copied", 1500);
|
||||
} catch {
|
||||
window.trxUi?.notify("Clipboard unavailable — the address bar holds the link", { kind: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
const shareLinkBtn = document.getElementById("header-share-btn") as HTMLButtonElement | null;
|
||||
if (shareLinkBtn) shareLinkBtn.addEventListener("click", () => { void copyTuneLink(); });
|
||||
|
||||
function render(update: AppUpdate) {
|
||||
if (!update) return;
|
||||
if (update.server_version) serverVersion = update.server_version;
|
||||
@@ -3842,6 +3953,10 @@ function render(update: AppUpdate) {
|
||||
swrBar.style.width = "0%";
|
||||
swrValue.textContent = "SWR --";
|
||||
}
|
||||
// Follow the link that opened the page once there is a rig to point, then
|
||||
// keep the address bar on whatever the dial does next.
|
||||
if (update.status) followTuneLinkOnce();
|
||||
scheduleTuneLinkSync();
|
||||
}
|
||||
|
||||
function scheduleReconnect(delayMs = 1000) {
|
||||
|
||||
+63
@@ -37,3 +37,66 @@ export function updateTabHistory(name: TabName, replace = false): void {
|
||||
if (replace) window.history.replaceState({}, "", nextUrl);
|
||||
else window.history.pushState({}, "", nextUrl);
|
||||
}
|
||||
|
||||
/** What a shared link says to listen to: `?rig=sdr&f=14074000&mode=USB&bw=3000`. */
|
||||
export interface TuneLink {
|
||||
rig: string | null;
|
||||
freqHz: number | null;
|
||||
mode: string | null;
|
||||
bandwidthHz: number | null;
|
||||
}
|
||||
|
||||
const FREQ_MULTIPLIER: Readonly<Record<string, number>> = { k: 1e3, m: 1e6, g: 1e9 };
|
||||
|
||||
/**
|
||||
* Read a frequency written for a person: bare Hz as the canonical form, but
|
||||
* `7074k` and `14.074M` are what someone typing a link by hand reaches for.
|
||||
* Returns Hz, or null for anything that is not a positive frequency.
|
||||
*/
|
||||
export function parseFrequencyParam(raw: string | null | undefined): number | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
|
||||
const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
|
||||
if (!match) return null;
|
||||
const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
/** Mode names run from FM to VDES; anything else in the URL is not one. */
|
||||
function parseModeParam(raw: string | null | undefined): string | null {
|
||||
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
||||
return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
|
||||
}
|
||||
|
||||
export function parseTuneLink(search: string): TuneLink {
|
||||
const params = new URLSearchParams(search);
|
||||
const rig = (params.get("rig") || "").trim();
|
||||
return {
|
||||
rig: rig || null,
|
||||
freqHz: parseFrequencyParam(params.get("f")),
|
||||
mode: parseModeParam(params.get("mode")),
|
||||
bandwidthHz: parseFrequencyParam(params.get("bw")),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the tune parameters of a query string, leaving anything else in it
|
||||
* alone — the address bar is the share link, so it has to keep up with the
|
||||
* dial without discarding whatever else a page put there.
|
||||
*/
|
||||
export function tuneLinkSearch(search: string, link: TuneLink): string {
|
||||
const params = new URLSearchParams(search);
|
||||
const set = (key: string, value: string | null) => {
|
||||
if (value == null || value === "") params.delete(key);
|
||||
else params.set(key, value);
|
||||
};
|
||||
const hz = (value: number | null) =>
|
||||
(typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null);
|
||||
set("rig", link.rig);
|
||||
set("f", hz(link.freqHz));
|
||||
set("mode", parseModeParam(link.mode));
|
||||
set("bw", hz(link.bandwidthHz));
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
@@ -327,7 +327,9 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
|
||||
// Secondary controls, in the order they leave the bar when it gets tight.
|
||||
// Audio, record and the rig picker are the operating controls and stay.
|
||||
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
|
||||
const overflowOrder = [
|
||||
".operator-layout-picker", ".header-style-pick", "#header-share-btn", "#theme-toggle",
|
||||
];
|
||||
|
||||
// Anchored at paint time in fixed coordinates. An absolutely positioned
|
||||
// dropdown is clipped by any scrolling ancestor and trapped inside the
|
||||
|
||||
+54
-1
@@ -17,7 +17,8 @@ async function loadRoutes(window) {
|
||||
write: false,
|
||||
});
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window });
|
||||
// The sandbox is bare; the browser globals the module actually uses go in.
|
||||
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window, URLSearchParams });
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
@@ -38,3 +39,55 @@ test("all top-level tabs have stable round-trip routes", async () => {
|
||||
assert.equal(calls[0][0], "push");
|
||||
assert.equal(calls[0][3], "/statistics?rig=one#panel");
|
||||
});
|
||||
|
||||
// A tune link is what one operator sends another: it has to survive being
|
||||
// typed by hand, and it has to come back out as the frequency that went in.
|
||||
test("tune links parse the frequencies people write", async () => {
|
||||
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||
for (const [text, hz] of [
|
||||
["14074000", 14_074_000], ["14074000hz", 14_074_000],
|
||||
["7074k", 7_074_000], ["14.074M", 14_074_000], ["1.2G", 1_200_000_000],
|
||||
[" 145500k ", 145_500_000],
|
||||
]) {
|
||||
assert.equal(routes.parseFrequencyParam(text), hz, `${text} did not read as ${hz} Hz`);
|
||||
}
|
||||
for (const bad of ["", " ", "abc", "-7074", "0", "14,074", "1e6", null, undefined]) {
|
||||
assert.equal(routes.parseFrequencyParam(bad), null, `${bad} was read as a frequency`);
|
||||
}
|
||||
});
|
||||
|
||||
test("tune links carry rig, frequency, mode and bandwidth", async () => {
|
||||
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||
assert.deepEqual({ ...routes.parseTuneLink("?rig=sdr&f=14.074M&mode=usb&bw=3k") }, {
|
||||
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
|
||||
});
|
||||
// A link with nothing to say must not tune anything.
|
||||
assert.deepEqual({ ...routes.parseTuneLink("?tab=map") }, {
|
||||
rig: null, freqHz: null, mode: null, bandwidthHz: null,
|
||||
});
|
||||
// Junk in a parameter is not a frequency, and must not become one.
|
||||
assert.deepEqual({ ...routes.parseTuneLink("?f=&mode=NOTAMODE&bw=wide") }, {
|
||||
rig: null, freqHz: null, mode: null, bandwidthHz: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("writing a tune link canonicalises it and leaves other parameters alone", async () => {
|
||||
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||
const search = routes.tuneLinkSearch("?theme=dark&f=7074k", {
|
||||
rig: "sdr", freqHz: 14_074_000.4, mode: "usb", bandwidthHz: 3000,
|
||||
});
|
||||
const params = new URLSearchParams(search);
|
||||
assert.equal(params.get("theme"), "dark", "an unrelated parameter was dropped");
|
||||
assert.equal(params.get("f"), "14074000", "the frequency was not written as whole Hz");
|
||||
assert.equal(params.get("mode"), "USB");
|
||||
assert.equal(params.get("bw"), "3000");
|
||||
assert.equal(params.get("rig"), "sdr");
|
||||
// Round trip: what was written reads back as what went in.
|
||||
assert.deepEqual({ ...routes.parseTuneLink(search) }, {
|
||||
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
|
||||
});
|
||||
// Nothing known yet means no stale tune parameters left behind.
|
||||
const cleared = routes.tuneLinkSearch(search, { rig: null, freqHz: null, mode: null, bandwidthHz: null });
|
||||
assert.equal(cleared, "?theme=dark");
|
||||
assert.equal(routes.tuneLinkSearch("", { rig: null, freqHz: null, mode: null, bandwidthHz: null }), "");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// A link is how one operator tells another where to listen. Opening one has to
|
||||
// put the radio there, and the address bar has to keep up with the dial after
|
||||
// that — a link that goes stale the moment someone tunes is worse than none,
|
||||
// because it looks like it works.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document, window */
|
||||
|
||||
const LINK_HZ = 14_074_000;
|
||||
const LINK_BW = 2_800;
|
||||
const TUNED_HZ = 7_040_000;
|
||||
|
||||
const fixture = await startWebFixture({ spectrum: true });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
const dial = () => page.evaluate(() => ({
|
||||
freqHz: window.lastFreqHz,
|
||||
mode: document.getElementById("mode").value,
|
||||
search: window.location.search,
|
||||
path: window.location.pathname,
|
||||
}));
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1500, height: 950 });
|
||||
|
||||
// Opening the link tunes the radio: frequency, mode and bandwidth.
|
||||
await page.goto(`${fixture.origin}/?f=14.074M&mode=USB&bw=${LINK_BW}`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const opened = await dial();
|
||||
assert.equal(opened.freqHz, LINK_HZ, `the link left the radio on ${opened.freqHz} Hz`);
|
||||
assert.equal(opened.mode, "USB", `the link left the radio in ${opened.mode}`);
|
||||
const bandwidth = await page.evaluate(() => window.currentBandwidthHz);
|
||||
assert.equal(bandwidth, LINK_BW, `the link left the bandwidth at ${bandwidth} Hz`);
|
||||
|
||||
// Hand-written frequencies are canonicalised in place, so what the operator
|
||||
// copies back out is the same link in the form the app writes.
|
||||
const params = new URLSearchParams(opened.search);
|
||||
assert.equal(params.get("f"), String(LINK_HZ), `the URL kept "${params.get("f")}"`);
|
||||
assert.equal(params.get("mode"), "USB");
|
||||
|
||||
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||
// bar shareable at any moment rather than only at load.
|
||||
await page.locator("#freq").fill("7.040M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await page.waitForTimeout(1500);
|
||||
const tuned = await dial();
|
||||
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||
assert.equal(new URLSearchParams(tuned.search).get("f"), String(TUNED_HZ),
|
||||
`the URL still reads "${tuned.search}" after tuning`);
|
||||
|
||||
// Tuning is not navigation: a swept dial must not bury the back button.
|
||||
const historyLength = await page.evaluate(() => window.history.length);
|
||||
await page.locator("#freq").fill("7.100M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
||||
"tuning pushed a history entry instead of replacing one");
|
||||
|
||||
// Moving between tabs keeps the tune parameters: the path changes, the link
|
||||
// does not stop being a link.
|
||||
await page.locator('.tab[data-tab="map"]').click();
|
||||
await page.waitForTimeout(800);
|
||||
const onMap = await dial();
|
||||
assert.equal(onMap.path, "/map", `the map tab landed on ${onMap.path}`);
|
||||
assert.equal(new URLSearchParams(onMap.search).get("f"), "7100000",
|
||||
`the map tab dropped the tune parameters: "${onMap.search}"`);
|
||||
|
||||
// A link naming a rig selects it before applying the rest.
|
||||
await page.goto(`${fixture.origin}/?rig=rig-b&f=${TUNED_HZ}&mode=CW`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2500);
|
||||
const switched = await page.evaluate(() => ({
|
||||
rig: document.getElementById("header-rig-switch-select")?.value ?? null,
|
||||
selected: window.location.search,
|
||||
mode: document.getElementById("mode").value,
|
||||
}));
|
||||
assert.equal(switched.rig, "rig-b", `the link left the client on rig "${switched.rig}"`);
|
||||
assert.equal(switched.mode, "CW", `the link left the radio in ${switched.mode}`);
|
||||
|
||||
// A link with nothing to say tunes nothing, and the address bar fills in
|
||||
// with where the radio already is.
|
||||
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2000);
|
||||
const bare = await dial();
|
||||
assert.equal(new URLSearchParams(bare.search).get("f"), String(bare.freqHz),
|
||||
`a bare load wrote "${bare.search}" for a radio on ${bare.freqHz} Hz`);
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
@@ -249,6 +249,24 @@ export async function startWebFixture({
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/set_freq") {
|
||||
const hz = Number(url.searchParams.get("hz"));
|
||||
if (Number.isFinite(hz) && hz > 0) status.status.freq = { hz: Math.round(hz) };
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/set_mode") {
|
||||
const next = (url.searchParams.get("mode") || "").toUpperCase();
|
||||
if (next) status.status.mode = next;
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/set_bandwidth") {
|
||||
const hz = Number(url.searchParams.get("hz"));
|
||||
if (Number.isFinite(hz) && hz > 0 && status.filter) status.filter.bandwidth_hz = Math.round(hz);
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/select_rig" && request.method === "POST") {
|
||||
const remote = url.searchParams.get("remote");
|
||||
if (remote) {
|
||||
@@ -347,9 +365,11 @@ export async function startWebFixture({
|
||||
});
|
||||
// Varying, as a real one is: the client skips a frame identical to the
|
||||
// last, so a repeated payload exercises none of the state-update path.
|
||||
// The variation rides on the S-meter rather than the dial — a rig that
|
||||
// wandered a kilohertz every frame could never be told to tune.
|
||||
const frame = () => JSON.stringify({
|
||||
...status,
|
||||
status: { ...status.status, freq: { hz: 100_000_000 + (Date.now() % 1000) } },
|
||||
status: { ...status.status, rx: { ...status.status.rx, sig: meterDb + ((Date.now() % 3) - 1) } },
|
||||
});
|
||||
response.write(`data: ${frame()}\n\n`);
|
||||
const timer = setInterval(() => {
|
||||
|
||||
Reference in New Issue
Block a user