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(() => {