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 03242457..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); @@ -3625,12 +3626,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 +5721,9 @@ Object.defineProperties(trxState, { decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, + authEnabled: { get() { + return authEnabled; + } }, authRole: { get() { return authRole; } }, @@ -5765,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; @@ -5816,6 +5840,22 @@ var trxCore = Object.freeze({ scheduleUiFrameJob, navigateToTab, rigBadgeColor, + setRigFrequency, + applyLocalTunedFrequency, + armOptimisticFrequency, + 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 b7bfd423..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,3 +1,8 @@ +import { + hostCore, + hostState +} from "./chunk-KL66PICH.js"; + // src/plugins/bookmarks.ts var bridge = window; function bmEl(id) { @@ -5,6 +10,9 @@ function bmEl(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 +42,7 @@ function bmEsc(str) { return d.innerHTML; } function bmCanControl() { - return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control"; + return !hostState.authEnabled || hostState.authRole === "control"; } function bmSyncAccess() { const canCtrl = bmCanControl(); @@ -44,8 +52,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 hostState.lastActiveRigId || "general"; } async function bmFetchOverlay() { const overlayScope = bmListScope(); @@ -61,7 +68,7 @@ async function bmFetchOverlay() { if (typeof bridge.syncBookmarkMapLocators === "function") { bridge.syncBookmarkMapLocators(bmOverlayList); } - if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); + hostCore.scheduleSpectrumDraw(); } async function bmFetch(categoryFilter) { let url = "/bookmarks"; @@ -184,12 +191,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 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 || []); - (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { - const el = bmEl("bm-dec-" + d.id); + hostState.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 +204,7 @@ function bmBuildDecoderCheckboxes() { const container = bmEl("bm-decoder-checkboxes"); if (!container) return; container.innerHTML = ""; - (bridge.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; @@ -227,19 +234,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 = hostState.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 (hostState.lastModeName) { + bmEl("bm-mode").value = hostState.lastModeName; } - if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { - bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); + if (hostState.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(hostState.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 = 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) { @@ -320,55 +325,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); - } + hostState.currentBandwidthHz = bm.bandwidth_hz; + hostCore.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(); + 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; + 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 hostCore.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 hostCore.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}`); - } + hostCore.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 = hostState.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 = hostState.lastActiveRigId; + if (rigId) { + statusUrl += "?remote=" + encodeURIComponent(rigId); } const statusResp = await fetch(statusUrl); if (!statusResp.ok) return; @@ -387,7 +380,7 @@ function bmApply(bm) { wanted = currentlyOn; } if (wanted !== currentlyOn) { - toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); } } if (toggles.length) await Promise.all(toggles); @@ -399,8 +392,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 +430,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 = hostState.lastRigIds; + const displayNames = hostState.lastRigDisplayNames; const prev = sel.value; sel.innerHTML = ""; if (bmScope !== "general") { @@ -545,8 +536,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 = hostState.lastRigIds; + const displayNames = hostState.lastRigDisplayNames; const prev = picker.value; while (picker.options.length > 1) picker.remove(1); rigIds.forEach((id) => { @@ -565,9 +556,7 @@ function bmPopulateScopePicker() { (function initBookmarks() { bmSyncAccess(); bmBuildDecoderCheckboxes(); - if (typeof bridge.onDecoderRegistryReady === "function") { - bridge.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 a3d42565..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; } } @@ -411,6 +405,19 @@ async function vchanInterceptBandwidth(bwHz) { await vchanSetChannelBandwidth(bwHz); return true; } +function vchanInterceptFrequency(freqHz) { + if (!vchanIsOnVirtual()) return false; + const targetHz = Math.round(freqHz); + 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 = { @@ -427,27 +434,11 @@ vchanWindow.trx.modules.vchan = { isOnVirtual: vchanIsOnVirtual, interceptMode: vchanInterceptMode, interceptBandwidth: vchanInterceptBandwidth, + interceptFrequency: vchanInterceptFrequency, + interceptFreqDisplay: vchanInterceptFreqDisplay, 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) { @@ -458,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 fb0f8b9e..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 @@ -15,6 +15,7 @@ import { applyDecoderRegistryVisibility, decoderRegistry, loadDecoderRegistry, + onDecoderRegistryReady, } from "./core/decoder-registry.js"; import { fetchAuthSession, @@ -201,6 +202,8 @@ interface TrxModules { handleChannels(data: string): void; handleSession(data: string): void; interceptBandwidth(bandwidthHz: number): Promise; + interceptFrequency(frequencyHz: number): boolean; + interceptFreqDisplay(): boolean; interceptMode(mode: string): Promise; isOnVirtual(): boolean; takeSchedulerControl(): Promise; @@ -217,6 +220,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; @@ -233,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; @@ -1984,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); @@ -2476,12 +2486,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 +4846,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; } }, @@ -4838,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; } }, @@ -4855,6 +4883,11 @@ 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, + formatFreqForStep, refreshFreqDisplay, setJogDivisor, mwDefaultsForMode, + resetRdsDisplay, positionRdsPsOverlay, updateWfmControls, + updateSdrSquelchControlVisibility, startRxAudio, stopRxAudio, latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm, formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText, nearestBookmarkForHz, currentDecodeHistoryRetentionMs, @@ -5475,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 88d14650..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; @@ -44,31 +38,12 @@ interface VirtualChannelService { } 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: { 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; @@ -79,6 +54,12 @@ function bmEl(id: string): BookmarkElement { 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 +100,7 @@ function bmEsc(str: unknown): string { } function bmCanControl() { - return ( - (typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) || - (typeof bridge.authRole !== "undefined" && bridge.authRole === "control") - ); + return !hostState.authEnabled || hostState.authRole === "control"; } // Show/hide the Add Bookmark / Select All buttons based on the current auth role. @@ -136,8 +114,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 hostState.lastActiveRigId || "general"; } async function bmFetchOverlay() { @@ -154,7 +131,7 @@ async function bmFetchOverlay() { if (typeof bridge.syncBookmarkMapLocators === "function") { bridge.syncBookmarkMapLocators(bmOverlayList); } - if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); + hostCore.scheduleSpectrumDraw(); } async function bmFetch(categoryFilter: string): Promise { @@ -310,19 +287,19 @@ function bmChangePage(delta: number): void { // Read decoder checkboxes and return an array of selected decoder names. function bmReadDecoders(): string[] { - return (bridge.decoderRegistry || []) + return hostState.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 || []) + hostState.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 +309,7 @@ function bmBuildDecoderCheckboxes() { const container = bmEl("bm-decoder-checkboxes"); if (!container) return; container.innerHTML = ""; - (bridge.decoderRegistry || []) + hostState.decoderRegistry .filter(d => d.bookmark_selectable) .forEach(d => { const label = document.createElement("label"); @@ -372,23 +349,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 = hostState.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 (hostState.lastModeName) { + bmEl("bm-mode").value = hostState.lastModeName; } - if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { - bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); + if (hostState.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz)); } // Prefill decoder checkboxes from current toggle button state. - const activeDecoders = (bridge.decoderRegistry || []) + const activeDecoders = hostState.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 +453,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); - } + hostState.currentBandwidthHz = bm.bandwidth_hz; + hostCore.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. + hostCore.armOptimisticFrequency(bm.freq_hz); + // Force display so the BW overlay is repositioned even when freq is unchanged. + hostCore.applyLocalTunedFrequency(bm.freq_hz, true); + if (hostState.lastSpectrumData) { + hostCore.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 hostCore.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 hostCore.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. + hostCore.setRigFrequency(bm.freq_hz); })(); // Decoder toggles — fire-and-forget. // - Decoders incompatible with the new mode are always turned off @@ -539,13 +500,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 = hostState.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 = hostState.lastActiveRigId; + if (rigId) { + statusUrl += "?remote=" + encodeURIComponent(rigId); } const statusResp = await fetch(statusUrl); if (!statusResp.ok) return; @@ -567,7 +529,7 @@ function bmApply(bm: Bookmark): void { wanted = currentlyOn; } if (wanted !== currentlyOn) { - toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); } } if (toggles.length) await Promise.all(toggles); @@ -582,8 +544,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 +579,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 = hostState.lastRigIds; + const displayNames = hostState.lastRigDisplayNames; const prev = sel.value; sel.innerHTML = ""; if (bmScope !== "general") { @@ -728,8 +688,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 = hostState.lastRigIds; + const displayNames = hostState.lastRigDisplayNames; // Preserve current selection if still valid. const prev = picker.value; while (picker.options.length > 1) picker.remove(1); @@ -756,9 +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(); - if (typeof bridge.onDecoderRegistryReady === "function") { - bridge.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 6fe984b8..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,35 +23,12 @@ 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; - _freqOptimisticSeq?: number; - _freqOptimisticHz?: number; 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; - applyLocalTunedFrequency?: (frequencyHz: number) => void; - setRigFrequency?: (frequencyHz: number) => void; - refreshFreqDisplay?: () => void; trx?: { modules?: { vchan?: VirtualChannelService } }; } @@ -63,6 +42,8 @@ interface VirtualChannelService { isOnVirtual(): boolean; interceptMode(mode: string): Promise; interceptBandwidth(bandwidthHz: number): Promise; + interceptFrequency(frequencyHz: number): boolean; + interceptFreqDisplay(): boolean; takeSchedulerControl(): Promise; releaseToScheduler(): Promise; } @@ -146,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", @@ -261,9 +242,7 @@ function vchanRender() { picker.appendChild(addBtn); vchanSyncAccentUI(); - if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { - vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); - } + hostCore.updateDocumentTitle(hostCore.activeChannelRds()); vchanRenderSchedulerRelease(); } @@ -271,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"; @@ -374,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); } @@ -414,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. @@ -432,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(); } } @@ -459,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; } } @@ -480,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; } } @@ -571,6 +539,26 @@ 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 targetHz = Math.round(freqHz); + 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 = { @@ -583,34 +571,12 @@ vchanWindow.trx.modules.vchan = { isOnVirtual: vchanIsOnVirtual, interceptMode: vchanInterceptMode, interceptBandwidth: vchanInterceptBandwidth, + interceptFrequency: vchanInterceptFrequency, + interceptFreqDisplay: vchanInterceptFreqDisplay, 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) { @@ -622,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 15b9440b..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() { @@ -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 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(); 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/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 b555f8e5..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; @@ -34,6 +35,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); }); 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);