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