From 23dbcac5b62be0afb07b8f38207532fc53a70656 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sun, 2 Aug 2026 10:15:56 +0200 Subject: [PATCH 1/2] [fix](trx-frontend-http): restore bookmark host contract The TypeScript migration turned app.js from a classic script into an ES module, so its top-level declarations stopped being shared globals. bookmarks.ts was converted verbatim and kept reading them as window properties, which app.ts no longer publishes. Every bookmark interaction read undefined: the Add Bookmark and Select All buttons stayed hidden because the auth check saw no authEnabled or authRole, per-rig scopes were missing from the scope picker and the move target, decoder checkboxes were never built, and Tune threw on bridge.postPath before issuing a single request. Extend the typed window.trx host contract instead of restoring globals, as docs/frontend-architecture.md closes the standalone window property list. trx.state publishes authEnabled; trx.core publishes setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency, syncBandwidthInput, scheduleSpectrumDraw, and onDecoderRegistryReady. Replace the vchan setRigFrequency wrapper with an interceptFrequency service method, matching interceptMode and interceptBandwidth. The wrapper captured an undefined original and silently dropped every tune; routing interception through setRigFrequency also restores virtual channel redirection for the application's own tuning. Read registry-built elements through bmOptionalEl, since bmEl throws and the decoder checkboxes and decode toggle buttons are legitimately absent until the registry arrives. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz Signed-off-by: Stan Grams --- .../assets/web/generated/app.js | 16 ++ .../assets/web/generated/bookmarks.js | 104 +++++----- .../assets/web/generated/vchan.js | 28 +-- .../trx-frontend-http/frontend/src/app.ts | 20 ++ .../frontend/src/plugins/bookmarks.ts | 177 +++++++++--------- .../frontend/src/plugins/vchan.ts | 53 +++--- .../frontend/tests/bookmarks.test.mjs | 141 ++++++++++++-- .../frontend/tests/vchan.test.mjs | 2 + 8 files changed, 324 insertions(+), 217 deletions(-) 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 03242457..d6b2abef 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 @@ -3625,12 +3625,19 @@ async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardB } var _freqOptimisticHz = null; var _freqOptimisticSeq = 0; +function armOptimisticFrequency(freqHz) { + if (!isFiniteNumber(freqHz)) return; + _freqOptimisticSeq += 1; + _freqOptimisticHz = Math.round(freqHz); +} function setRigFrequency(freqHz) { const targetHz = Math.round(freqHz); if (!freqAllowed(targetHz)) { showUnsupportedFreqPopup(targetHz); throw new Error(`Unsupported frequency: ${targetHz}`); } + if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return; + void window.trx?.modules.vchan?.takeSchedulerControl(); const prevFreqHz = lastFreqHz; const seq = ++_freqOptimisticSeq; _freqOptimisticHz = targetHz; @@ -5713,6 +5720,9 @@ Object.defineProperties(trxState, { decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, + authEnabled: { get() { + return authEnabled; + } }, authRole: { get() { return authRole; } }, @@ -5816,6 +5826,12 @@ var trxCore = Object.freeze({ scheduleUiFrameJob, navigateToTab, rigBadgeColor, + setRigFrequency, + applyLocalTunedFrequency, + armOptimisticFrequency, + syncBandwidthInput, + scheduleSpectrumDraw, + onDecoderRegistryReady, latLonToMaidenhead, locatorToLatLon, haversineKm, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js index b7bfd423..28a9df3d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js @@ -1,10 +1,15 @@ // src/plugins/bookmarks.ts var bridge = window; +var trxState = bridge.trx.state; +var trxCore = bridge.trx.core; function bmEl(id) { const element = document.getElementById(id); if (!element) throw new Error(`Missing bookmark element #${id}`); return element; } +function bmOptionalEl(id) { + return document.getElementById(id); +} function errorMessage(error) { return error instanceof Error ? error.message : String(error); } @@ -34,7 +39,7 @@ function bmEsc(str) { return d.innerHTML; } function bmCanControl() { - return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control"; + return !trxState.authEnabled || trxState.authRole === "control"; } function bmSyncAccess() { const canCtrl = bmCanControl(); @@ -44,8 +49,7 @@ function bmSyncAccess() { if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; } function bmListScope() { - const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null; - return rig || "general"; + return trxState.lastActiveRigId || "general"; } async function bmFetchOverlay() { const overlayScope = bmListScope(); @@ -61,7 +65,7 @@ async function bmFetchOverlay() { if (typeof bridge.syncBookmarkMapLocators === "function") { bridge.syncBookmarkMapLocators(bmOverlayList); } - if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); + trxCore.scheduleSpectrumDraw(); } async function bmFetch(categoryFilter) { let url = "/bookmarks"; @@ -184,12 +188,12 @@ function bmChangePage(delta) { bmRender(bmFilteredList); } function bmReadDecoders() { - return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id); + return trxState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id); } function bmWriteDecoders(decoders) { const set = new Set(decoders || []); - (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { - const el = bmEl("bm-dec-" + d.id); + trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => { + const el = bmOptionalEl("bm-dec-" + d.id); if (el) el.checked = set.has(d.id); }); } @@ -197,7 +201,7 @@ function bmBuildDecoderCheckboxes() { const container = bmEl("bm-decoder-checkboxes"); if (!container) return; container.innerHTML = ""; - (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { + trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => { const label = document.createElement("label"); label.className = "bm-decoder-check"; label.innerHTML = ' ' + d.label; @@ -227,19 +231,17 @@ function bmCloseForm() { if (wrap) wrap.style.display = "none"; } function bmPrefillFromStatus() { - if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) { - bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz)); + const freqHz = trxState.lastFreqHz; + if (freqHz != null && Number.isFinite(freqHz)) { + bmEl("bm-freq").value = String(Math.round(freqHz)); } - if (typeof bridge.lastModeName === "string" && bridge.lastModeName) { - bmEl("bm-mode").value = bridge.lastModeName; + if (trxState.lastModeName) { + bmEl("bm-mode").value = trxState.lastModeName; } - if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { - bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); + if (trxState.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz)); } - const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => { - const btn = bmEl(d.id + "-decode-toggle-btn"); - return btn && btn.dataset.enabled === "true"; - }).map((d) => d.id); + const activeDecoders = trxState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id); bmWriteDecoders(activeDecoders); } async function bmSave(e) { @@ -320,55 +322,43 @@ async function bmDelete(id) { } function bmApply(bm) { try { - if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) { - bridge.modeEl.value = (bm.mode || "").toUpperCase(); + const modeEl = document.getElementById("mode"); + if (modeEl) { + modeEl.value = (bm.mode || "").toUpperCase(); } if (bm.bandwidth_hz) { - if (typeof bridge.currentBandwidthHz !== "undefined") { - bridge.currentBandwidthHz = bm.bandwidth_hz; - } - bridge.currentBandwidthHz = bm.bandwidth_hz; - if (typeof bridge.syncBandwidthInput === "function") { - bridge.syncBandwidthInput(bm.bandwidth_hz); - } + trxState.currentBandwidthHz = bm.bandwidth_hz; + trxCore.syncBandwidthInput(bm.bandwidth_hz); } - if (typeof bridge.applyLocalTunedFrequency === "function") { - if (typeof bridge._freqOptimisticSeq !== "undefined") { - ++bridge._freqOptimisticSeq; - bridge._freqOptimisticHz = bm.freq_hz; - } - bridge.applyLocalTunedFrequency(bm.freq_hz, true); - } - if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) { - bridge.scheduleSpectrumDraw(); + trxCore.armOptimisticFrequency(bm.freq_hz); + trxCore.applyLocalTunedFrequency(bm.freq_hz, true); + if (trxState.lastSpectrumData) { + trxCore.scheduleSpectrumDraw(); } const tunePromise = (async () => { - await bridge.trx?.modules?.vchan?.takeSchedulerControl(); - const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false; + await bridge.trx.modules.vchan?.takeSchedulerControl(); + const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false; if (!onVirtual) { - await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); + await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); } if (bm.bandwidth_hz) { - const bwHandledByVchan = await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; + const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; if (!bwHandledByVchan) { - await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); + await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); } } - if (typeof bridge.setRigFrequency === "function") { - await bridge.setRigFrequency(bm.freq_hz); - } else { - await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`); - } + trxCore.setRigFrequency(bm.freq_hz); })(); const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const modeUp = (bm.mode || "").toUpperCase(); - const allToggleDecoders = (bridge.decoderRegistry || []).filter( + const allToggleDecoders = trxState.decoderRegistry.filter( (d) => d.activation === "toggle" ); const decoderPromise = allToggleDecoders.length ? (async () => { let statusUrl = "/status"; - if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) { - statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId); + const rigId = trxState.lastActiveRigId; + if (rigId) { + statusUrl += "?remote=" + encodeURIComponent(rigId); } const statusResp = await fetch(statusUrl); if (!statusResp.ok) return; @@ -387,7 +377,7 @@ function bmApply(bm) { wanted = currentlyOn; } if (wanted !== currentlyOn) { - toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); } } if (toggles.length) await Promise.all(toggles); @@ -399,8 +389,6 @@ function bmApply(bm) { console.error("Failed to apply bookmark:", err); } } -bridge.trx ??= {}; -bridge.trx.modules ??= {}; bridge.trx.modules.bookmarks = { get overlayList() { return bmOverlayList; @@ -439,8 +427,8 @@ function bmUpdateSelectionUi() { function bmPopulateMoveTarget() { const sel = bmEl("bm-move-target"); if (!sel) return; - const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; - const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; + const rigIds = trxState.lastRigIds; + const displayNames = trxState.lastRigDisplayNames; const prev = sel.value; sel.innerHTML = ""; if (bmScope !== "general") { @@ -545,8 +533,8 @@ async function bmDeleteSelected() { function bmPopulateScopePicker() { const picker = bmEl("bm-scope-picker"); if (!picker) return; - const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; - const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; + const rigIds = trxState.lastRigIds; + const displayNames = trxState.lastRigDisplayNames; const prev = picker.value; while (picker.options.length > 1) picker.remove(1); rigIds.forEach((id) => { @@ -565,9 +553,7 @@ function bmPopulateScopePicker() { (function initBookmarks() { bmSyncAccess(); bmBuildDecoderCheckboxes(); - if (typeof bridge.onDecoderRegistryReady === "function") { - bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes); - } + trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); bmPopulateScopePicker(); const scopePicker = bmEl("bm-scope-picker"); if (scopePicker) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js index a3d42565..fd805c97 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js @@ -411,6 +411,15 @@ async function vchanInterceptBandwidth(bwHz) { await vchanSetChannelBandwidth(bwHz); return true; } +function vchanInterceptFrequency(freqHz) { + if (!vchanIsOnVirtual()) return false; + const core = vchanWindow.trx?.core; + const targetHz = Math.round(freqHz); + core?.armOptimisticFrequency(targetHz); + core?.applyLocalTunedFrequency(targetHz); + vchanSetChannelFreq(freqHz); + return true; +} vchanWindow.trx ??= {}; vchanWindow.trx.modules ??= {}; vchanWindow.trx.modules.vchan = { @@ -427,27 +436,10 @@ vchanWindow.trx.modules.vchan = { isOnVirtual: vchanIsOnVirtual, interceptMode: vchanInterceptMode, interceptBandwidth: vchanInterceptBandwidth, + interceptFrequency: vchanInterceptFrequency, takeSchedulerControl: vchanTakeSchedulerControl, releaseToScheduler: vchanToggleSchedulerRelease }; -(function() { - const original = vchanWindow.setRigFrequency; - vchanWindow.setRigFrequency = function(freqHz) { - if (vchanIsOnVirtual()) { - if (vchanWindow.applyLocalTunedFrequency) { - if (typeof vchanWindow._freqOptimisticSeq === "number") { - vchanWindow._freqOptimisticSeq += 1; - vchanWindow._freqOptimisticHz = Math.round(freqHz); - } - vchanWindow.applyLocalTunedFrequency(Math.round(freqHz)); - } - vchanSetChannelFreq(freqHz); - return; - } - void vchanTakeSchedulerControl(); - original?.(freqHz); - }; -})(); (function initSchedulerReleaseControl() { const btn = document.getElementById("scheduler-release-btn"); if (btn) { 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 fb0f8b9e..a6ee84e6 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 @@ -15,6 +15,7 @@ import { applyDecoderRegistryVisibility, decoderRegistry, loadDecoderRegistry, + onDecoderRegistryReady, } from "./core/decoder-registry.js"; import { fetchAuthSession, @@ -201,6 +202,7 @@ interface TrxModules { handleChannels(data: string): void; handleSession(data: string): void; interceptBandwidth(bandwidthHz: number): Promise; + interceptFrequency(frequencyHz: number): boolean; interceptMode(mode: string): Promise; isOnVirtual(): boolean; takeSchedulerControl(): Promise; @@ -217,6 +219,7 @@ interface TrxState { readonly lastRigDisplayNames: Record; readonly initialMapZoom: number; readonly decodeHistoryRetentionMin: number; + readonly authEnabled: boolean; readonly authRole: AuthRole | null; readonly decoderRegistry: typeof decoderRegistry; readonly sseSessionId: string | null; @@ -2476,12 +2479,26 @@ async function ensureTunedBandwidthCoverage(freqHz: number, bandwidthHz = covera let _freqOptimisticHz: number | null = null; let _freqOptimisticSeq = 0; +// Lazy features paint their own optimistic frequency before starting the tune +// round-trip (a bookmark updates the marker before mode and bandwidth settle). +// Arming the guard from there keeps SSE from snapping back to the stale value. +function armOptimisticFrequency(freqHz: number) { + if (!isFiniteNumber(freqHz)) return; + _freqOptimisticSeq += 1; + _freqOptimisticHz = Math.round(freqHz); +} + function setRigFrequency(freqHz: number) { const targetHz = Math.round(freqHz); if (!freqAllowed(targetHz)) { showUnsupportedFreqPopup(targetHz); throw new Error(`Unsupported frequency: ${targetHz}`); } + // A virtual channel owns its own tuning path: the plugin applies the + // optimistic update and posts to the channel API instead of the rig. + if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return; + // Scheduler control is fire-and-forget — don't block the freq change. + void window.trx?.modules.vchan?.takeSchedulerControl(); // Optimistic local update — visual is instant via CSS overlay + guard. const prevFreqHz = lastFreqHz; const seq = ++_freqOptimisticSeq; @@ -4822,6 +4839,7 @@ Object.defineProperties(trxState, { lastRigDisplayNames: { get() { return lastRigDisplayNames; } }, initialMapZoom: { get() { return initialMapZoom; } }, decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, + authEnabled: { get() { return authEnabled; } }, authRole: { get() { return authRole; } }, decoderRegistry: { get() { return decoderRegistry; } }, sseSessionId: { get() { return sseSessionId; } }, @@ -4855,6 +4873,8 @@ const trxCore = Object.freeze({ saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans, formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal, postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor, + setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency, + syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm, formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText, nearestBookmarkForHz, currentDecodeHistoryRetentionMs, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts index 88d14650..d842b6bf 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts @@ -43,42 +43,60 @@ interface VirtualChannelService { takeSchedulerControl(): Promise; } +/* Application state read through the `window.trx` host contract. This plugin + * is a separate bundle, so it cannot import the application module directly. */ +interface BookmarkHostState { + readonly authEnabled: boolean; + readonly authRole: string | null; + readonly lastActiveRigId: string | null; + readonly lastRigIds: string[]; + readonly lastRigDisplayNames: Record; + readonly lastFreqHz: number | null; + readonly lastModeName: string; + readonly lastSpectrumData: unknown; + currentBandwidthHz: number; + readonly decoderRegistry: readonly DecoderDescriptor[]; +} + +interface BookmarkHostCore { + postPath(path: string): Promise; + setRigFrequency(frequencyHz: number): void; + applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void; + armOptimisticFrequency(frequencyHz: number): void; + syncBandwidthInput(bandwidthHz: number): void; + scheduleSpectrumDraw(): void; + onDecoderRegistryReady(callback: () => void): void; +} + interface BookmarkBridge { - authEnabled?: boolean; - authRole?: string | null; - lastActiveRigId?: string | null; - lastRigIds?: string[]; - lastRigDisplayNames?: Record; - lastFreqHz?: number; - lastModeName?: string; - lastSpectrumData?: unknown; - currentBandwidthHz?: number; - modeEl?: HTMLSelectElement | null; - decoderRegistry?: DecoderDescriptor[]; - trx?: { modules?: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } }; + trx: { + state: BookmarkHostState; + core: BookmarkHostCore; + modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService }; + }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise; notify?(message: string, options: { kind: "error" }): void; }; syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void; - scheduleSpectrumDraw?(): void; - syncBandwidthInput?(bandwidthHz: number): void; - applyLocalTunedFrequency?(frequencyHz: number, force?: boolean): void; - setRigFrequency?(frequencyHz: number): Promise; - postPath(path: string): Promise; - onDecoderRegistryReady?(callback: () => void): void; - _freqOptimisticSeq?: number; - _freqOptimisticHz?: number; } type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement; const bridge = window as unknown as BookmarkBridge; +const trxState = bridge.trx.state; +const trxCore = bridge.trx.core; function bmEl(id: string): BookmarkElement { const element = document.getElementById(id); if (!element) throw new Error(`Missing bookmark element #${id}`); return element as BookmarkElement; } +/* Decoder checkboxes and decoder toggle buttons are built from the runtime + * registry, so their elements are legitimately absent for unbuilt decoders. */ +function bmOptionalEl(id: string): BookmarkElement | null { + return document.getElementById(id) as BookmarkElement | null; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -119,10 +137,7 @@ function bmEsc(str: unknown): string { } function bmCanControl() { - return ( - (typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) || - (typeof bridge.authRole !== "undefined" && bridge.authRole === "control") - ); + return !trxState.authEnabled || trxState.authRole === "control"; } // Show/hide the Add Bookmark / Select All buttons based on the current auth role. @@ -136,8 +151,7 @@ function bmSyncAccess() { /** The listing scope: always the active rig (to merge general + rig bookmarks). */ function bmListScope() { - const rig = (typeof bridge.lastActiveRigId !== "undefined") ? bridge.lastActiveRigId : null; - return rig || "general"; + return trxState.lastActiveRigId || "general"; } async function bmFetchOverlay() { @@ -154,7 +168,7 @@ async function bmFetchOverlay() { if (typeof bridge.syncBookmarkMapLocators === "function") { bridge.syncBookmarkMapLocators(bmOverlayList); } - if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); + trxCore.scheduleSpectrumDraw(); } async function bmFetch(categoryFilter: string): Promise { @@ -310,19 +324,19 @@ function bmChangePage(delta: number): void { // Read decoder checkboxes and return an array of selected decoder names. function bmReadDecoders(): string[] { - return (bridge.decoderRegistry || []) + return trxState.decoderRegistry .filter(d => d.bookmark_selectable) - .filter(d => bmEl("bm-dec-" + d.id)?.checked) + .filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked) .map(d => d.id); } // Set decoder checkboxes to match the given array. function bmWriteDecoders(decoders: readonly string[]): void { const set = new Set(decoders || []); - (bridge.decoderRegistry || []) + trxState.decoderRegistry .filter(d => d.bookmark_selectable) .forEach(d => { - const el = bmEl("bm-dec-" + d.id); + const el = bmOptionalEl("bm-dec-" + d.id); if (el) el.checked = set.has(d.id); }); } @@ -332,7 +346,7 @@ function bmBuildDecoderCheckboxes() { const container = bmEl("bm-decoder-checkboxes"); if (!container) return; container.innerHTML = ""; - (bridge.decoderRegistry || []) + trxState.decoderRegistry .filter(d => d.bookmark_selectable) .forEach(d => { const label = document.createElement("label"); @@ -372,23 +386,21 @@ function bmCloseForm() { } function bmPrefillFromStatus() { - // Use globals maintained by app.js (updated by SSE stream) - if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) { - bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz)); + // Read live rig state from the host contract (updated by the SSE stream). + const freqHz = trxState.lastFreqHz; + if (freqHz != null && Number.isFinite(freqHz)) { + bmEl("bm-freq").value = String(Math.round(freqHz)); } - if (typeof bridge.lastModeName === "string" && bridge.lastModeName) { - bmEl("bm-mode").value = bridge.lastModeName; + if (trxState.lastModeName) { + bmEl("bm-mode").value = trxState.lastModeName; } - if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { - bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); + if (trxState.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz)); } // Prefill decoder checkboxes from current toggle button state. - const activeDecoders = (bridge.decoderRegistry || []) + const activeDecoders = trxState.decoderRegistry .filter(d => d.bookmark_selectable && d.activation === "toggle") - .filter(d => { - const btn = bmEl(d.id + "-decode-toggle-btn"); - return btn && btn.dataset.enabled === "true"; - }) + .filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true") .map(d => d.id); bmWriteDecoders(activeDecoders); } @@ -478,58 +490,44 @@ async function bmDelete(id: string): Promise { function bmApply(bm: Bookmark): void { try { // --- Optimistic UI updates (instant, before any network round-trips) --- - if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) { - bridge.modeEl.value = (bm.mode || "").toUpperCase(); + const modeEl = document.getElementById("mode") as HTMLSelectElement | null; + if (modeEl) { + modeEl.value = (bm.mode || "").toUpperCase(); } if (bm.bandwidth_hz) { - if (typeof bridge.currentBandwidthHz !== "undefined") { - bridge.currentBandwidthHz = bm.bandwidth_hz; - } - bridge.currentBandwidthHz = bm.bandwidth_hz; - if (typeof bridge.syncBandwidthInput === "function") { - bridge.syncBandwidthInput(bm.bandwidth_hz); - } + trxState.currentBandwidthHz = bm.bandwidth_hz; + trxCore.syncBandwidthInput(bm.bandwidth_hz); } - if (typeof bridge.applyLocalTunedFrequency === "function") { - // Set optimistic guard before applying so SSE cannot snap back. - if (typeof bridge._freqOptimisticSeq !== "undefined") { - ++bridge._freqOptimisticSeq; - bridge._freqOptimisticHz = bm.freq_hz; - } - // Force display so the BW overlay is repositioned even when freq is unchanged. - bridge.applyLocalTunedFrequency(bm.freq_hz, true); - } - if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) { - bridge.scheduleSpectrumDraw(); + // Set optimistic guard before applying so SSE cannot snap back. + trxCore.armOptimisticFrequency(bm.freq_hz); + // Force display so the BW overlay is repositioned even when freq is unchanged. + trxCore.applyLocalTunedFrequency(bm.freq_hz, true); + if (trxState.lastSpectrumData) { + trxCore.scheduleSpectrumDraw(); } // Take scheduler control up front, then apply mode before bandwidth so a // late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz. const tunePromise = (async () => { - await bridge.trx?.modules?.vchan?.takeSchedulerControl(); + await bridge.trx.modules.vchan?.takeSchedulerControl(); - const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false; + const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false; if (!onVirtual) { - await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); + await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); } if (bm.bandwidth_hz) { const bwHandledByVchan = - await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; + await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; if (!bwHandledByVchan) { - await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); + await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); } } - // bridge.setRigFrequency is wrapped by vchan.js to redirect to the channel API - // when on a virtual channel, so this call works correctly in both cases. - // It also does its own optimistic update (bridge.applyLocalTunedFrequency) but - // that's a no-op since we already set the same value above. - if (typeof bridge.setRigFrequency === "function") { - await bridge.setRigFrequency(bm.freq_hz); - } else { - await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`); - } + // setRigFrequency redirects to the channel API when a virtual channel is + // active. It repeats the optimistic update applied above, which is a + // no-op because the value is unchanged. + trxCore.setRigFrequency(bm.freq_hz); })(); // Decoder toggles — fire-and-forget. // - Decoders incompatible with the new mode are always turned off @@ -539,13 +537,14 @@ function bmApply(bm: Bookmark): void { // alone. const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const modeUp = (bm.mode || "").toUpperCase(); - const allToggleDecoders = (bridge.decoderRegistry || []).filter(d => + const allToggleDecoders = trxState.decoderRegistry.filter(d => d.activation === "toggle" ); const decoderPromise = allToggleDecoders.length ? (async () => { let statusUrl = "/status"; - if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) { - statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId); + const rigId = trxState.lastActiveRigId; + if (rigId) { + statusUrl += "?remote=" + encodeURIComponent(rigId); } const statusResp = await fetch(statusUrl); if (!statusResp.ok) return; @@ -567,7 +566,7 @@ function bmApply(bm: Bookmark): void { wanted = currentlyOn; } if (wanted !== currentlyOn) { - toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); } } if (toggles.length) await Promise.all(toggles); @@ -582,8 +581,6 @@ function bmApply(bm: Bookmark): void { } } -bridge.trx ??= {}; -bridge.trx.modules ??= {}; bridge.trx.modules.bookmarks = { get overlayList() { return bmOverlayList; }, get overlayRevision() { return bmOverlayRevision; }, @@ -619,8 +616,8 @@ function bmUpdateSelectionUi() { function bmPopulateMoveTarget() { const sel = bmEl("bm-move-target"); if (!sel) return; - const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : []; - const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {}; + const rigIds = trxState.lastRigIds; + const displayNames = trxState.lastRigDisplayNames; const prev = sel.value; sel.innerHTML = ""; if (bmScope !== "general") { @@ -728,8 +725,8 @@ async function bmDeleteSelected() { function bmPopulateScopePicker() { const picker = bmEl("bm-scope-picker"); if (!picker) return; - const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : []; - const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {}; + const rigIds = trxState.lastRigIds; + const displayNames = trxState.lastRigDisplayNames; // Preserve current selection if still valid. const prev = picker.value; while (picker.options.length > 1) picker.remove(1); @@ -756,9 +753,7 @@ function bmPopulateScopePicker() { // Build decoder checkboxes from registry. The registry is fetched async // so we rebuild once it arrives to ensure checkboxes are present. bmBuildDecoderCheckboxes(); - if (typeof bridge.onDecoderRegistryReady === "function") { - bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes); - } + trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); // Scope picker bmPopulateScopePicker(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts index 6fe984b8..b616e45b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts @@ -31,8 +31,6 @@ interface VirtualChannelBridge { jogUnit?: number; rxActive?: boolean; _audioChannelOverride?: string | null; - _freqOptimisticSeq?: number; - _freqOptimisticHz?: number; renderRdsOverlays?: () => void; updateDocumentTitle?: (rds: unknown) => void; activeChannelRds?: () => unknown; @@ -47,10 +45,16 @@ interface VirtualChannelBridge { positionRdsPsOverlay?: () => void; mwDefaultsForMode?: (mode: string) => [number, ...unknown[]]; showHint?: (message: string, durationMs: number) => void; - applyLocalTunedFrequency?: (frequencyHz: number) => void; - setRigFrequency?: (frequencyHz: number) => void; refreshFreqDisplay?: () => void; - trx?: { modules?: { vchan?: VirtualChannelService } }; + trx?: { + core?: VirtualChannelCoreServices; + modules?: { vchan?: VirtualChannelService }; + }; +} + +interface VirtualChannelCoreServices { + applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void; + armOptimisticFrequency(frequencyHz: number): void; } interface VirtualChannelService { @@ -63,6 +67,7 @@ interface VirtualChannelService { isOnVirtual(): boolean; interceptMode(mode: string): Promise; interceptBandwidth(bandwidthHz: number): Promise; + interceptFrequency(frequencyHz: number): boolean; takeSchedulerControl(): Promise; releaseToScheduler(): Promise; } @@ -571,6 +576,19 @@ async function vchanInterceptBandwidth(bwHz: number): Promise { return true; } +// Called by setRigFrequency before it posts /set_freq. When a non-primary +// channel is active the change belongs to the channel API instead of the rig, +// so this applies the optimistic local update and reports the tune as handled. +function vchanInterceptFrequency(freqHz: number): boolean { + if (!vchanIsOnVirtual()) return false; + const core = vchanWindow.trx?.core; + const targetHz = Math.round(freqHz); + core?.armOptimisticFrequency(targetHz); + core?.applyLocalTunedFrequency(targetHz); + vchanSetChannelFreq(freqHz); + return true; +} + vchanWindow.trx ??= {}; vchanWindow.trx.modules ??= {}; vchanWindow.trx.modules.vchan = { @@ -583,34 +601,11 @@ vchanWindow.trx.modules.vchan = { isOnVirtual: vchanIsOnVirtual, interceptMode: vchanInterceptMode, interceptBandwidth: vchanInterceptBandwidth, + interceptFrequency: vchanInterceptFrequency, takeSchedulerControl: vchanTakeSchedulerControl, releaseToScheduler: vchanToggleSchedulerRelease, }; -// Wrap setRigFrequency (defined in app.js, loaded before this file) so that -// frequency changes are redirected to the active virtual channel instead of -// the server when on a non-primary channel. -(function() { - const original = vchanWindow.setRigFrequency; - vchanWindow.setRigFrequency = function(freqHz: number) { - if (vchanIsOnVirtual()) { - // Optimistic local update first, then fire-and-forget channel API. - if (vchanWindow.applyLocalTunedFrequency) { - if (typeof vchanWindow._freqOptimisticSeq === "number") { - vchanWindow._freqOptimisticSeq += 1; - vchanWindow._freqOptimisticHz = Math.round(freqHz); - } - vchanWindow.applyLocalTunedFrequency(Math.round(freqHz)); - } - vchanSetChannelFreq(freqHz); - return; - } - // Scheduler control is fire-and-forget — don't block the freq change. - void vchanTakeSchedulerControl(); - original?.(freqHz); - }; -})(); - (function initSchedulerReleaseControl() { const btn = document.getElementById("scheduler-release-btn"); if (btn) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs index 15b9440b..b1ae08ed 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs @@ -26,6 +26,48 @@ class ElementFixture { querySelector() { return null; } } +// Mirrors the `window.trx` host contract published by app.ts. The plugin is a +// separate bundle, so every application service it uses arrives this way. +function hostFixture(overrides = {}) { + const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0 }; + const state = { + authEnabled: false, + authRole: "control", + lastActiveRigId: null, + lastRigIds: [], + lastRigDisplayNames: {}, + lastFreqHz: 14_074_000, + lastModeName: "USB", + lastSpectrumData: null, + currentBandwidthHz: 2400, + decoderRegistry: [], + ...overrides, + }; + const core = { + postPath: async (path) => { calls.postPath.push(path); }, + setRigFrequency: (hz) => { calls.setRigFrequency.push(hz); }, + applyLocalTunedFrequency: (hz, force) => { calls.applyLocalTunedFrequency.push([hz, force]); }, + armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); }, + syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); }, + scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; }, + onDecoderRegistryReady: () => {}, + }; + return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls }; +} + +function documentFixture(element) { + return { + getElementById: element, + querySelector: () => new ElementFixture(), + querySelectorAll: () => [], + createElement: () => new ElementFixture(), + createTextNode: (text) => ({ textContent: text }), + addEventListener() {}, + }; +} + +const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8"); + test("bookmarks register an explicit typed service for application consumers", async () => { const elements = new Map(); const element = (id) => { @@ -36,33 +78,15 @@ test("bookmarks register an explicit typed service for application consumers", a element("bm-category-filter").options.push({ value: "" }); element("bm-mode-filter").options.push({ value: "" }); const bookmarks = [{ id: "one", name: "Local", freq_hz: 145_500_000, mode: "FM", scope: "general" }]; - const window = { - trx: { modules: {} }, - trxUi: { confirm: async () => true }, - decoderRegistry: [], - }; + const { window } = hostFixture(); const context = vm.createContext({ window, - document: { - getElementById: element, - querySelector: () => new ElementFixture(), - querySelectorAll: () => [], - createElement: () => new ElementFixture(), - createTextNode: (text) => ({ textContent: text }), - addEventListener() {}, - }, + document: documentFixture(element), fetch: async () => ({ ok: true, json: async () => bookmarks }), CSS: { escape: (value) => value }, Element: ElementFixture, - Set, - Map, - Array, - Number, - String, - Promise, console, }); - const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8"); new vm.Script(source).runInContext(context); await new Promise((resolve) => setImmediate(resolve)); @@ -73,3 +97,80 @@ test("bookmarks register an explicit typed service for application consumers", a assert.equal(typeof window.trx.modules.bookmarks.populateScopePicker, "function"); assert.equal(globalThis.bmOverlayList, undefined); }); + +test("applying a bookmark drives tuning through the host services", async () => { + const elements = new Map(); + const element = (id) => { + if (!elements.has(id)) elements.set(id, new ElementFixture()); + return elements.get(id); + }; + const { window, calls } = hostFixture({ + lastActiveRigId: "sdr", + decoderRegistry: [ + { id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"], bookmark_selectable: true }, + { id: "cw", label: "CW", activation: "toggle", active_modes: ["CW"], bookmark_selectable: true }, + ], + }); + const context = vm.createContext({ + window, + document: documentFixture(element), + fetch: async (url) => ({ + ok: true, + json: async () => (url.startsWith("/status") ? { ft8_decode_enabled: false, cw_decode_enabled: true } : []), + }), + CSS: { escape: (value) => value }, + Element: ElementFixture, + console, + }); + new vm.Script(source).runInContext(context); + await new Promise((resolve) => setImmediate(resolve)); + calls.postPath.length = 0; + + window.trx.modules.bookmarks.apply({ + id: "ft8-20m", name: "FT8 20m", freq_hz: 14_074_000, mode: "USB", bandwidth_hz: 3000, decoders: ["ft8"], + }); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + // Optimistic UI: the guard is armed before the display update so a stale SSE + // frame cannot snap the marker back while the tune is in flight. + assert.deepEqual(calls.armOptimisticFrequency, [14_074_000]); + assert.deepEqual(calls.applyLocalTunedFrequency, [[14_074_000, true]]); + assert.deepEqual(calls.syncBandwidthInput, [3000]); + assert.equal(window.trx.state.currentBandwidthHz, 3000); + assert.equal(element("mode").value, "USB"); + + // Rig commands: mode and bandwidth over postPath, frequency via the core + // tuning service so virtual-channel redirection still applies. + assert.deepEqual(calls.setRigFrequency, [14_074_000]); + assert.ok(calls.postPath.includes("/set_mode?mode=USB")); + assert.ok(calls.postPath.includes("/set_bandwidth?hz=3000")); + // ft8 is selected and off; cw is on but incompatible with USB. + assert.ok(calls.postPath.includes("/toggle_ft8_decode")); + assert.ok(calls.postPath.includes("/toggle_cw_decode")); +}); + +test("bookmark controls follow the host authentication state", async () => { + const elements = new Map(); + const element = (id) => { + if (!elements.has(id)) elements.set(id, new ElementFixture()); + return elements.get(id); + }; + const { window } = hostFixture({ authEnabled: true, authRole: "rx" }); + const context = vm.createContext({ + window, + document: documentFixture(element), + fetch: async () => ({ ok: true, json: async () => [] }), + CSS: { escape: (value) => value }, + Element: ElementFixture, + console, + }); + new vm.Script(source).runInContext(context); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(element("bm-add-btn").style.display, "none"); + + window.trx.state.authRole = "control"; + await window.trx.modules.bookmarks.fetch(""); + assert.equal(element("bm-add-btn").style.display, ""); +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs index b555f8e5..6636ad11 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs @@ -34,6 +34,8 @@ test("virtual channels expose typed SSE and interception boundaries", async () = assert.equal(typeof service.applyCapabilities, "function"); assert.equal(await service.interceptMode("USB"), false); assert.equal(await service.interceptBandwidth(2400), false); + // No virtual channel is active, so tuning stays with the physical rig. + assert.equal(service.interceptFrequency(14_074_000), false); assert.equal(window.vchanHandleSession, undefined); assert.equal(window.vchanInterceptBandwidth, undefined); }); -- 2.55.0 From 0d4c657b97568088c296c6d6458f8cd7ecb0694d Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sun, 2 Aug 2026 11:20:17 +0200 Subject: [PATCH 2/2] [fix](trx-frontend-http): route feature bundles through the host contract The bookmark fix addressed one instance of a defect the TypeScript migration left across the feature entries. app.js stopped being a classic script, so its top-level declarations are no longer shared globals, but the converted entries kept reading them as window properties that nothing publishes. Restore the broken behavior: - ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as undefined, so every positioned packet rendered an empty distance. - ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined postPath, so clear-history and decoder toggles threw. - scheduler read authRole as undefined, so the lazy-load path never self-initialized and the Settings tab opened an inert scheduler. - background-decode read authEnabled as undefined, so control gating fell back to role-only. - vchan read fifteen application values and services as undefined: mode and bandwidth sync, the out-of-band hint, RX audio restart, and the frequency field all silently no-opped on a virtual channel. - vchan wrapped window.refreshFreqDisplay, capturing an undefined original exactly as it did for setRigFrequency, so leaving a channel never restored the application's own frequency display. - _audioChannelOverride was a const that nothing could assign, so RX audio always subscribed to the primary channel. - ftx-family read fmtTime, a helper legacy ft8.js owned locally, so decode bar timestamps rendered empty. Declare the contract once in plugins/host.ts and import it from the feature entries, rather than restoring globals that docs/frontend-architecture.md excludes. trx.state gains jogUnit, rxActive and audioChannelOverride, and makes lastModeName writable; trx.core gains the tuning, RDS, WFM, jog and RX audio services the entries need. vchan interception moves to an interceptFreqDisplay service method that refreshFreqDisplay calls, matching the frequency, mode and bandwidth interception it already registers. Reading registry-built elements through a strict lookup is the same defect as in bookmarks: renderTimelineNeedle guards its result, but schedulerEl throws, so the now-initializing scheduler crashed on the timeline needle group that its own SVG creates. Feature tests move onto a shared host fixture, and entries that now import a common module are bundled through bundleEntry like the other shared-module entries. Covers scheduler self-initialization and the distance path that the bare window reads broke. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz Signed-off-by: Stan Grams --- docs/frontend-architecture.md | 9 ++ .../assets/web/generated/ais.js | 13 +- .../assets/web/generated/app.js | 24 +++ .../assets/web/generated/aprs.js | 14 +- .../assets/web/generated/background-decode.js | 6 +- .../assets/web/generated/bookmarks.js | 65 ++++---- .../assets/web/generated/chunk-KL66PICH.js | 9 ++ .../{chunk-SGMG5LG2.js => chunk-O2Y7YEVQ.js} | 14 +- .../assets/web/generated/cw.js | 14 +- .../assets/web/generated/ft2.js | 3 +- .../assets/web/generated/ft4.js | 3 +- .../assets/web/generated/ft8.js | 3 +- .../assets/web/generated/hf-aprs.js | 18 ++- .../assets/web/generated/sat.js | 8 +- .../assets/web/generated/scheduler.js | 21 ++- .../assets/web/generated/vchan.js | 97 ++++++------ .../assets/web/generated/vdes.js | 8 +- .../assets/web/generated/wefax.js | 8 +- .../assets/web/generated/wspr.js | 8 +- .../trx-frontend-http/frontend/src/README.md | 3 + .../trx-frontend-http/frontend/src/app.ts | 22 ++- .../frontend/src/plugins/ais.ts | 17 +-- .../frontend/src/plugins/aprs.ts | 20 +-- .../frontend/src/plugins/background-decode.ts | 5 +- .../frontend/src/plugins/bookmarks.ts | 101 ++++--------- .../frontend/src/plugins/cw.ts | 16 +- .../frontend/src/plugins/ftx-family.ts | 18 ++- .../frontend/src/plugins/hf-aprs.ts | 24 ++- .../frontend/src/plugins/host.ts | 72 +++++++++ .../frontend/src/plugins/sat.ts | 7 +- .../frontend/src/plugins/scheduler-types.ts | 4 - .../frontend/src/plugins/scheduler.ts | 22 ++- .../frontend/src/plugins/vchan.ts | 140 ++++++------------ .../frontend/src/plugins/vdes.ts | 10 +- .../frontend/src/plugins/wefax.ts | 7 +- .../frontend/src/plugins/wspr.ts | 7 +- .../frontend/tests/ais.test.mjs | 5 +- .../frontend/tests/aprs.test.mjs | 48 ++++++ .../frontend/tests/background-decode.test.mjs | 6 +- .../frontend/tests/bookmarks.test.mjs | 4 +- .../frontend/tests/cw.test.mjs | 6 +- .../frontend/tests/ftx-family.test.mjs | 3 + .../frontend/tests/hf-aprs.test.mjs | 3 +- .../frontend/tests/host-fixture.mjs | 66 +++++++++ .../frontend/tests/sat.test.mjs | 5 +- .../frontend/tests/scheduler.test.mjs | 76 +++++++++- .../frontend/tests/vchan.test.mjs | 7 +- .../frontend/tests/vdes.test.mjs | 5 +- .../frontend/tests/wefax.test.mjs | 6 +- .../frontend/tests/wspr.test.mjs | 5 +- 50 files changed, 687 insertions(+), 398 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-KL66PICH.js rename src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/{chunk-SGMG5LG2.js => chunk-O2Y7YEVQ.js} (96%) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md index 78c4e433..428cbd65 100644 --- a/docs/frontend-architecture.md +++ b/docs/frontend-architecture.md @@ -39,6 +39,15 @@ entry, so they use three intentional host namespaces: | `window.trxPluginRuntime` | Typed decoder registration and message dispatch | Runtime object is installed once; plugins register lifecycle handlers through its API. | | `window.trxUi` | Notifications, confirmations, tab accessibility, and control presentation | Installed once by `ui-core.ts`; consumers call methods but do not replace them. | +`frontend/src/plugins/host.ts` declares the typed view of `window.trx.state` +and `window.trx.core` that feature bundles consume. Feature entries import it +instead of re-deriving the contract, so a service that moves out of the +application entry is added in one place. Application state that a feature needs +belongs in `trx.state`, and shared behavior belongs in `trx.core`; a feature +that has to intercept application behavior registers a `modules.*` method the +application calls, as the virtual-channel entry does for tuning, mode, +bandwidth, and frequency display. + The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`, `trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js index 3a85696f..521191e7 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js @@ -1,6 +1,11 @@ +import { + hostCore, + hostState +} from "./chunk-KL66PICH.js"; + // src/plugins/ais.ts var aisWindow = window; -var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var escapeAisHtml = (input) => hostCore.escapeMapHtml(input); var aisStatus = document.getElementById("ais-status"); var aisMessagesEl = document.getElementById("ais-messages"); var aisFilterInput = document.getElementById("ais-filter"); @@ -119,10 +124,10 @@ function aisRouteText(msg) { return [msg.callsign, msg.destination].filter(Boolean).join(" -> "); } function aisDistanceText(msg) { - if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) { + if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) { return ""; } - const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.serverLon, msg.lat, msg.lon); + const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon); if (!Number.isFinite(distKm)) return ""; if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; return `${distKm.toFixed(1)} km from TRX`; @@ -303,7 +308,7 @@ document.getElementById("settings-clear-ais-history")?.addEventListener("click", void (async () => { if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await aisWindow.postPath?.("/clear_ais_decode"); + await hostCore.postPath("/clear_ais_decode"); resetAisHistoryView(); } catch (e) { console.error("AIS history clear failed", e); 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 d6b2abef..a380fbc3 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 @@ -3206,6 +3206,7 @@ function refreshWavelengthDisplay(hz) { wavelengthEl.textContent = formatWavelength(hz); } function refreshFreqDisplay() { + if (window.trx?.modules.vchan?.interceptFreqDisplay()) return; if (lastFreqHz == null || freqDirty) return; freqEl.value = formatFrequencyForStep(lastFreqHz, jogUnit); refreshWavelengthDisplay(lastFreqHz); @@ -5775,6 +5776,19 @@ Object.defineProperties(trxState, { } }, lastModeName: { get() { return lastModeName; + }, set(v) { + lastModeName = v; + } }, + jogUnit: { get() { + return jogUnit; + } }, + rxActive: { get() { + return rxActive; + } }, + audioChannelOverride: { get() { + return _audioChannelOverride; + }, set(v) { + _audioChannelOverride = v; } }, lastSpectrumData: { get() { return lastSpectrumData; @@ -5832,6 +5846,16 @@ var trxCore = Object.freeze({ syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, + formatFreqForStep: formatFrequencyForStep, + refreshFreqDisplay, + setJogDivisor, + mwDefaultsForMode, + resetRdsDisplay, + positionRdsPsOverlay, + updateWfmControls, + updateSdrSquelchControlVisibility, + startRxAudio, + stopRxAudio, latLonToMaidenhead, locatorToLatLon, haversineKm, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index f56e02b4..a0ccd2a7 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -8,12 +8,16 @@ import { renderAprsInfo, renderLocalAprsSymbol } from "./chunk-M2I6DH4X.js"; +import { + hostCore, + hostState +} from "./chunk-KL66PICH.js"; // src/plugins/aprs.ts var aprsWindow = window; -var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var escapeAprsHtml = (input) => hostCore.escapeMapHtml(input); var showAprsHint = (message, durationMs) => { - aprsWindow.showHint?.(message, durationMs); + hostCore.showHint(message, durationMs); }; var aprsStatus = document.getElementById("aprs-status"); var aprsPacketsEl = document.getElementById("aprs-packets"); @@ -58,8 +62,8 @@ function scheduleAprsBarUpdate() { }); } function aprsDistanceText(pkt) { - if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return ""; - const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon); + if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return ""; + const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon); if (!Number.isFinite(distKm)) return ""; if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; return `${distKm.toFixed(1)} km from TRX`; @@ -260,7 +264,7 @@ document.getElementById("settings-clear-aprs-history")?.addEventListener("click" void (async () => { if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await aprsWindow.postPath?.("/clear_aprs_decode"); + await hostCore.postPath("/clear_aprs_decode"); resetAprsHistoryView(); } catch (e) { console.error("APRS history clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js index 88d9afec..24c09973 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js @@ -1,3 +1,7 @@ +import { + hostState +} from "./chunk-KL66PICH.js"; + // src/plugins/background-decode.ts var bgdWindow = window; (function() { @@ -104,7 +108,7 @@ var bgdWindow = window; } setCheckbox("background-decode-enabled", currentConfig.enabled); renderBookmarkChecklist(); - const isControl = backgroundDecodeRole === "control" || bgdWindow.authEnabled === false; + const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false; const panel = document.getElementById("background-decode-panel"); if (panel) { panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js index 28a9df3d..2fd2631a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js @@ -1,7 +1,10 @@ +import { + hostCore, + hostState +} from "./chunk-KL66PICH.js"; + // src/plugins/bookmarks.ts var bridge = window; -var trxState = bridge.trx.state; -var trxCore = bridge.trx.core; function bmEl(id) { const element = document.getElementById(id); if (!element) throw new Error(`Missing bookmark element #${id}`); @@ -39,7 +42,7 @@ function bmEsc(str) { return d.innerHTML; } function bmCanControl() { - return !trxState.authEnabled || trxState.authRole === "control"; + return !hostState.authEnabled || hostState.authRole === "control"; } function bmSyncAccess() { const canCtrl = bmCanControl(); @@ -49,7 +52,7 @@ function bmSyncAccess() { if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; } function bmListScope() { - return trxState.lastActiveRigId || "general"; + return hostState.lastActiveRigId || "general"; } async function bmFetchOverlay() { const overlayScope = bmListScope(); @@ -65,7 +68,7 @@ async function bmFetchOverlay() { if (typeof bridge.syncBookmarkMapLocators === "function") { bridge.syncBookmarkMapLocators(bmOverlayList); } - trxCore.scheduleSpectrumDraw(); + hostCore.scheduleSpectrumDraw(); } async function bmFetch(categoryFilter) { let url = "/bookmarks"; @@ -188,11 +191,11 @@ function bmChangePage(delta) { bmRender(bmFilteredList); } function bmReadDecoders() { - return trxState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id); + return hostState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id); } function bmWriteDecoders(decoders) { const set = new Set(decoders || []); - trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => { + hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => { const el = bmOptionalEl("bm-dec-" + d.id); if (el) el.checked = set.has(d.id); }); @@ -201,7 +204,7 @@ function bmBuildDecoderCheckboxes() { const container = bmEl("bm-decoder-checkboxes"); if (!container) return; container.innerHTML = ""; - trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => { + hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => { const label = document.createElement("label"); label.className = "bm-decoder-check"; label.innerHTML = ' ' + d.label; @@ -231,17 +234,17 @@ function bmCloseForm() { if (wrap) wrap.style.display = "none"; } function bmPrefillFromStatus() { - const freqHz = trxState.lastFreqHz; + const freqHz = hostState.lastFreqHz; if (freqHz != null && Number.isFinite(freqHz)) { bmEl("bm-freq").value = String(Math.round(freqHz)); } - if (trxState.lastModeName) { - bmEl("bm-mode").value = trxState.lastModeName; + if (hostState.lastModeName) { + bmEl("bm-mode").value = hostState.lastModeName; } - if (trxState.currentBandwidthHz > 0) { - bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz)); + if (hostState.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz)); } - const activeDecoders = trxState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id); + const activeDecoders = hostState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id); bmWriteDecoders(activeDecoders); } async function bmSave(e) { @@ -327,36 +330,36 @@ function bmApply(bm) { modeEl.value = (bm.mode || "").toUpperCase(); } if (bm.bandwidth_hz) { - trxState.currentBandwidthHz = bm.bandwidth_hz; - trxCore.syncBandwidthInput(bm.bandwidth_hz); + hostState.currentBandwidthHz = bm.bandwidth_hz; + hostCore.syncBandwidthInput(bm.bandwidth_hz); } - trxCore.armOptimisticFrequency(bm.freq_hz); - trxCore.applyLocalTunedFrequency(bm.freq_hz, true); - if (trxState.lastSpectrumData) { - trxCore.scheduleSpectrumDraw(); + hostCore.armOptimisticFrequency(bm.freq_hz); + hostCore.applyLocalTunedFrequency(bm.freq_hz, true); + if (hostState.lastSpectrumData) { + hostCore.scheduleSpectrumDraw(); } const tunePromise = (async () => { await bridge.trx.modules.vchan?.takeSchedulerControl(); const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false; if (!onVirtual) { - await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); + await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); } if (bm.bandwidth_hz) { const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; if (!bwHandledByVchan) { - await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); + await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); } } - trxCore.setRigFrequency(bm.freq_hz); + hostCore.setRigFrequency(bm.freq_hz); })(); const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const modeUp = (bm.mode || "").toUpperCase(); - const allToggleDecoders = trxState.decoderRegistry.filter( + const allToggleDecoders = hostState.decoderRegistry.filter( (d) => d.activation === "toggle" ); const decoderPromise = allToggleDecoders.length ? (async () => { let statusUrl = "/status"; - const rigId = trxState.lastActiveRigId; + const rigId = hostState.lastActiveRigId; if (rigId) { statusUrl += "?remote=" + encodeURIComponent(rigId); } @@ -377,7 +380,7 @@ function bmApply(bm) { wanted = currentlyOn; } if (wanted !== currentlyOn) { - toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); } } if (toggles.length) await Promise.all(toggles); @@ -427,8 +430,8 @@ function bmUpdateSelectionUi() { function bmPopulateMoveTarget() { const sel = bmEl("bm-move-target"); if (!sel) return; - const rigIds = trxState.lastRigIds; - const displayNames = trxState.lastRigDisplayNames; + const rigIds = hostState.lastRigIds; + const displayNames = hostState.lastRigDisplayNames; const prev = sel.value; sel.innerHTML = ""; if (bmScope !== "general") { @@ -533,8 +536,8 @@ async function bmDeleteSelected() { function bmPopulateScopePicker() { const picker = bmEl("bm-scope-picker"); if (!picker) return; - const rigIds = trxState.lastRigIds; - const displayNames = trxState.lastRigDisplayNames; + const rigIds = hostState.lastRigIds; + const displayNames = hostState.lastRigDisplayNames; const prev = picker.value; while (picker.options.length > 1) picker.remove(1); rigIds.forEach((id) => { @@ -553,7 +556,7 @@ function bmPopulateScopePicker() { (function initBookmarks() { bmSyncAccess(); bmBuildDecoderCheckboxes(); - trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); + hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); bmPopulateScopePicker(); const scopePicker = bmEl("bm-scope-picker"); if (scopePicker) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-KL66PICH.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-KL66PICH.js new file mode 100644 index 00000000..a0cc2d85 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-KL66PICH.js @@ -0,0 +1,9 @@ +// src/plugins/host.ts +var host = window; +var hostState = host.trx.state; +var hostCore = host.trx.core; + +export { + hostState, + hostCore +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-SGMG5LG2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-O2Y7YEVQ.js similarity index 96% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-SGMG5LG2.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-O2Y7YEVQ.js index 1fa6534f..bbb4b0db 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-SGMG5LG2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-O2Y7YEVQ.js @@ -1,5 +1,13 @@ +import { + hostCore +} from "./chunk-KL66PICH.js"; + // src/plugins/ftx-family.ts var bridge = window; +function formatBarTime(timestampMs) { + if (!timestampMs) return "--:--:--"; + return new Date(timestampMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} function finiteNumber(value) { const number = typeof value === "number" ? value : Number(value); return Number.isFinite(number) ? number : null; @@ -198,7 +206,7 @@ function initializeFtxDecoder(config) { let html = ""; for (const message of recent) { const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); - const time = timestamp === null ? "" : `${bridge.fmtTime?.(timestamp) ?? ""}`; + const time = timestamp === null ? "" : `${formatBarTime(timestamp)}`; const snr = finiteNumber(message.snr_db); const delta = finiteNumber(message.dt_s); const frequency = displayFrequency(message.freq_hz); @@ -243,7 +251,7 @@ function initializeFtxDecoder(config) { void (async () => { try { await bridge.takeSchedulerControlForDecoderDisable?.(toggle); - await bridge.postPath?.(`/toggle_${id}_decode`); + await hostCore.postPath(`/toggle_${id}_decode`); } catch (error) { console.error(`${label} toggle failed`, error); } @@ -253,7 +261,7 @@ function initializeFtxDecoder(config) { void (async () => { if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return; try { - await bridge.postPath?.(`/clear_${id}_decode`); + await hostCore.postPath(`/clear_${id}_decode`); reset(); } catch (error) { console.error(`${label} history clear failed`, error); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js index 514dd896..0a7e7a3e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js @@ -1,3 +1,7 @@ +import { + hostCore +} from "./chunk-KL66PICH.js"; + // src/plugins/cw.ts var cwWindow = window; var cwStatusEl = document.getElementById("cw-status"); @@ -25,7 +29,7 @@ var cwBarCurrentLine = null; var cwBarDismissedAtMs = 0; var cwAutoLocalOverride = null; function escapeCwHtml(input) { - return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); + return hostCore.escapeMapHtml(input); } function applyCwAutoUi(enabled) { if (cwAutoInput) cwAutoInput.checked = enabled; @@ -246,7 +250,7 @@ async function setCwTone(tone, { syncInput = true } = {}) { cwToneInput.value = String(clamped); } try { - await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); + await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); } catch (e) { console.error("CW tone set failed", e); } @@ -259,7 +263,7 @@ if (cwAutoInput) { cwAutoLocalOverride = enabled; applyCwAutoUi(enabled); try { - await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); + await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); drawCwTonePicker(); } catch (error) { console.error("CW auto toggle failed", error); @@ -276,7 +280,7 @@ if (cwWpmInput) { const wpm = clampCwWpm(cwWpmInput.value); cwWpmInput.value = String(wpm); try { - await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); + await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); } catch (error) { console.error("CW WPM set failed", error); } @@ -312,7 +316,7 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click", void (async () => { if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await cwWindow.postPath?.("/clear_cw_decode"); + await hostCore.postPath("/clear_cw_decode"); resetCwHistoryView(); } catch (error) { console.error("CW history clear failed", error); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js index bb0f2236..9b1997d8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -1,6 +1,7 @@ import { initializeFtxDecoder -} from "./chunk-SGMG5LG2.js"; +} from "./chunk-O2Y7YEVQ.js"; +import "./chunk-KL66PICH.js"; // src/plugins/ft2.ts initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js index bafde67f..9c7839bf 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -1,6 +1,7 @@ import { initializeFtxDecoder -} from "./chunk-SGMG5LG2.js"; +} from "./chunk-O2Y7YEVQ.js"; +import "./chunk-KL66PICH.js"; // src/plugins/ft4.ts initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js index d9d961ce..d18bcde6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -2,7 +2,8 @@ import { initializeFt8FamilyBar, initializeFtxDecoder, installFtxCompatibilityHelpers -} from "./chunk-SGMG5LG2.js"; +} from "./chunk-O2Y7YEVQ.js"; +import "./chunk-KL66PICH.js"; // src/plugins/ft8.ts installFtxCompatibilityHelpers(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js index aa270dfe..6b6d80a7 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -8,10 +8,14 @@ import { renderAprsInfo, renderLocalAprsSymbol } from "./chunk-M2I6DH4X.js"; +import { + hostCore, + hostState +} from "./chunk-KL66PICH.js"; // src/plugins/hf-aprs.ts var hfAprsWindow = window; -var escapeHfAprsHtml = (input) => hfAprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input); var hfAprsStatus = document.getElementById("hf-aprs-status"); var hfAprsPacketsEl = document.getElementById("hf-aprs-packets"); var hfAprsFilterInput = document.getElementById("hf-aprs-filter"); @@ -44,8 +48,8 @@ function scheduleHfAprsHistoryRender() { renderHfAprsHistory(); } function hfAprsDistanceText(pkt) { - if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return ""; - const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon); + if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return ""; + const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon); if (!Number.isFinite(distKm)) return ""; if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; return `${distKm.toFixed(1)} km from TRX`; @@ -134,10 +138,10 @@ function renderHfAprsRow(pkt, isFresh) { const clipboard = Reflect.get(navigator, "clipboard"); if (clipboard) { await clipboard.writeText(raw); - hfAprsWindow.showHint?.("Coordinates copied", 1200); + hostCore.showHint("Coordinates copied", 1200); } } catch { - hfAprsWindow.showHint?.("Copy failed", 1500); + hostCore.showHint("Copy failed", 1500); } })(); }); @@ -201,7 +205,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => { try { await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn); - await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode"); + await hostCore.postPath("/toggle_hf_aprs_decode"); } catch (e) { console.error("HF APRS toggle failed", e); } @@ -211,7 +215,7 @@ document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("cli void (async () => { if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await hfAprsWindow.postPath?.("/clear_hf_aprs_decode"); + await hostCore.postPath("/clear_hf_aprs_decode"); resetHfAprsHistoryView(); } catch (e) { console.error("HF APRS history clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js index 6238a038..44bd3940 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js @@ -1,3 +1,7 @@ +import { + hostCore +} from "./chunk-KL66PICH.js"; + // src/plugins/sat.ts var satWindow = window; var satDom = { @@ -243,7 +247,7 @@ lrptDecodeToggleBtn?.addEventListener("click", () => { void (async () => { try { await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn); - await satWindow.postPath?.("/toggle_lrpt_decode"); + await hostCore.postPath("/toggle_lrpt_decode"); } catch (e) { console.error("LRPT toggle failed", e); } @@ -264,7 +268,7 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click", void (async () => { if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await satWindow.postPath?.("/clear_lrpt_decode"); + await hostCore.postPath("/clear_lrpt_decode"); resetSatHistoryView(); } catch (e) { console.error("Weather satellite history clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js index d1a7b1f4..dfd4f77d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js @@ -1,3 +1,7 @@ +import { + hostState +} from "./chunk-KL66PICH.js"; + // src/plugins/scheduler.ts var schedulerWindow = window; var wiredElements = /* @__PURE__ */ new WeakSet(); @@ -6,6 +10,9 @@ function schedulerEl(id) { if (!element) throw new Error(`Missing scheduler element #${id}`); return element; } +function schedulerOptionalEl(id) { + return document.getElementById(id); +} (function() { "use strict"; let schedulerRole = null; @@ -359,8 +366,8 @@ function schedulerEl(id) { renderSatelliteSection(); if (mode === "grayline" && currentConfig && currentConfig.grayline) { const gl = currentConfig.grayline; - const lat = gl.lat ?? schedulerWindow.serverLat ?? ""; - const lon = gl.lon ?? schedulerWindow.serverLon ?? ""; + const lat = gl.lat ?? hostState.serverLat ?? ""; + const lon = gl.lon ?? hostState.serverLon ?? ""; setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : ""); const gridEl = schedulerEl("scheduler-gl-grid"); @@ -373,8 +380,8 @@ function schedulerEl(id) { renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id); renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); } else if (mode === "grayline") { - const lat = schedulerWindow.serverLat ?? ""; - const lon = schedulerWindow.serverLon ?? ""; + const lat = hostState.serverLat ?? ""; + const lon = hostState.serverLon ?? ""; setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : ""); const gridEl2 = schedulerEl("scheduler-gl-grid"); @@ -624,7 +631,7 @@ function schedulerEl(id) { return ''; } function renderTimelineNeedle() { - const g = schedulerEl("sch-timeline-needle-g"); + const g = schedulerOptionalEl("sch-timeline-needle-g"); if (g) g.innerHTML = timelineNeedleSvg(); } function schInlineEdit(tr, entry, idx) { @@ -1213,8 +1220,8 @@ function schedulerEl(id) { markDirty: markSchedulerDirty }; schedulerWindow.trx.modules.scheduler = schedulerService; - if (schedulerWindow.authRole != null) { - initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole); + if (hostState.authRole != null) { + initScheduler(hostState.lastActiveRigId, hostState.authRole); wireSchedulerEvents(); } })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js index fd805c97..409eb20b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js @@ -1,3 +1,8 @@ +import { + hostCore, + hostState +} from "./chunk-KL66PICH.js"; + // src/plugins/vchan.ts var vchanWindow = window; var vchanSessionId = null; @@ -63,7 +68,7 @@ function vchanStartSchedulerReleasePolling() { } async function vchanToggleSchedulerRelease() { if (!vchanSessionId) return; - const rigId = vchanRigId || vchanWindow.lastActiveRigId || null; + const rigId = vchanRigId || hostState.lastActiveRigId || null; try { const resp = await fetch("/scheduler-control", { method: "PUT", @@ -161,14 +166,12 @@ function vchanRender() { }); picker.appendChild(addBtn); vchanSyncAccentUI(); - if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { - vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); - } + hostCore.updateDocumentTitle(hostCore.activeChannelRds()); vchanRenderSchedulerRelease(); } async function vchanAllocate() { if (!vchanSessionId || !vchanRigId) return; - const freqHz = typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0 ? vchanWindow.lastFreqHz : 0; + const freqHz = typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0 ? hostState.lastFreqHz : 0; const modeEl = document.getElementById("mode"); const mode = modeEl ? modeEl.value || "USB" : "USB"; try { @@ -251,11 +254,11 @@ async function vchanSubscribe(channelId) { } function vchanReconnectAudio() { const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; - vchanWindow._audioChannelOverride = ch?.id ?? null; - if (!vchanWindow.rxActive) return; - vchanWindow.stopRxAudio?.(); + hostState.audioChannelOverride = ch?.id ?? null; + if (!hostState.rxActive) return; + hostCore.stopRxAudio(); setTimeout(() => { - vchanWindow.startRxAudio?.(); + hostCore.startRxAudio(); }, 300); } function vchanApplyCapabilities(caps) { @@ -276,11 +279,7 @@ function vchanUpdateFreqDisplay() { if (!ch) return; const el = document.getElementById("freq"); if (!el) return; - if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") { - el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit); - } else { - el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, ""); - } + el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit); } function vchanSyncModeDisplay() { const modeEl = document.getElementById("mode"); @@ -290,21 +289,21 @@ function vchanSyncModeDisplay() { if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); } const modeUpper = (modeEl.value || "").toUpperCase(); - if (typeof vchanWindow.lastModeName === "string") { - if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") { - vchanWindow.setJogDivisor?.(10); - vchanWindow.resetRdsDisplay?.(); - } else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") { - vchanWindow.resetRdsDisplay?.(); + if (typeof hostState.lastModeName === "string") { + if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") { + hostCore.setJogDivisor(10); + hostCore.resetRdsDisplay(); + } else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") { + hostCore.resetRdsDisplay(); } - vchanWindow.lastModeName = modeUpper; + hostState.lastModeName = modeUpper; } - vchanWindow.updateWfmControls?.(); - vchanWindow.updateSdrSquelchControlVisibility?.(); + hostCore.updateWfmControls(); + hostCore.updateSdrSquelchControlVisibility(); if (vchanWindow.refreshRdsUi) { vchanWindow.refreshRdsUi(); } else { - vchanWindow.positionRdsPsOverlay?.(); + hostCore.positionRdsPsOverlay(); } } function vchanSyncBwDisplay() { @@ -314,12 +313,12 @@ function vchanSyncBwDisplay() { const bwEl = document.getElementById("spectrum-bw-input"); if (!bwEl) return; let bwHz = ch.bandwidth_hz || 0; - if (bwHz === 0 && vchanWindow.mwDefaultsForMode) { - bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0; + if (bwHz === 0) { + bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0; } if (bwHz > 0) { bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, ""); - vchanWindow.currentBandwidthHz = bwHz; + hostState.currentBandwidthHz = bwHz; } } function vchanSyncAccentUI() { @@ -333,25 +332,20 @@ function vchanSyncAccentUI() { vchanSyncModeDisplay(); vchanSyncBwDisplay(); } else { - origRefreshFreqDisplay?.(); - } - if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { - vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); + hostCore.refreshFreqDisplay(); } + hostCore.updateDocumentTitle(hostCore.activeChannelRds()); } -var origRefreshFreqDisplay = null; function vchanSetChannelFreq(freqHz) { if (!vchanRigId || !vchanActiveId) return; - if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) { - const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2; - const center = vchanWindow.lastSpectrumData.center_hz; + if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) { + const halfSpan = hostState.lastSpectrumData.sample_rate / 2; + const center = hostState.lastSpectrumData.center_hz; if (Math.abs(freqHz - center) > halfSpan) { - if (vchanWindow.showHint) { - vchanWindow.showHint( - `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, - 3e3 - ); - } + hostCore.showHint( + `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, + 3e3 + ); return; } } @@ -413,13 +407,17 @@ async function vchanInterceptBandwidth(bwHz) { } function vchanInterceptFrequency(freqHz) { if (!vchanIsOnVirtual()) return false; - const core = vchanWindow.trx?.core; const targetHz = Math.round(freqHz); - core?.armOptimisticFrequency(targetHz); - core?.applyLocalTunedFrequency(targetHz); + hostCore.armOptimisticFrequency(targetHz); + hostCore.applyLocalTunedFrequency(targetHz); vchanSetChannelFreq(freqHz); return true; } +function vchanInterceptFreqDisplay() { + if (!vchanIsOnVirtual()) return false; + vchanUpdateFreqDisplay(); + return true; +} vchanWindow.trx ??= {}; vchanWindow.trx.modules ??= {}; vchanWindow.trx.modules.vchan = { @@ -437,6 +435,7 @@ vchanWindow.trx.modules.vchan = { interceptMode: vchanInterceptMode, interceptBandwidth: vchanInterceptBandwidth, interceptFrequency: vchanInterceptFrequency, + interceptFreqDisplay: vchanInterceptFreqDisplay, takeSchedulerControl: vchanTakeSchedulerControl, releaseToScheduler: vchanToggleSchedulerRelease }; @@ -450,13 +449,3 @@ vchanWindow.trx.modules.vchan = { vchanStartSchedulerReleasePolling(); vchanRenderSchedulerRelease(); })(); -(function() { - origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null; - vchanWindow.refreshFreqDisplay = function() { - if (vchanIsOnVirtual()) { - vchanUpdateFreqDisplay(); - return; - } - origRefreshFreqDisplay?.(); - }; -})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js index eb756fcf..7f0e0574 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js @@ -1,6 +1,10 @@ +import { + hostCore +} from "./chunk-KL66PICH.js"; + // src/plugins/vdes.ts var vdesWindow = window; -var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var escapeVdesHtml = (input) => hostCore.escapeMapHtml(input); var vdesStatus = document.getElementById("vdes-status"); var vdesMessagesEl = document.getElementById("vdes-messages"); var vdesFilterInput = document.getElementById("vdes-filter"); @@ -231,7 +235,7 @@ document.getElementById("settings-clear-vdes-history")?.addEventListener("click" void (async () => { if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await vdesWindow.postPath?.("/clear_vdes_decode"); + await hostCore.postPath("/clear_vdes_decode"); resetVdesHistoryView(); } catch (e) { console.error("VDES history clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js index 09bae50f..0b09f26b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js @@ -1,3 +1,7 @@ +import { + hostCore +} from "./chunk-KL66PICH.js"; + // src/plugins/wefax.ts var wefaxWindow = window; var wefaxDom = { @@ -299,7 +303,7 @@ if (wefaxDom.toggleBtn) { if (wefaxWindow.takeSchedulerControlForDecoderDisable) { await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton); } - await wefaxWindow.postPath?.("/toggle_wefax_decode"); + await hostCore.postPath("/toggle_wefax_decode"); } catch (e) { console.error("WEFAX toggle failed", e); } @@ -310,7 +314,7 @@ if (wefaxDom.clearBtn) { wefaxDom.clearBtn.addEventListener("click", () => { void (async () => { try { - await wefaxWindow.postPath?.("/clear_wefax_decode"); + await hostCore.postPath("/clear_wefax_decode"); resetWefaxHistoryView(); } catch (e) { console.error("WEFAX clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js index 60f26a9f..df393e5d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js @@ -1,3 +1,7 @@ +import { + hostCore +} from "./chunk-KL66PICH.js"; + // src/plugins/wspr.ts var wsprWindow = window; var wsprStatus = document.getElementById("wspr-status"); @@ -229,7 +233,7 @@ wsprDecodeToggleBtn?.addEventListener("click", () => { void (async () => { try { await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); - await wsprWindow.postPath?.("/toggle_wspr_decode"); + await hostCore.postPath("/toggle_wspr_decode"); } catch (error) { console.error("WSPR toggle failed", error); } @@ -239,7 +243,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click" void (async () => { if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await wsprWindow.postPath?.("/clear_wspr_decode"); + await hostCore.postPath("/clear_wspr_decode"); resetWsprHistoryView(); } catch (error) { console.error("WSPR history clear failed", error); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md index 3b997837..330fef9a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md @@ -42,6 +42,9 @@ source change. - `core/` contains dependency-light shared helpers. - `features/` contains application behavior grouped by responsibility. - `plugins/` contains lazy decoder and high-coupling feature entries. +- `plugins/host.ts` declares the typed `window.trx` state and services that + feature bundles read; feature entries import it rather than reaching for bare + `window` properties, which the module graph does not publish. - Files under `assets/web/vendor/` are vendored JavaScript and are not part of the TypeScript migration. 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 a6ee84e6..33c0caa1 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 @@ -203,6 +203,7 @@ interface TrxModules { handleSession(data: string): void; interceptBandwidth(bandwidthHz: number): Promise; interceptFrequency(frequencyHz: number): boolean; + interceptFreqDisplay(): boolean; interceptMode(mode: string): Promise; isOnVirtual(): boolean; takeSchedulerControl(): Promise; @@ -236,7 +237,10 @@ interface TrxState { readonly aisVesselUrlBase: string | null; readonly serverRigs: RigListItem[]; readonly serverActiveRigId: string | null; - readonly lastModeName: string; + lastModeName: string; + readonly jogUnit: number; + readonly rxActive: boolean; + audioChannelOverride: string | null; readonly lastSpectrumData: SpectrumFrame | null; readonly lastSpectrumRenderData: SpectrumFrame | null; currentBandwidthHz: number; @@ -1987,6 +1991,9 @@ function refreshWavelengthDisplay(hz: number) { } function refreshFreqDisplay() { + // On a virtual channel the field must show that channel's frequency; SSE + // rig-state updates would otherwise overwrite it with the primary channel. + if (window.trx?.modules.vchan?.interceptFreqDisplay()) return; if (lastFreqHz == null || freqDirty) return; freqEl.value = formatFreqForStep(lastFreqHz, jogUnit); refreshWavelengthDisplay(lastFreqHz); @@ -4856,7 +4863,10 @@ Object.defineProperties(trxState, { aisVesselUrlBase: { get() { return aisVesselUrlBase; } }, serverRigs: { get() { return serverRigs; } }, serverActiveRigId: { get() { return serverActiveRigId; } }, - lastModeName: { get() { return lastModeName; } }, + lastModeName: { get() { return lastModeName; }, set(v: string) { lastModeName = v; } }, + jogUnit: { get() { return jogUnit; } }, + rxActive: { get() { return rxActive; } }, + audioChannelOverride: { get() { return _audioChannelOverride; }, set(v: string | null) { _audioChannelOverride = v; } }, lastSpectrumData: { get() { return lastSpectrumData; } }, lastSpectrumRenderData: { get() { return lastSpectrumRenderData; } }, currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v: number) { currentBandwidthHz = v; window.currentBandwidthHz = v; } }, @@ -4875,6 +4885,9 @@ const trxCore = Object.freeze({ postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor, setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency, syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, + formatFreqForStep, refreshFreqDisplay, setJogDivisor, mwDefaultsForMode, + resetRdsDisplay, positionRdsPsOverlay, updateWfmControls, + updateSdrSquelchControlVisibility, startRxAudio, stopRxAudio, latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm, formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText, nearestBookmarkForHz, currentDecodeHistoryRetentionMs, @@ -5495,8 +5508,9 @@ function extractAudioFrameChannels(frame: AudioData) { return out; } -// Optional channel_id injected by vchan.js when connecting to a virtual channel. -const _audioChannelOverride: string | null = null; +// Optional channel_id set by the vchan feature when a virtual channel is +// active, so RX audio subscribes to that channel instead of the primary one. +let _audioChannelOverride: string | null = null; /** Schedule decoded PCM channels for playback via Web Audio API. */ function scheduleDecodedAudio(channelData: Float32Array[], frameCount: number, sampleRate: number) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts index 342fd0d5..6a7034ed 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore, hostState } from "./host.js"; + export {}; import type { PluginRuntimeWindow } from "./runtime-contract"; @@ -27,22 +29,15 @@ interface AisChannelInfo { label: string; badgeClass: string; freqText: string } interface AisBridge { getDecodeHistoryRetentionMs?: () => number; trxScheduleUiFrameJob?: (key: string, job: () => void) => void; - escapeMapHtml?: (input: string) => string; buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null; - serverLat?: number | null; - serverLon?: number | null; - haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number; aisMapAddVessel?: (message: AisMessage) => void; clearMapMarkersByType?: (type: string) => void; - postPath?: (path: string) => Promise; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; updateAisBar?: () => void; clearAisBar?: () => void; } const aisWindow = window as unknown as AisBridge; -const escapeAisHtml = (input: string): string => aisWindow.escapeMapHtml?.(input) ?? input - .replaceAll("&", "&").replaceAll("<", "<") - .replaceAll(">", ">").replaceAll('"', """); +const escapeAisHtml = (input: string): string => hostCore.escapeMapHtml(input); // --- AIS Decoder Plugin (server-side decode) --- const aisStatus = document.getElementById("ais-status"); @@ -176,10 +171,10 @@ function aisRouteText(msg: AisMessage): string { } function aisDistanceText(msg: AisMessage): string { - if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) { + if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) { return ""; } - const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.serverLon, msg.lat, msg.lon); + const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon); if (!Number.isFinite(distKm)) return ""; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; return `${distKm.toFixed(1)} km from TRX`; @@ -405,7 +400,7 @@ function pruneAisHistoryView(): void { document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => { if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await aisWindow.postPath?.("/clear_ais_decode"); + await hostCore.postPath("/clear_ais_decode"); resetAisHistoryView(); } catch (e) { console.error("AIS history clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts index 654e847e..b306cbc3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore, hostState } from "./host.js"; + import { aprsAgeText, aprsCategoryLabel, @@ -18,26 +20,18 @@ import type { PluginRuntimeWindow } from "./runtime-contract"; interface AprsBridge { getDecodeHistoryRetentionMs?: () => number; trxScheduleUiFrameJob?: (key: string, job: () => void) => void; - serverLat?: number | null; - serverLon?: number | null; - haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number; - escapeMapHtml?: (input: string) => string; navigateToAprsMap?: (lat: number, lon: number) => void; - showHint?: (message: string, durationMs: number) => void; clearMapMarkersByType?: (type: string) => void; aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void; getDecodeRigMeta?: () => unknown; - postPath?: (path: string) => Promise; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; updateAprsBar?: () => void; clearAprsBar?: () => void; closeAprsBar?: () => void; } const aprsWindow = window as unknown as AprsBridge; -const escapeAprsHtml = (input: string): string => aprsWindow.escapeMapHtml?.(input) ?? input - .replaceAll("&", "&").replaceAll("<", "<") - .replaceAll(">", ">").replaceAll('"', """); -const showAprsHint = (message: string, durationMs: number): void => { aprsWindow.showHint?.(message, durationMs); }; +const escapeAprsHtml = (input: string): string => hostCore.escapeMapHtml(input); +const showAprsHint = (message: string, durationMs: number): void => { hostCore.showHint(message, durationMs); }; // --- APRS Decoder Plugin (server-side decode) --- const aprsStatus = document.getElementById("aprs-status"); @@ -87,8 +81,8 @@ function scheduleAprsBarUpdate() { } function aprsDistanceText(pkt: AprsPacket): string { - if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return ""; - const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon); + if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return ""; + const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon); if (!Number.isFinite(distKm)) return ""; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; return `${distKm.toFixed(1)} km from TRX`; @@ -354,7 +348,7 @@ function onServerAprsBatch(packets: AprsPacket[]): void { document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => { if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await aprsWindow.postPath?.("/clear_aprs_decode"); + await hostCore.postPath("/clear_aprs_decode"); resetAprsHistoryView(); } catch (e) { console.error("APRS history clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts index e2b55686..16e7dcc5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostState } from "./host.js"; + export {}; interface DecoderDescriptor { @@ -38,7 +40,6 @@ interface BackgroundDecodeStatus { } interface BackgroundBridge { decoderRegistry?: DecoderDescriptor[]; - authEnabled?: boolean; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } }; } @@ -170,7 +171,7 @@ const bgdWindow = window as unknown as BackgroundBridge; setCheckbox("background-decode-enabled", currentConfig.enabled); renderBookmarkChecklist(); - const isControl = backgroundDecodeRole === "control" || bgdWindow.authEnabled === false; + const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false; const panel = document.getElementById("background-decode-panel"); if (panel) { panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts index d842b6bf..440fa00c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore, hostState } from "./host.js"; + export {}; /* DOM IDs in the server-owned page are required by this feature; bmEl throws @@ -19,14 +21,6 @@ interface Bookmark { scope?: string; } -interface DecoderDescriptor { - id: string; - label: string; - activation?: string; - active_modes?: string[]; - bookmark_selectable?: boolean; -} - interface BookmarkService { readonly overlayList: readonly Bookmark[]; readonly overlayRevision: number; @@ -43,37 +37,8 @@ interface VirtualChannelService { takeSchedulerControl(): Promise; } -/* Application state read through the `window.trx` host contract. This plugin - * is a separate bundle, so it cannot import the application module directly. */ -interface BookmarkHostState { - readonly authEnabled: boolean; - readonly authRole: string | null; - readonly lastActiveRigId: string | null; - readonly lastRigIds: string[]; - readonly lastRigDisplayNames: Record; - readonly lastFreqHz: number | null; - readonly lastModeName: string; - readonly lastSpectrumData: unknown; - currentBandwidthHz: number; - readonly decoderRegistry: readonly DecoderDescriptor[]; -} - -interface BookmarkHostCore { - postPath(path: string): Promise; - setRigFrequency(frequencyHz: number): void; - applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void; - armOptimisticFrequency(frequencyHz: number): void; - syncBandwidthInput(bandwidthHz: number): void; - scheduleSpectrumDraw(): void; - onDecoderRegistryReady(callback: () => void): void; -} - interface BookmarkBridge { - trx: { - state: BookmarkHostState; - core: BookmarkHostCore; - modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService }; - }; + trx: { modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise; notify?(message: string, options: { kind: "error" }): void; @@ -83,8 +48,6 @@ interface BookmarkBridge { type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement; const bridge = window as unknown as BookmarkBridge; -const trxState = bridge.trx.state; -const trxCore = bridge.trx.core; function bmEl(id: string): BookmarkElement { const element = document.getElementById(id); if (!element) throw new Error(`Missing bookmark element #${id}`); @@ -137,7 +100,7 @@ function bmEsc(str: unknown): string { } function bmCanControl() { - return !trxState.authEnabled || trxState.authRole === "control"; + return !hostState.authEnabled || hostState.authRole === "control"; } // Show/hide the Add Bookmark / Select All buttons based on the current auth role. @@ -151,7 +114,7 @@ function bmSyncAccess() { /** The listing scope: always the active rig (to merge general + rig bookmarks). */ function bmListScope() { - return trxState.lastActiveRigId || "general"; + return hostState.lastActiveRigId || "general"; } async function bmFetchOverlay() { @@ -168,7 +131,7 @@ async function bmFetchOverlay() { if (typeof bridge.syncBookmarkMapLocators === "function") { bridge.syncBookmarkMapLocators(bmOverlayList); } - trxCore.scheduleSpectrumDraw(); + hostCore.scheduleSpectrumDraw(); } async function bmFetch(categoryFilter: string): Promise { @@ -324,7 +287,7 @@ function bmChangePage(delta: number): void { // Read decoder checkboxes and return an array of selected decoder names. function bmReadDecoders(): string[] { - return trxState.decoderRegistry + return hostState.decoderRegistry .filter(d => d.bookmark_selectable) .filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked) .map(d => d.id); @@ -333,7 +296,7 @@ function bmReadDecoders(): string[] { // Set decoder checkboxes to match the given array. function bmWriteDecoders(decoders: readonly string[]): void { const set = new Set(decoders || []); - trxState.decoderRegistry + hostState.decoderRegistry .filter(d => d.bookmark_selectable) .forEach(d => { const el = bmOptionalEl("bm-dec-" + d.id); @@ -346,7 +309,7 @@ function bmBuildDecoderCheckboxes() { const container = bmEl("bm-decoder-checkboxes"); if (!container) return; container.innerHTML = ""; - trxState.decoderRegistry + hostState.decoderRegistry .filter(d => d.bookmark_selectable) .forEach(d => { const label = document.createElement("label"); @@ -387,18 +350,18 @@ function bmCloseForm() { function bmPrefillFromStatus() { // Read live rig state from the host contract (updated by the SSE stream). - const freqHz = trxState.lastFreqHz; + const freqHz = hostState.lastFreqHz; if (freqHz != null && Number.isFinite(freqHz)) { bmEl("bm-freq").value = String(Math.round(freqHz)); } - if (trxState.lastModeName) { - bmEl("bm-mode").value = trxState.lastModeName; + if (hostState.lastModeName) { + bmEl("bm-mode").value = hostState.lastModeName; } - if (trxState.currentBandwidthHz > 0) { - bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz)); + if (hostState.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz)); } // Prefill decoder checkboxes from current toggle button state. - const activeDecoders = trxState.decoderRegistry + const activeDecoders = hostState.decoderRegistry .filter(d => d.bookmark_selectable && d.activation === "toggle") .filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true") .map(d => d.id); @@ -495,15 +458,15 @@ function bmApply(bm: Bookmark): void { modeEl.value = (bm.mode || "").toUpperCase(); } if (bm.bandwidth_hz) { - trxState.currentBandwidthHz = bm.bandwidth_hz; - trxCore.syncBandwidthInput(bm.bandwidth_hz); + hostState.currentBandwidthHz = bm.bandwidth_hz; + hostCore.syncBandwidthInput(bm.bandwidth_hz); } // Set optimistic guard before applying so SSE cannot snap back. - trxCore.armOptimisticFrequency(bm.freq_hz); + hostCore.armOptimisticFrequency(bm.freq_hz); // Force display so the BW overlay is repositioned even when freq is unchanged. - trxCore.applyLocalTunedFrequency(bm.freq_hz, true); - if (trxState.lastSpectrumData) { - trxCore.scheduleSpectrumDraw(); + hostCore.applyLocalTunedFrequency(bm.freq_hz, true); + if (hostState.lastSpectrumData) { + hostCore.scheduleSpectrumDraw(); } // Take scheduler control up front, then apply mode before bandwidth so a @@ -513,21 +476,21 @@ function bmApply(bm: Bookmark): void { const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false; if (!onVirtual) { - await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); + await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); } if (bm.bandwidth_hz) { const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; if (!bwHandledByVchan) { - await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); + await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); } } // setRigFrequency redirects to the channel API when a virtual channel is // active. It repeats the optimistic update applied above, which is a // no-op because the value is unchanged. - trxCore.setRigFrequency(bm.freq_hz); + hostCore.setRigFrequency(bm.freq_hz); })(); // Decoder toggles — fire-and-forget. // - Decoders incompatible with the new mode are always turned off @@ -537,12 +500,12 @@ function bmApply(bm: Bookmark): void { // alone. const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const modeUp = (bm.mode || "").toUpperCase(); - const allToggleDecoders = trxState.decoderRegistry.filter(d => + const allToggleDecoders = hostState.decoderRegistry.filter(d => d.activation === "toggle" ); const decoderPromise = allToggleDecoders.length ? (async () => { let statusUrl = "/status"; - const rigId = trxState.lastActiveRigId; + const rigId = hostState.lastActiveRigId; if (rigId) { statusUrl += "?remote=" + encodeURIComponent(rigId); } @@ -566,7 +529,7 @@ function bmApply(bm: Bookmark): void { wanted = currentlyOn; } if (wanted !== currentlyOn) { - toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); } } if (toggles.length) await Promise.all(toggles); @@ -616,8 +579,8 @@ function bmUpdateSelectionUi() { function bmPopulateMoveTarget() { const sel = bmEl("bm-move-target"); if (!sel) return; - const rigIds = trxState.lastRigIds; - const displayNames = trxState.lastRigDisplayNames; + const rigIds = hostState.lastRigIds; + const displayNames = hostState.lastRigDisplayNames; const prev = sel.value; sel.innerHTML = ""; if (bmScope !== "general") { @@ -725,8 +688,8 @@ async function bmDeleteSelected() { function bmPopulateScopePicker() { const picker = bmEl("bm-scope-picker"); if (!picker) return; - const rigIds = trxState.lastRigIds; - const displayNames = trxState.lastRigDisplayNames; + const rigIds = hostState.lastRigIds; + const displayNames = hostState.lastRigDisplayNames; // Preserve current selection if still valid. const prev = picker.value; while (picker.options.length > 1) picker.remove(1); @@ -753,7 +716,7 @@ function bmPopulateScopePicker() { // Build decoder checkboxes from registry. The registry is fetched async // so we rebuild once it arrives to ensure checkboxes are present. bmBuildDecoderCheckboxes(); - trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); + hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); // Scope picker bmPopulateScopePicker(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts index a91b9c0e..8ca86646 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore } from "./host.js"; + import type { PluginRuntimeWindow } from "./runtime-contract.js"; export {}; @@ -30,8 +32,6 @@ interface CwBridge { lastFreqHz?: number; currentBandwidthHz?: number; lastSpectrumData?: CwSpectrum; - escapeMapHtml?: (input: string) => string; - postPath?: (path: string) => Promise; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; applyCwAutoUi?: (enabled: boolean) => void; applyCwAutoUiFromServer?: (enabled: boolean) => void; @@ -75,9 +75,7 @@ let cwBarDismissedAtMs = 0; let cwAutoLocalOverride: boolean | null = null; function escapeCwHtml(input: string): string { - return cwWindow.escapeMapHtml?.(input) ?? input - .replaceAll("&", "&").replaceAll("<", "<") - .replaceAll(">", ">").replaceAll('"', """); + return hostCore.escapeMapHtml(input); } function applyCwAutoUi(enabled: boolean): void { @@ -344,7 +342,7 @@ async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: bool cwToneInput.value = String(clamped); } try { - await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); + await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); } catch (e) { console.error("CW tone set failed", e); } @@ -358,7 +356,7 @@ if (cwAutoInput) { cwAutoLocalOverride = enabled; applyCwAutoUi(enabled); try { - await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); + await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); drawCwTonePicker(); } catch (error: unknown) { console.error("CW auto toggle failed", error); @@ -375,7 +373,7 @@ if (cwWpmInput) { if (cwAutoInput?.checked) return; const wpm = clampCwWpm(cwWpmInput.value); cwWpmInput.value = String(wpm); - try { await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); } + try { await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); } catch (error: unknown) { console.error("CW WPM set failed", error); } })(); }); @@ -413,7 +411,7 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click", void (async () => { if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await cwWindow.postPath?.("/clear_cw_decode"); + await hostCore.postPath("/clear_cw_decode"); resetCwHistoryView(); } catch (error: unknown) { console.error("CW history clear failed", error); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts index c0c4f719..5a533914 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore } from "./host.js"; + import type { PluginRuntimeWindow } from "./runtime-contract.js"; export type FtxDecoderId = "ft2" | "ft4" | "ft8"; @@ -45,8 +47,6 @@ interface FtxBridge { updateFt8Bar?: () => void; registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise; - postPath?: (path: string) => Promise; - fmtTime?: (timestampMs: number) => string; trxUi: ConfirmApi; clearFt8Bar?: () => void; closeFt8Bar?: () => void; @@ -62,6 +62,14 @@ interface FtxConfig { const bridge = window as unknown as FtxBridge & PluginRuntimeWindow; +// Legacy ft8.js owned this formatter locally; the shared module kept reading it +// off `window`, where nothing published it, so bar timestamps rendered empty. +function formatBarTime(timestampMs: number): string { + if (!timestampMs) return "--:--:--"; + return new Date(timestampMs) + .toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + function finiteNumber(value: unknown): number | null { const number = typeof value === "number" ? value : Number(value); return Number.isFinite(number) ? number : null; @@ -274,7 +282,7 @@ export function initializeFtxDecoder(config: FtxConfig): void { let html = ""; for (const message of recent) { const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); - const time = timestamp === null ? "" : `${bridge.fmtTime?.(timestamp) ?? ""}`; + const time = timestamp === null ? "" : `${formatBarTime(timestamp)}`; const snr = finiteNumber(message.snr_db); const delta = finiteNumber(message.dt_s); const frequency = displayFrequency(message.freq_hz); @@ -309,12 +317,12 @@ export function initializeFtxDecoder(config: FtxConfig): void { toggle?.addEventListener("click", () => { void (async () => { try { await bridge.takeSchedulerControlForDecoderDisable?.(toggle); - await bridge.postPath?.(`/toggle_${id}_decode`); + await hostCore.postPath(`/toggle_${id}_decode`); } catch (error: unknown) { console.error(`${label} toggle failed`, error); } })(); }); document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => { void (async () => { if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return; - try { await bridge.postPath?.(`/clear_${id}_decode`); reset(); } + try { await hostCore.postPath(`/clear_${id}_decode`); reset(); } catch (error: unknown) { console.error(`${label} history clear failed`, error); } })(); }); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts index 2fc3425e..3ee88258 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore, hostState } from "./host.js"; + import { aprsAgeText, aprsCategoryLabel, @@ -19,21 +21,13 @@ import type { PluginRuntimeWindow } from "./runtime-contract"; interface HfAprsBridge { getDecodeHistoryRetentionMs?: () => number; trxScheduleUiFrameJob?: (key: string, job: () => void) => void; - serverLat?: number | null; - serverLon?: number | null; - haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number; - escapeMapHtml?: (input: string) => string; navigateToAprsMap?: (lat: number, lon: number) => void; - showHint?: (message: string, durationMs: number) => void; getDecodeRigMeta?: () => unknown; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise; - postPath?: (path: string) => Promise; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; } const hfAprsWindow = window as unknown as HfAprsBridge; -const escapeHfAprsHtml = (input: string): string => hfAprsWindow.escapeMapHtml?.(input) ?? input - .replaceAll("&", "&").replaceAll("<", "<") - .replaceAll(">", ">").replaceAll('"', """); +const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input); // --- HF APRS Decoder Plugin (server-side decode, 300 baud) --- const hfAprsStatus = document.getElementById("hf-aprs-status"); @@ -72,8 +66,8 @@ function scheduleHfAprsHistoryRender() { } function hfAprsDistanceText(pkt: AprsPacket): string { - if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return ""; - const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon); + if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return ""; + const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon); if (!Number.isFinite(distKm)) return ""; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; return `${distKm.toFixed(1)} km from TRX`; @@ -214,10 +208,10 @@ function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement { const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined; if (clipboard) { await clipboard.writeText(raw); - hfAprsWindow.showHint?.("Coordinates copied", 1200); + hostCore.showHint("Coordinates copied", 1200); } } catch { - hfAprsWindow.showHint?.("Copy failed", 1500); + hostCore.showHint("Copy failed", 1500); } })(); }); } @@ -289,7 +283,7 @@ const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => { try { await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn); - await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode"); + await hostCore.postPath("/toggle_hf_aprs_decode"); } catch (e) { console.error("HF APRS toggle failed", e); } @@ -298,7 +292,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => { document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", () => { void (async () => { if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await hfAprsWindow.postPath?.("/clear_hf_aprs_decode"); + await hostCore.postPath("/clear_hf_aprs_decode"); resetHfAprsHistoryView(); } catch (e) { console.error("HF APRS history clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts new file mode 100644 index 00000000..04c05441 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +// Typed view of the `window.trx` host contract for lazy feature bundles. +// +// Feature entries are separate esbuild bundles, so they cannot share module +// instances with the application entry and must reach application state and +// services through the host namespace documented in +// `docs/frontend-architecture.md`. Declaring that contract once here keeps the +// feature bundles from re-deriving it — and from drifting back to bare `window` +// properties, which the module graph no longer publishes. + +export interface HostDecoderDescriptor { + id: string; + label: string; + activation?: string; + active_modes?: string[]; + bookmark_selectable?: boolean; +} + +export interface HostState { + readonly serverLat: number | null; + readonly serverLon: number | null; + readonly authEnabled: boolean; + readonly authRole: string | null; + readonly lastActiveRigId: string | null; + readonly lastRigIds: string[]; + readonly lastRigDisplayNames: Record; + readonly lastFreqHz: number | null; + readonly lastSpectrumData: { sample_rate: number; center_hz: number } | null; + readonly jogUnit: number; + readonly rxActive: boolean; + readonly decoderRegistry: readonly HostDecoderDescriptor[]; + lastModeName: string; + currentBandwidthHz: number; + audioChannelOverride: string | null; +} + +export interface HostCore { + postPath(path: string): Promise; + applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void; + armOptimisticFrequency(frequencyHz: number): void; + escapeMapHtml(value: string): string; + haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number; + showHint(message: string, durationMs?: number): void; + formatFreqForStep(frequencyHz: number, stepHz: number): string; + refreshFreqDisplay(): void; + setJogDivisor(divisor: number): void; + mwDefaultsForMode(mode: string): [number, number, number, number]; + resetRdsDisplay(): void; + positionRdsPsOverlay(): void; + updateWfmControls(): void; + updateSdrSquelchControlVisibility(): void; + updateDocumentTitle(rds: unknown): void; + activeChannelRds(): unknown; + startRxAudio(): void; + stopRxAudio(): void; + setRigFrequency(frequencyHz: number): void; + syncBandwidthInput(bandwidthHz: number): void; + scheduleSpectrumDraw(): void; + onDecoderRegistryReady(callback: () => void): void; +} + +interface HostWindow { + trx: { state: HostState; core: HostCore }; +} + +const host = window as unknown as HostWindow; + +export const hostState: HostState = host.trx.state; +export const hostCore: HostCore = host.trx.core; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.ts index 5abe1c1c..fac01e79 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore } from "./host.js"; + import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types"; import type { PluginRuntimeWindow } from "./runtime-contract.js"; @@ -13,7 +15,6 @@ interface SatelliteBridge { addSatMapOverlay?: (image: SatelliteImage) => void; clearSatMapOverlays?: () => void; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise; - postPath?: (path: string) => Promise; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; satShowOnMap?: (south: number, west: number, north: number, east: number) => void; enableMapSourceFilter?: (source: string) => void; @@ -305,7 +306,7 @@ const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn"); lrptDecodeToggleBtn?.addEventListener("click", () => { void (async () => { try { await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn); - await satWindow.postPath?.("/toggle_lrpt_decode"); + await hostCore.postPath("/toggle_lrpt_decode"); } catch (e) { console.error("LRPT toggle failed", e); } @@ -327,7 +328,7 @@ document ?.addEventListener("click", () => { void (async () => { if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await satWindow.postPath?.("/clear_lrpt_decode"); + await hostCore.postPath("/clear_lrpt_decode"); resetSatHistoryView(); } catch (e) { console.error("Weather satellite history clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts index acc1f91e..e4ae35f6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts @@ -80,9 +80,5 @@ export interface SchedulerWindow { confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise; notify?(message: string, options: { kind: "error" }): void; }; - authRole?: string | null; - lastActiveRigId?: string | null; - serverLat?: number | null; - serverLon?: number | null; satScheduler?: SatelliteSchedulerApi; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts index dd6fa118..cc0e96b1 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostState } from "./host.js"; + import type { ScheduleEntry, SchedulerBookmark, @@ -27,6 +29,12 @@ function schedulerEl(id: string): SchedulerElement { return element as SchedulerElement; } +/* The timeline needle group is drawn by this feature's own SVG markup, so it is + * absent until the timeline has rendered at least once. */ +function schedulerOptionalEl(id: string): SchedulerElement | null { + return document.getElementById(id) as SchedulerElement | null; +} + // Background Decoding Scheduler UI (function () { @@ -481,8 +489,8 @@ function schedulerEl(id: string): SchedulerElement { if (mode === "grayline" && currentConfig && currentConfig.grayline) { const gl = currentConfig.grayline; // Prefer saved value; fall back to server coordinates from app.js globals. - const lat = gl.lat ?? schedulerWindow.serverLat ?? ""; - const lon = gl.lon ?? schedulerWindow.serverLon ?? ""; + const lat = gl.lat ?? hostState.serverLat ?? ""; + const lon = gl.lon ?? hostState.serverLon ?? ""; setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : ""); const gridEl = schedulerEl("scheduler-gl-grid"); @@ -496,8 +504,8 @@ function schedulerEl(id: string): SchedulerElement { renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); } else if (mode === "grayline") { // No saved grayline config yet — pre-fill coords from server if available. - const lat = schedulerWindow.serverLat ?? ""; - const lon = schedulerWindow.serverLon ?? ""; + const lat = hostState.serverLat ?? ""; + const lon = hostState.serverLon ?? ""; setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : ""); const gridEl2 = schedulerEl("scheduler-gl-grid"); @@ -821,7 +829,7 @@ function schedulerEl(id: string): SchedulerElement { } function renderTimelineNeedle() { - const g = schedulerEl("sch-timeline-needle-g"); + const g = schedulerOptionalEl("sch-timeline-needle-g"); if (g) g.innerHTML = timelineNeedleSvg(); } @@ -1566,8 +1574,8 @@ function schedulerEl(id: string): SchedulerElement { // When loaded eagerly, initSettingsUI() in app.js calls initScheduler(); // when loaded lazily (e.g. settings tab click after boot), the app has // already passed that point, so we must self-initialize here. - if (schedulerWindow.authRole != null) { - initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole); + if (hostState.authRole != null) { + initScheduler(hostState.lastActiveRigId, hostState.authRole); wireSchedulerEvents(); } })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts index b616e45b..4c8fe134 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore, hostState } from "./host.js"; + export {}; interface VirtualChannel { @@ -21,40 +23,13 @@ interface SchedulerReleaseState { all_released?: boolean; current_session_released?: boolean; } -interface SpectrumSnapshot { sample_rate: number; center_hz: number } +// Callbacks the application publishes as documented transitional window +// properties; all other application state and services arrive through the +// typed host contract in ./host.ts. interface VirtualChannelBridge { - lastActiveRigId?: string | null; - lastFreqHz?: number; - lastModeName?: string; - lastSpectrumData?: SpectrumSnapshot | null; - currentBandwidthHz?: number; - jogUnit?: number; - rxActive?: boolean; - _audioChannelOverride?: string | null; renderRdsOverlays?: () => void; - updateDocumentTitle?: (rds: unknown) => void; - activeChannelRds?: () => unknown; - stopRxAudio?: () => void; - startRxAudio?: () => void; - formatFreqForStep?: (frequencyHz: number, jogUnit: number) => string; - setJogDivisor?: (divisor: number) => void; - resetRdsDisplay?: () => void; - updateWfmControls?: () => void; - updateSdrSquelchControlVisibility?: () => void; refreshRdsUi?: () => void; - positionRdsPsOverlay?: () => void; - mwDefaultsForMode?: (mode: string) => [number, ...unknown[]]; - showHint?: (message: string, durationMs: number) => void; - refreshFreqDisplay?: () => void; - trx?: { - core?: VirtualChannelCoreServices; - modules?: { vchan?: VirtualChannelService }; - }; -} - -interface VirtualChannelCoreServices { - applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void; - armOptimisticFrequency(frequencyHz: number): void; + trx?: { modules?: { vchan?: VirtualChannelService } }; } interface VirtualChannelService { @@ -68,6 +43,7 @@ interface VirtualChannelService { interceptMode(mode: string): Promise; interceptBandwidth(bandwidthHz: number): Promise; interceptFrequency(frequencyHz: number): boolean; + interceptFreqDisplay(): boolean; takeSchedulerControl(): Promise; releaseToScheduler(): Promise; } @@ -151,7 +127,7 @@ function vchanStartSchedulerReleasePolling() { async function vchanToggleSchedulerRelease() { if (!vchanSessionId) return; - const rigId = vchanRigId || vchanWindow.lastActiveRigId || null; + const rigId = vchanRigId || hostState.lastActiveRigId || null; try { const resp = await fetch("/scheduler-control", { method: "PUT", @@ -266,9 +242,7 @@ function vchanRender() { picker.appendChild(addBtn); vchanSyncAccentUI(); - if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { - vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); - } + hostCore.updateDocumentTitle(hostCore.activeChannelRds()); vchanRenderSchedulerRelease(); } @@ -276,8 +250,8 @@ async function vchanAllocate() { if (!vchanSessionId || !vchanRigId) return; // Use the last known rig frequency and mode as the starting point. - const freqHz = (typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0) - ? vchanWindow.lastFreqHz + const freqHz = (typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0) + ? hostState.lastFreqHz : 0; const modeEl = document.getElementById("mode") as HTMLSelectElement | null; const mode = modeEl ? (modeEl.value || "USB") : "USB"; @@ -379,14 +353,14 @@ function vchanReconnectAudio(): void { // Always update the override so startRxAudio picks up the right URL, // even when audio isn't currently running. const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; - vchanWindow._audioChannelOverride = ch?.id ?? null; - if (!vchanWindow.rxActive) return; - vchanWindow.stopRxAudio?.(); + hostState.audioChannelOverride = ch?.id ?? null; + if (!hostState.rxActive) return; + hostCore.stopRxAudio(); // Delay so the server has time to set up the per-channel encoder. // The server-side audio_ws handler also polls for up to 2 s, so this // just needs to be long enough for the WS upgrade to reach the server. setTimeout(() => { - vchanWindow.startRxAudio?.(); + hostCore.startRxAudio(); }, 300); } @@ -419,11 +393,7 @@ function vchanUpdateFreqDisplay() { if (!ch) return; const el = document.getElementById("freq") as HTMLInputElement | null; if (!el) return; - if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") { - el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit); - } else { - el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, ""); - } + el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit); } // Sync the mode picker to the active virtual channel's mode. @@ -437,21 +407,21 @@ function vchanSyncModeDisplay() { } // When on primary channel, app.js rig-state updates handle the picker. const modeUpper = (modeEl.value || "").toUpperCase(); - if (typeof vchanWindow.lastModeName === "string") { - if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") { - vchanWindow.setJogDivisor?.(10); - vchanWindow.resetRdsDisplay?.(); - } else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") { - vchanWindow.resetRdsDisplay?.(); + if (typeof hostState.lastModeName === "string") { + if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") { + hostCore.setJogDivisor(10); + hostCore.resetRdsDisplay(); + } else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") { + hostCore.resetRdsDisplay(); } - vchanWindow.lastModeName = modeUpper; + hostState.lastModeName = modeUpper; } - vchanWindow.updateWfmControls?.(); - vchanWindow.updateSdrSquelchControlVisibility?.(); + hostCore.updateWfmControls(); + hostCore.updateSdrSquelchControlVisibility(); if (vchanWindow.refreshRdsUi) { vchanWindow.refreshRdsUi(); } else { - vchanWindow.positionRdsPsOverlay?.(); + hostCore.positionRdsPsOverlay(); } } @@ -464,12 +434,12 @@ function vchanSyncBwDisplay() { if (!bwEl) return; // bandwidth_hz == 0 means mode-default; derive it from the channel mode. let bwHz = ch.bandwidth_hz || 0; - if (bwHz === 0 && vchanWindow.mwDefaultsForMode) { - bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0; + if (bwHz === 0) { + bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0; } if (bwHz > 0) { bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, ""); - vchanWindow.currentBandwidthHz = bwHz; + hostState.currentBandwidthHz = bwHz; } } @@ -485,29 +455,22 @@ function vchanSyncAccentUI() { vchanSyncModeDisplay(); vchanSyncBwDisplay(); } else { - origRefreshFreqDisplay?.(); - } - if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { - vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); + hostCore.refreshFreqDisplay(); } + hostCore.updateDocumentTitle(hostCore.activeChannelRds()); } -// Saved reference to the original refreshFreqDisplay from app.js. -let origRefreshFreqDisplay: (() => void) | null = null; - function vchanSetChannelFreq(freqHz: number): void { if (!vchanRigId || !vchanActiveId) return; // Validate against current SDR capture window. - if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) { - const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2; - const center = vchanWindow.lastSpectrumData.center_hz; + if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) { + const halfSpan = hostState.lastSpectrumData.sample_rate / 2; + const center = hostState.lastSpectrumData.center_hz; if (Math.abs(freqHz - center) > halfSpan) { - if (vchanWindow.showHint) { - vchanWindow.showHint( - `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, - 3000 - ); - } + hostCore.showHint( + `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, + 3000 + ); return; } } @@ -581,14 +544,21 @@ async function vchanInterceptBandwidth(bwHz: number): Promise { // so this applies the optimistic local update and reports the tune as handled. function vchanInterceptFrequency(freqHz: number): boolean { if (!vchanIsOnVirtual()) return false; - const core = vchanWindow.trx?.core; const targetHz = Math.round(freqHz); - core?.armOptimisticFrequency(targetHz); - core?.applyLocalTunedFrequency(targetHz); + hostCore.armOptimisticFrequency(targetHz); + hostCore.applyLocalTunedFrequency(targetHz); vchanSetChannelFreq(freqHz); return true; } +// Called by the application's refreshFreqDisplay. While a virtual channel is +// active the field belongs to that channel, so report the render as handled. +function vchanInterceptFreqDisplay(): boolean { + if (!vchanIsOnVirtual()) return false; + vchanUpdateFreqDisplay(); + return true; +} + vchanWindow.trx ??= {}; vchanWindow.trx.modules ??= {}; vchanWindow.trx.modules.vchan = { @@ -602,6 +572,7 @@ vchanWindow.trx.modules.vchan = { interceptMode: vchanInterceptMode, interceptBandwidth: vchanInterceptBandwidth, interceptFrequency: vchanInterceptFrequency, + interceptFreqDisplay: vchanInterceptFreqDisplay, takeSchedulerControl: vchanTakeSchedulerControl, releaseToScheduler: vchanToggleSchedulerRelease, }; @@ -617,16 +588,3 @@ vchanWindow.trx.modules.vchan = { vchanRenderSchedulerRelease(); })(); -// Wrap refreshFreqDisplay so the main freq field stays in sync with the -// active virtual channel's frequency (SSE rig-state updates would otherwise -// constantly overwrite it with channel 0's freq). -(function() { - origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null; - vchanWindow.refreshFreqDisplay = function() { - if (vchanIsOnVirtual()) { - vchanUpdateFreqDisplay(); - return; - } - origRefreshFreqDisplay?.(); - }; -})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts index b931d905..ec56f718 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore } from "./host.js"; + export {}; import type { PluginRuntimeWindow } from "./runtime-contract"; @@ -38,8 +40,6 @@ interface VdesMessage { interface VdesBridge { getDecodeHistoryRetentionMs?: () => number; trxScheduleUiFrameJob?: (key: string, job: () => void) => void; - escapeMapHtml?: (input: string) => string; - postPath?: (path: string) => Promise; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; vdesMapAddPoint?: (message: VdesMessage) => void; updateVdesBar?: () => void; @@ -47,9 +47,7 @@ interface VdesBridge { } const vdesWindow = window as unknown as VdesBridge; -const escapeVdesHtml = (input: string): string => vdesWindow.escapeMapHtml?.(input) ?? input - .replaceAll("&", "&").replaceAll("<", "<") - .replaceAll(">", ">").replaceAll('"', """); +const escapeVdesHtml = (input: string): string => hostCore.escapeMapHtml(input); // --- VDES Decoder Plugin (server-side decode) --- const vdesStatus = document.getElementById("vdes-status"); @@ -337,7 +335,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void { document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { void (async () => { if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await vdesWindow.postPath?.("/clear_vdes_decode"); + await hostCore.postPath("/clear_vdes_decode"); resetVdesHistoryView(); } catch (e) { console.error("VDES history clear failed", e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.ts index d9b84fec..bc0e4e45 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore } from "./host.js"; + import type { PluginRuntimeWindow } from "./runtime-contract.js"; export {}; @@ -29,7 +31,6 @@ interface WefaxProgress { interface WefaxBridge { getDecodeHistoryRetentionMs?: () => number; trxScheduleUiFrameJob?: (key: string, job: () => void) => void; - postPath?: (path: string) => Promise; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise; syncWefaxToggle?: (enabled: boolean) => void; } @@ -411,7 +412,7 @@ if (wefaxDom.toggleBtn) { if (wefaxWindow.takeSchedulerControlForDecoderDisable) { await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton); } - await wefaxWindow.postPath?.('/toggle_wefax_decode'); + await hostCore.postPath('/toggle_wefax_decode'); } catch (e) { console.error('WEFAX toggle failed', e); } @@ -420,7 +421,7 @@ if (wefaxDom.toggleBtn) { if (wefaxDom.clearBtn) { wefaxDom.clearBtn.addEventListener('click', () => { void (async () => { try { - await wefaxWindow.postPath?.('/clear_wefax_decode'); + await hostCore.postPath('/clear_wefax_decode'); resetWefaxHistoryView(); } catch (e) { console.error('WEFAX clear failed', e); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts index 728fea4b..9ba9654d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import { hostCore } from "./host.js"; + import type { PluginRuntimeWindow } from "./runtime-contract.js"; export {}; @@ -25,7 +27,6 @@ interface WsprBridge { navigateToMapLocator?: (grid: string, type: "wspr") => void; clearMapMarkersByType?: (type: "wspr") => void; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise; - postPath?: (path: string) => Promise; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; [key: string]: unknown; } @@ -291,7 +292,7 @@ wsprDecodeToggleBtn?.addEventListener("click", () => { void (async () => { try { await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); - await wsprWindow.postPath?.("/toggle_wspr_decode"); + await hostCore.postPath("/toggle_wspr_decode"); } catch (error: unknown) { console.error("WSPR toggle failed", error); } @@ -302,7 +303,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click" void (async () => { if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { - await wsprWindow.postPath?.("/clear_wspr_decode"); + await hostCore.postPath("/clear_wspr_decode"); resetWsprHistoryView(); } catch (error: unknown) { console.error("WSPR history clear failed", error); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs index 58d36fc5..de727ce1 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs @@ -3,14 +3,15 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; test("AIS entry forwards positioned vessels with normalized metadata", async () => { const forwarded = []; const window = { + ...createHost(), trxUi: { confirm: async () => true }, aisMapAddVessel: (message) => { forwarded.push(message); }, }; @@ -25,7 +26,7 @@ test("AIS entry forwards positioned vessels with normalized metadata", async () console, }); const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); - const source = await readFile(new URL("../../assets/web/generated/ais.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugins/ais.ts", import.meta.url)); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs index a9963b0b..0b268371 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs @@ -6,10 +6,12 @@ import assert from "node:assert/strict"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; test("APRS entry normalizes positioned packets without remote symbol assets", async () => { const forwarded = []; const window = { + ...createHost(), trxUi: { confirm: async () => true }, aprsMapAddStation: (...args) => { forwarded.push(args); }, }; @@ -38,3 +40,49 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as assert.equal(forwarded[0][1], 54.5); assert.equal(forwarded[0][6].rig_id, null); }); + +// Distance is computed from the receiver position and the great-circle helper, +// both of which the entry reads through the host contract. These used to be +// bare window properties that the module graph stopped publishing, so the +// distance column rendered empty for every positioned packet. +test("APRS distance text uses the receiver position from the host contract", async () => { + const distanceArgs = []; + const host = createHost({ + state: { serverLat: 54.0, serverLon: 18.0 }, + core: { + haversineKm: (lat1, lon1, lat2, lon2) => { + distanceArgs.push([lat1, lon1, lat2, lon2]); + return 61.7; + }, + }, + }); + const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} }; + const node = () => ({ + innerHTML: "", textContent: "", className: "", style: {}, dataset: {}, + appendChild(child) { return child; }, addEventListener() {}, replaceChildren() {}, + querySelector: () => null, querySelectorAll: () => [], setAttribute() {}, + classList: { add() {}, toggle() {} }, + }); + const context = vm.createContext({ + window, + document: { + getElementById: (id) => (id === "aprs-packets" ? node() : null), + querySelectorAll: () => [], + createElement: node, + createDocumentFragment: node, + }, + navigator: {}, + Date, Number, String, Array, Set, Reflect, console, + }); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); + const source = await bundleEntry(new URL("../src/plugins/aprs.ts", import.meta.url)); + new vm.Script(runtime).runInContext(context); + new vm.Script(source).runInContext(context); + + window.trxPluginRuntime.dispatch("aprs", { + src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, lat: 54.5, lon: 18.5, + symbol_table: "/", symbol_code: ">", + }); + + assert.deepEqual(distanceArgs, [[54.0, 18.0, 54.5, 18.5]]); +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs index 7a7f91d3..aa89dd4f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs @@ -3,13 +3,15 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { createHost } from "./host-fixture.mjs"; +import { bundleEntry } from "./bundle-entry.mjs"; test("background decode loads configuration for the explicitly selected rig", async () => { const requested = []; const window = { + ...createHost(), decoderRegistry: [], trxUi: { confirm: async () => true }, }; @@ -35,7 +37,7 @@ test("background decode loads configuration for the explicitly selected rig", as Error, console, }); - const source = await readFile(new URL("../../assets/web/generated/background-decode.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url)); new vm.Script(source).runInContext(context); window.trx.modules.backgroundDecode.initialize("rig/a", "control"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs index b1ae08ed..341dc028 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs @@ -3,9 +3,9 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; class ElementFixture { constructor() { @@ -66,7 +66,7 @@ function documentFixture(element) { }; } -const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8"); +const source = await bundleEntry(new URL("../src/plugins/bookmarks.ts", import.meta.url)); test("bookmarks register an explicit typed service for application consumers", async () => { const elements = new Map(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs index d09e4a66..09116c7c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs @@ -3,10 +3,10 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; class ElementFixture { constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; } @@ -21,7 +21,7 @@ test("CW entry appends server-decoded text and registers lifecycle callbacks", a const output = new ElementFixture(); const status = new ElementFixture(); const elements = new Map([["cw-output", output], ["cw-status", status]]); - const window = { trxUi: { confirm: async () => true }, addEventListener() {} }; + const window = { ...createHost(), trxUi: { confirm: async () => true }, addEventListener() {} }; const context = vm.createContext({ window, document: { @@ -39,7 +39,7 @@ test("CW entry appends server-decoded text and registers lifecycle callbacks", a console, }); const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); - const source = await readFile(new URL("../../assets/web/generated/cw.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugins/cw.ts", import.meta.url)); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs index c2238ba3..1fd11646 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs @@ -6,11 +6,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; test("FT2 entry normalizes audio offsets and registers typed callbacks", async () => { let barRenderer; const mapMessages = []; const window = { + ...createHost(), ft8BaseHz: 14_074_000, trxUi: { confirm: async () => true }, ft8ExtractAllGrids: () => ["JO91"], @@ -46,6 +48,7 @@ test("FT2 entry normalizes audio offsets and registers typed callbacks", async ( test("FT8 entry installs shared parsing without relying on script globals", async () => { const forwarded = []; const window = { + ...createHost(), ft8BaseHz: 7_074_000, trxUi: { confirm: async () => true }, mapAddLocator: (...args) => { forwarded.push(args); }, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs index 74b5ab3d..f611555e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs @@ -6,9 +6,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; test("HF APRS entry uses shared typed normalization and local symbols", async () => { - const window = { trxUi: { confirm: async () => true } }; + const window = { ...createHost(), trxUi: { confirm: async () => true } }; const context = vm.createContext({ window, document: { getElementById: () => null, querySelectorAll: () => [] }, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs new file mode 100644 index 00000000..ca278d89 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +// Mirrors the `window.trx` host contract that app.ts publishes and that +// src/plugins/host.ts consumes. Feature bundles read all application state +// and services through it, so every plugin test needs it on its stub window. + +export function createHost({ state = {}, core = {}, modules = {} } = {}) { + const calls = []; + const record = (name, result) => (...args) => { + calls.push({ name, args }); + return typeof result === "function" ? result(...args) : result; + }; + return { + calls, + trx: { + state: { + serverLat: null, + serverLon: null, + authEnabled: false, + authRole: "control", + lastActiveRigId: null, + lastRigIds: [], + lastRigDisplayNames: {}, + lastFreqHz: null, + lastSpectrumData: null, + jogUnit: 1000, + rxActive: false, + decoderRegistry: [], + lastModeName: "", + currentBandwidthHz: 2400, + audioChannelOverride: null, + ...state, + }, + core: { + postPath: record("postPath", async () => undefined), + applyLocalTunedFrequency: record("applyLocalTunedFrequency"), + armOptimisticFrequency: record("armOptimisticFrequency"), + escapeMapHtml: (value) => String(value) + .replaceAll("&", "&").replaceAll("<", "<") + .replaceAll(">", ">").replaceAll('"', """), + haversineKm: record("haversineKm", () => 0), + showHint: record("showHint"), + formatFreqForStep: (hz) => String(hz), + refreshFreqDisplay: record("refreshFreqDisplay"), + setJogDivisor: record("setJogDivisor"), + mwDefaultsForMode: record("mwDefaultsForMode", () => [0, 0, 0, 0]), + resetRdsDisplay: record("resetRdsDisplay"), + positionRdsPsOverlay: record("positionRdsPsOverlay"), + updateWfmControls: record("updateWfmControls"), + updateSdrSquelchControlVisibility: record("updateSdrSquelchControlVisibility"), + updateDocumentTitle: record("updateDocumentTitle"), + activeChannelRds: record("activeChannelRds", () => null), + startRxAudio: record("startRxAudio"), + stopRxAudio: record("stopRxAudio"), + setRigFrequency: record("setRigFrequency"), + syncBandwidthInput: record("syncBandwidthInput"), + scheduleSpectrumDraw: record("scheduleSpectrumDraw"), + onDecoderRegistryReady: record("onDecoderRegistryReady"), + ...core, + }, + modules, + }, + }; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sat.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sat.test.mjs index 6a26205b..e7aa26b6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sat.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sat.test.mjs @@ -3,14 +3,15 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => { const overlays = []; const window = { + ...createHost(), trxUi: { confirm: async () => true }, addSatMapOverlay: (image) => { overlays.push(image); }, }; @@ -28,7 +29,7 @@ test("satellite entry registers lifecycle callbacks and forwards georeferenced i console, }); const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); - const source = await readFile(new URL("../../assets/web/generated/sat.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugins/sat.ts", import.meta.url)); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs index a2d9f015..8153604c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs @@ -3,12 +3,15 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { createHost } from "./host-fixture.mjs"; +import { bundleEntry } from "./bundle-entry.mjs"; test("scheduler registers a typed module service without lifecycle globals", async () => { - const window = { trx: { modules: {} }, trxUi: { confirm: async () => true } }; + // No role known yet: the entry registers its service and waits for the + // application to drive initialization. + const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } }; const context = vm.createContext({ window, document: { @@ -31,7 +34,7 @@ test("scheduler registers a typed module service without lifecycle globals", asy WeakSet, console, }); - const source = await readFile(new URL("../../assets/web/generated/scheduler.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugins/scheduler.ts", import.meta.url)); new vm.Script(source).runInContext(context); const service = window.trx.modules.scheduler; @@ -41,3 +44,70 @@ test("scheduler registers a typed module service without lifecycle globals", asy assert.equal(window.initScheduler, undefined); assert.equal(window.schedulerBridge, undefined); }); + +// Lazy-load case: the settings tab is opened after boot, so the application has +// already passed initSettingsUI() and the entry must initialize itself from the +// host state. This read used to come from a bare window property that the +// module graph stopped publishing, so the scheduler never started. +test("scheduler self-initializes for the active rig when a role is already known", async () => { + class ElementFixture { + constructor() { + this.value = ""; + this.textContent = ""; + this.innerHTML = ""; + this.style = {}; + this.dataset = {}; + this.options = []; + this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false }; + this.children = []; + } + addEventListener() {} + appendChild(child) { this.children.push(child); return child; } + remove() {} + focus() {} + closest() { return null; } + querySelector() { return null; } + querySelectorAll() { return []; } + } + const elements = new Map(); + const fetched = []; + const element = (id) => { + if (!elements.has(id)) elements.set(id, new ElementFixture()); + return elements.get(id); + }; + const window = { + ...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }), + trxUi: { confirm: async () => true }, + }; + const context = vm.createContext({ + window, + document: { + activeElement: null, + addEventListener() {}, + getElementById: element, + querySelector: () => new ElementFixture(), + querySelectorAll: () => [], + createElement: () => new ElementFixture(), + }, + localStorage: { getItem: () => null, setItem() {} }, + fetch: async (url) => { fetched.push(String(url)); return { ok: true, json: async () => ({}) }; }, + setInterval: () => 1, + clearInterval() {}, + setTimeout: () => 1, + HTMLElement: ElementFixture, + Element: ElementFixture, + Date, + Number, + String, + Array, + Promise, + WeakSet, + console, + }); + const source = await bundleEntry(new URL("../src/plugins/scheduler.ts", import.meta.url)); + new vm.Script(source).runInContext(context); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(fetched.some((url) => url.includes("scheduler") && url.includes("sdr")), + `expected a scheduler load for the active rig, saw ${JSON.stringify(fetched)}`); +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs index 6636ad11..cac72224 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs @@ -3,12 +3,13 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { createHost } from "./host-fixture.mjs"; +import { bundleEntry } from "./bundle-entry.mjs"; test("virtual channels expose typed SSE and interception boundaries", async () => { - const window = {}; + const window = { ...createHost() }; const context = vm.createContext({ window, document: { getElementById: () => null }, @@ -25,7 +26,7 @@ test("virtual channels expose typed SSE and interception boundaries", async () = Error, console, }); - const source = await readFile(new URL("../../assets/web/generated/vchan.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugins/vchan.ts", import.meta.url)); new vm.Script(source).runInContext(context); const service = window.trx.modules.vchan; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs index 3e838d79..17f848ae 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs @@ -3,14 +3,15 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; test("VDES entry normalizes and forwards positioned server messages", async () => { const forwarded = []; const window = { + ...createHost(), trxUi: { confirm: async () => true }, vdesMapAddPoint: (message) => { forwarded.push(message); }, }; @@ -24,7 +25,7 @@ test("VDES entry normalizes and forwards positioned server messages", async () = console, }); const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); - const source = await readFile(new URL("../../assets/web/generated/vdes.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugins/vdes.ts", import.meta.url)); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wefax.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wefax.test.mjs index be18586c..23364aa6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wefax.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wefax.test.mjs @@ -3,14 +3,14 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", async () => { const status = { textContent: "", style: { color: "" } }; - const window = {}; + const window = { ...createHost() }; const context = vm.createContext({ window, document: { getElementById: (id) => id === "wefax-status" ? status : null }, @@ -21,7 +21,7 @@ test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", a console, }); const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); - const source = await readFile(new URL("../../assets/web/generated/wefax.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugins/wefax.ts", import.meta.url)); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs index af964cc2..a6c6a9b5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs @@ -3,14 +3,15 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; test("WSPR entry forwards decoded locators at their absolute frequency", async () => { const forwarded = []; const window = { + ...createHost(), ft8BaseHz: 14_095_600, trxUi: { confirm: async () => true }, mapAddLocator: (...args) => { forwarded.push(args); }, @@ -27,7 +28,7 @@ test("WSPR entry forwards decoded locators at their absolute frequency", async ( console, }); const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); - const source = await readFile(new URL("../../assets/web/generated/wspr.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugins/wspr.ts", import.meta.url)); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); -- 2.55.0