From a2c630a92b2ec173512cd1989303f7706016fc4d Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Wed, 5 Aug 2026 22:31:12 +0200 Subject: [PATCH] [feat](trx-frontend-http): put the tuned frequency in the address bar A receiver spreads by being linked to, and there was nothing to link to: the routes carried the tab and nothing else, so "listen to this" could only ever mean a screenshot and a frequency typed out in a message. The query string now carries the dial -- rig, frequency, mode and bandwidth -- in both directions. Opening a link selects the rig, sets the mode, tunes, then applies the bandwidth: a mode change brings its own default bandwidth with it, so an explicit bw has to land after it. Frequencies are read the way someone writes them by hand (7074k, 14.074M) and written back as whole Hz, so what comes out of the address bar is the same link in canonical form. After that the address bar keeps up with the dial, which is what makes it copyable at any moment rather than only at load. It is rewritten with replaceState -- tuning is not navigation, and a swept dial would otherwise bury the back button. A link button in the top bar copies the current link; it folds into the overflow menu when the bar is tight. Applying a link changes the radio, so an rx session says so instead of failing control calls one at a time. A tab listening to a virtual channel leaves the address alone rather than publishing a frequency the rig is not on, and bw is skipped in both directions on rigs without filter control, which would only refuse it. The fixture pinned every state frame to 100 MHz plus jitter to keep frames distinct, so no test could observe tuning at all. The jitter moves to the S-meter and the fixture echoes set_freq/set_mode/ set_bandwidth, as it already did for squelch. Signed-off-by: Stan Grams --- docs/User-Manual.md | 33 +++++ .../assets/web/generated/app.js | 128 +++++++++++++++++- .../trx-frontend-http/assets/web/index.html | 3 + .../trx-frontend-http/assets/web/style.css | 12 +- .../trx-frontend-http/frontend/package.json | 2 +- .../trx-frontend-http/frontend/src/app.ts | 115 ++++++++++++++++ .../src/features/navigation/routes.ts | 63 +++++++++ .../trx-frontend-http/frontend/src/ui-core.ts | 4 +- .../frontend/tests/navigation-routes.test.mjs | 55 +++++++- .../frontend/tests/tune-links.mjs | 102 ++++++++++++++ .../frontend/tests/web-fixture.mjs | 22 ++- 11 files changed, 532 insertions(+), 7 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs diff --git a/docs/User-Manual.md b/docs/User-Manual.md index ba498840..2c4a10b0 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -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 diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index ab7aabe2..592d42ab 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -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; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index de45e33f..7f8def65 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -99,6 +99,9 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 46d67167..0edbabd9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -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; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json index ec41ae2f..60334cc3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -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": { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index 3ded13e7..653422e1 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -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) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts index 3d8ad7a5..497413c5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts @@ -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> = { 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}` : ""; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts index d59be76a..70022d6e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts @@ -327,7 +327,9 @@ function elementById(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 diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/navigation-routes.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/navigation-routes.test.mjs index 2703b784..6f966280 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/navigation-routes.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/navigation-routes.test.mjs @@ -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 }), ""); +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs new file mode 100644 index 00000000..85ab0535 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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(); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs index ee29f064..d81b4a65 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs @@ -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(() => { -- 2.55.0