Compare commits

...
Author SHA1 Message Date
sjgandClaude Opus 5 0d4c657b97 [fix](trx-frontend-http): route feature bundles through the host contract
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 41s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / frontend (push) Failing after 36s
CI / reuse (push) Failing after 1s
The bookmark fix addressed one instance of a defect the TypeScript
migration left across the feature entries.  app.js stopped being a
classic script, so its top-level declarations are no longer shared
globals, but the converted entries kept reading them as window
properties that nothing publishes.

Restore the broken behavior:

- ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as
  undefined, so every positioned packet rendered an empty distance.
- ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined
  postPath, so clear-history and decoder toggles threw.
- scheduler read authRole as undefined, so the lazy-load path never
  self-initialized and the Settings tab opened an inert scheduler.
- background-decode read authEnabled as undefined, so control gating
  fell back to role-only.
- vchan read fifteen application values and services as undefined:
  mode and bandwidth sync, the out-of-band hint, RX audio restart, and
  the frequency field all silently no-opped on a virtual channel.
- vchan wrapped window.refreshFreqDisplay, capturing an undefined
  original exactly as it did for setRigFrequency, so leaving a channel
  never restored the application's own frequency display.
- _audioChannelOverride was a const that nothing could assign, so RX
  audio always subscribed to the primary channel.
- ftx-family read fmtTime, a helper legacy ft8.js owned locally, so
  decode bar timestamps rendered empty.

Declare the contract once in plugins/host.ts and import it from the
feature entries, rather than restoring globals that
docs/frontend-architecture.md excludes.  trx.state gains jogUnit,
rxActive and audioChannelOverride, and makes lastModeName writable;
trx.core gains the tuning, RDS, WFM, jog and RX audio services the
entries need.  vchan interception moves to an interceptFreqDisplay
service method that refreshFreqDisplay calls, matching the frequency,
mode and bandwidth interception it already registers.

Reading registry-built elements through a strict lookup is the same
defect as in bookmarks: renderTimelineNeedle guards its result, but
schedulerEl throws, so the now-initializing scheduler crashed on the
timeline needle group that its own SVG creates.

Feature tests move onto a shared host fixture, and entries that now
import a common module are bundled through bundleEntry like the other
shared-module entries.  Covers scheduler self-initialization and the
distance path that the bare window reads broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:20:17 +02:00
sjgandClaude Opus 5 23dbcac5b6 [fix](trx-frontend-http): restore bookmark host contract
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 42s
CI / reuse (pull_request) Failing after 0s
The TypeScript migration turned app.js from a classic script into an ES
module, so its top-level declarations stopped being shared globals.
bookmarks.ts was converted verbatim and kept reading them as window
properties, which app.ts no longer publishes.

Every bookmark interaction read undefined: the Add Bookmark and Select
All buttons stayed hidden because the auth check saw no authEnabled or
authRole, per-rig scopes were missing from the scope picker and the move
target, decoder checkboxes were never built, and Tune threw on
bridge.postPath before issuing a single request.

Extend the typed window.trx host contract instead of restoring globals,
as docs/frontend-architecture.md closes the standalone window property
list.  trx.state publishes authEnabled; trx.core publishes
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, and onDecoderRegistryReady.

Replace the vchan setRigFrequency wrapper with an interceptFrequency
service method, matching interceptMode and interceptBandwidth.  The
wrapper captured an undefined original and silently dropped every tune;
routing interception through setRigFrequency also restores virtual
channel redirection for the application's own tuning.

Read registry-built elements through bmOptionalEl, since bmEl throws and
the decoder checkboxes and decode toggle buttons are legitimately absent
until the registry arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 10:15:56 +02:00
50 changed files with 902 additions and 506 deletions
+9
View File
@@ -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.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. | | `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`, The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`,
`trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet `trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet
adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet
@@ -1,6 +1,11 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/ais.ts // src/plugins/ais.ts
var aisWindow = window; var aisWindow = window;
var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); var escapeAisHtml = (input) => hostCore.escapeMapHtml(input);
var aisStatus = document.getElementById("ais-status"); var aisStatus = document.getElementById("ais-status");
var aisMessagesEl = document.getElementById("ais-messages"); var aisMessagesEl = document.getElementById("ais-messages");
var aisFilterInput = document.getElementById("ais-filter"); var aisFilterInput = document.getElementById("ais-filter");
@@ -119,10 +124,10 @@ function aisRouteText(msg) {
return [msg.callsign, msg.destination].filter(Boolean).join(" -> "); return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
} }
function aisDistanceText(msg) { 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 ""; 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 (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`; return `${distKm.toFixed(1)} km from TRX`;
@@ -303,7 +308,7 @@ document.getElementById("settings-clear-ais-history")?.addEventListener("click",
void (async () => { void (async () => {
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await aisWindow.postPath?.("/clear_ais_decode"); await hostCore.postPath("/clear_ais_decode");
resetAisHistoryView(); resetAisHistoryView();
} catch (e) { } catch (e) {
console.error("AIS history clear failed", e); console.error("AIS history clear failed", e);
@@ -3206,6 +3206,7 @@ function refreshWavelengthDisplay(hz) {
wavelengthEl.textContent = formatWavelength(hz); wavelengthEl.textContent = formatWavelength(hz);
} }
function refreshFreqDisplay() { function refreshFreqDisplay() {
if (window.trx?.modules.vchan?.interceptFreqDisplay()) return;
if (lastFreqHz == null || freqDirty) return; if (lastFreqHz == null || freqDirty) return;
freqEl.value = formatFrequencyForStep(lastFreqHz, jogUnit); freqEl.value = formatFrequencyForStep(lastFreqHz, jogUnit);
refreshWavelengthDisplay(lastFreqHz); refreshWavelengthDisplay(lastFreqHz);
@@ -3625,12 +3626,19 @@ async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardB
} }
var _freqOptimisticHz = null; var _freqOptimisticHz = null;
var _freqOptimisticSeq = 0; var _freqOptimisticSeq = 0;
function armOptimisticFrequency(freqHz) {
if (!isFiniteNumber(freqHz)) return;
_freqOptimisticSeq += 1;
_freqOptimisticHz = Math.round(freqHz);
}
function setRigFrequency(freqHz) { function setRigFrequency(freqHz) {
const targetHz = Math.round(freqHz); const targetHz = Math.round(freqHz);
if (!freqAllowed(targetHz)) { if (!freqAllowed(targetHz)) {
showUnsupportedFreqPopup(targetHz); showUnsupportedFreqPopup(targetHz);
throw new Error(`Unsupported frequency: ${targetHz}`); throw new Error(`Unsupported frequency: ${targetHz}`);
} }
if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return;
void window.trx?.modules.vchan?.takeSchedulerControl();
const prevFreqHz = lastFreqHz; const prevFreqHz = lastFreqHz;
const seq = ++_freqOptimisticSeq; const seq = ++_freqOptimisticSeq;
_freqOptimisticHz = targetHz; _freqOptimisticHz = targetHz;
@@ -5713,6 +5721,9 @@ Object.defineProperties(trxState, {
decodeHistoryRetentionMin: { get() { decodeHistoryRetentionMin: { get() {
return decodeHistoryRetentionMin; return decodeHistoryRetentionMin;
} }, } },
authEnabled: { get() {
return authEnabled;
} },
authRole: { get() { authRole: { get() {
return authRole; return authRole;
} }, } },
@@ -5765,6 +5776,19 @@ Object.defineProperties(trxState, {
} }, } },
lastModeName: { get() { lastModeName: { get() {
return lastModeName; return lastModeName;
}, set(v) {
lastModeName = v;
} },
jogUnit: { get() {
return jogUnit;
} },
rxActive: { get() {
return rxActive;
} },
audioChannelOverride: { get() {
return _audioChannelOverride;
}, set(v) {
_audioChannelOverride = v;
} }, } },
lastSpectrumData: { get() { lastSpectrumData: { get() {
return lastSpectrumData; return lastSpectrumData;
@@ -5816,6 +5840,22 @@ var trxCore = Object.freeze({
scheduleUiFrameJob, scheduleUiFrameJob,
navigateToTab, navigateToTab,
rigBadgeColor, rigBadgeColor,
setRigFrequency,
applyLocalTunedFrequency,
armOptimisticFrequency,
syncBandwidthInput,
scheduleSpectrumDraw,
onDecoderRegistryReady,
formatFreqForStep: formatFrequencyForStep,
refreshFreqDisplay,
setJogDivisor,
mwDefaultsForMode,
resetRdsDisplay,
positionRdsPsOverlay,
updateWfmControls,
updateSdrSquelchControlVisibility,
startRxAudio,
stopRxAudio,
latLonToMaidenhead, latLonToMaidenhead,
locatorToLatLon, locatorToLatLon,
haversineKm, haversineKm,
@@ -8,12 +8,16 @@ import {
renderAprsInfo, renderAprsInfo,
renderLocalAprsSymbol renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js"; } from "./chunk-M2I6DH4X.js";
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/aprs.ts // src/plugins/aprs.ts
var aprsWindow = window; var aprsWindow = window;
var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); var escapeAprsHtml = (input) => hostCore.escapeMapHtml(input);
var showAprsHint = (message, durationMs) => { var showAprsHint = (message, durationMs) => {
aprsWindow.showHint?.(message, durationMs); hostCore.showHint(message, durationMs);
}; };
var aprsStatus = document.getElementById("aprs-status"); var aprsStatus = document.getElementById("aprs-status");
var aprsPacketsEl = document.getElementById("aprs-packets"); var aprsPacketsEl = document.getElementById("aprs-packets");
@@ -58,8 +62,8 @@ function scheduleAprsBarUpdate() {
}); });
} }
function aprsDistanceText(pkt) { function aprsDistanceText(pkt) {
if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return ""; if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon); const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return ""; if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`; return `${distKm.toFixed(1)} km from TRX`;
@@ -260,7 +264,7 @@ document.getElementById("settings-clear-aprs-history")?.addEventListener("click"
void (async () => { void (async () => {
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await aprsWindow.postPath?.("/clear_aprs_decode"); await hostCore.postPath("/clear_aprs_decode");
resetAprsHistoryView(); resetAprsHistoryView();
} catch (e) { } catch (e) {
console.error("APRS history clear failed", e); console.error("APRS history clear failed", e);
@@ -1,3 +1,7 @@
import {
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/background-decode.ts // src/plugins/background-decode.ts
var bgdWindow = window; var bgdWindow = window;
(function() { (function() {
@@ -104,7 +108,7 @@ var bgdWindow = window;
} }
setCheckbox("background-decode-enabled", currentConfig.enabled); setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist(); renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || bgdWindow.authEnabled === false; const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const panel = document.getElementById("background-decode-panel"); const panel = document.getElementById("background-decode-panel");
if (panel) { if (panel) {
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) { panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
@@ -1,3 +1,8 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/bookmarks.ts // src/plugins/bookmarks.ts
var bridge = window; var bridge = window;
function bmEl(id) { function bmEl(id) {
@@ -5,6 +10,9 @@ function bmEl(id) {
if (!element) throw new Error(`Missing bookmark element #${id}`); if (!element) throw new Error(`Missing bookmark element #${id}`);
return element; return element;
} }
function bmOptionalEl(id) {
return document.getElementById(id);
}
function errorMessage(error) { function errorMessage(error) {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
@@ -34,7 +42,7 @@ function bmEsc(str) {
return d.innerHTML; return d.innerHTML;
} }
function bmCanControl() { function bmCanControl() {
return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control"; return !hostState.authEnabled || hostState.authRole === "control";
} }
function bmSyncAccess() { function bmSyncAccess() {
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
@@ -44,8 +52,7 @@ function bmSyncAccess() {
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
} }
function bmListScope() { function bmListScope() {
const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null; return hostState.lastActiveRigId || "general";
return rig || "general";
} }
async function bmFetchOverlay() { async function bmFetchOverlay() {
const overlayScope = bmListScope(); const overlayScope = bmListScope();
@@ -61,7 +68,7 @@ async function bmFetchOverlay() {
if (typeof bridge.syncBookmarkMapLocators === "function") { if (typeof bridge.syncBookmarkMapLocators === "function") {
bridge.syncBookmarkMapLocators(bmOverlayList); bridge.syncBookmarkMapLocators(bmOverlayList);
} }
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); hostCore.scheduleSpectrumDraw();
} }
async function bmFetch(categoryFilter) { async function bmFetch(categoryFilter) {
let url = "/bookmarks"; let url = "/bookmarks";
@@ -184,12 +191,12 @@ function bmChangePage(delta) {
bmRender(bmFilteredList); bmRender(bmFilteredList);
} }
function bmReadDecoders() { function bmReadDecoders() {
return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id); return hostState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
} }
function bmWriteDecoders(decoders) { function bmWriteDecoders(decoders) {
const set = new Set(decoders || []); const set = new Set(decoders || []);
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
const el = bmEl("bm-dec-" + d.id); const el = bmOptionalEl("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id); if (el) el.checked = set.has(d.id);
}); });
} }
@@ -197,7 +204,7 @@ function bmBuildDecoderCheckboxes() {
const container = bmEl("bm-decoder-checkboxes"); const container = bmEl("bm-decoder-checkboxes");
if (!container) return; if (!container) return;
container.innerHTML = ""; container.innerHTML = "";
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
const label = document.createElement("label"); const label = document.createElement("label");
label.className = "bm-decoder-check"; label.className = "bm-decoder-check";
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label; label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
@@ -227,19 +234,17 @@ function bmCloseForm() {
if (wrap) wrap.style.display = "none"; if (wrap) wrap.style.display = "none";
} }
function bmPrefillFromStatus() { function bmPrefillFromStatus() {
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) { const freqHz = hostState.lastFreqHz;
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz)); if (freqHz != null && Number.isFinite(freqHz)) {
bmEl("bm-freq").value = String(Math.round(freqHz));
} }
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) { if (hostState.lastModeName) {
bmEl("bm-mode").value = bridge.lastModeName; bmEl("bm-mode").value = hostState.lastModeName;
} }
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { if (hostState.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
} }
const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => { 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);
const btn = bmEl(d.id + "-decode-toggle-btn");
return btn && btn.dataset.enabled === "true";
}).map((d) => d.id);
bmWriteDecoders(activeDecoders); bmWriteDecoders(activeDecoders);
} }
async function bmSave(e) { async function bmSave(e) {
@@ -320,55 +325,43 @@ async function bmDelete(id) {
} }
function bmApply(bm) { function bmApply(bm) {
try { try {
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) { const modeEl = document.getElementById("mode");
bridge.modeEl.value = (bm.mode || "").toUpperCase(); if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
if (typeof bridge.currentBandwidthHz !== "undefined") { hostState.currentBandwidthHz = bm.bandwidth_hz;
bridge.currentBandwidthHz = bm.bandwidth_hz; hostCore.syncBandwidthInput(bm.bandwidth_hz);
}
bridge.currentBandwidthHz = bm.bandwidth_hz;
if (typeof bridge.syncBandwidthInput === "function") {
bridge.syncBandwidthInput(bm.bandwidth_hz);
}
} }
if (typeof bridge.applyLocalTunedFrequency === "function") { hostCore.armOptimisticFrequency(bm.freq_hz);
if (typeof bridge._freqOptimisticSeq !== "undefined") { hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
++bridge._freqOptimisticSeq; if (hostState.lastSpectrumData) {
bridge._freqOptimisticHz = bm.freq_hz; hostCore.scheduleSpectrumDraw();
}
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
}
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
bridge.scheduleSpectrumDraw();
} }
const tunePromise = (async () => { const tunePromise = (async () => {
await bridge.trx?.modules?.vchan?.takeSchedulerControl(); await bridge.trx.modules.vchan?.takeSchedulerControl();
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false; const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) { if (!onVirtual) {
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
const bwHandledByVchan = await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
if (!bwHandledByVchan) { if (!bwHandledByVchan) {
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
} }
} }
if (typeof bridge.setRigFrequency === "function") { hostCore.setRigFrequency(bm.freq_hz);
await bridge.setRigFrequency(bm.freq_hz);
} else {
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
}
})(); })();
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase(); const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = (bridge.decoderRegistry || []).filter( const allToggleDecoders = hostState.decoderRegistry.filter(
(d) => d.activation === "toggle" (d) => d.activation === "toggle"
); );
const decoderPromise = allToggleDecoders.length ? (async () => { const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status"; let statusUrl = "/status";
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) { const rigId = hostState.lastActiveRigId;
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId); if (rigId) {
statusUrl += "?remote=" + encodeURIComponent(rigId);
} }
const statusResp = await fetch(statusUrl); const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return; if (!statusResp.ok) return;
@@ -387,7 +380,7 @@ function bmApply(bm) {
wanted = currentlyOn; wanted = currentlyOn;
} }
if (wanted !== currentlyOn) { if (wanted !== currentlyOn) {
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
} }
} }
if (toggles.length) await Promise.all(toggles); if (toggles.length) await Promise.all(toggles);
@@ -399,8 +392,6 @@ function bmApply(bm) {
console.error("Failed to apply bookmark:", err); console.error("Failed to apply bookmark:", err);
} }
} }
bridge.trx ??= {};
bridge.trx.modules ??= {};
bridge.trx.modules.bookmarks = { bridge.trx.modules.bookmarks = {
get overlayList() { get overlayList() {
return bmOverlayList; return bmOverlayList;
@@ -439,8 +430,8 @@ function bmUpdateSelectionUi() {
function bmPopulateMoveTarget() { function bmPopulateMoveTarget() {
const sel = bmEl("bm-move-target"); const sel = bmEl("bm-move-target");
if (!sel) return; if (!sel) return;
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; const rigIds = hostState.lastRigIds;
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; const displayNames = hostState.lastRigDisplayNames;
const prev = sel.value; const prev = sel.value;
sel.innerHTML = ""; sel.innerHTML = "";
if (bmScope !== "general") { if (bmScope !== "general") {
@@ -545,8 +536,8 @@ async function bmDeleteSelected() {
function bmPopulateScopePicker() { function bmPopulateScopePicker() {
const picker = bmEl("bm-scope-picker"); const picker = bmEl("bm-scope-picker");
if (!picker) return; if (!picker) return;
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; const rigIds = hostState.lastRigIds;
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; const displayNames = hostState.lastRigDisplayNames;
const prev = picker.value; const prev = picker.value;
while (picker.options.length > 1) picker.remove(1); while (picker.options.length > 1) picker.remove(1);
rigIds.forEach((id) => { rigIds.forEach((id) => {
@@ -565,9 +556,7 @@ function bmPopulateScopePicker() {
(function initBookmarks() { (function initBookmarks() {
bmSyncAccess(); bmSyncAccess();
bmBuildDecoderCheckboxes(); bmBuildDecoderCheckboxes();
if (typeof bridge.onDecoderRegistryReady === "function") { hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
}
bmPopulateScopePicker(); bmPopulateScopePicker();
const scopePicker = bmEl("bm-scope-picker"); const scopePicker = bmEl("bm-scope-picker");
if (scopePicker) { if (scopePicker) {
@@ -0,0 +1,9 @@
// src/plugins/host.ts
var host = window;
var hostState = host.trx.state;
var hostCore = host.trx.core;
export {
hostState,
hostCore
};
@@ -1,5 +1,13 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/ftx-family.ts // src/plugins/ftx-family.ts
var bridge = window; 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) { function finiteNumber(value) {
const number = typeof value === "number" ? value : Number(value); const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : null; return Number.isFinite(number) ? number : null;
@@ -198,7 +206,7 @@ function initializeFtxDecoder(config) {
let html = ""; let html = "";
for (const message of recent) { for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`; const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
const snr = finiteNumber(message.snr_db); const snr = finiteNumber(message.snr_db);
const delta = finiteNumber(message.dt_s); const delta = finiteNumber(message.dt_s);
const frequency = displayFrequency(message.freq_hz); const frequency = displayFrequency(message.freq_hz);
@@ -243,7 +251,7 @@ function initializeFtxDecoder(config) {
void (async () => { void (async () => {
try { try {
await bridge.takeSchedulerControlForDecoderDisable?.(toggle); await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
await bridge.postPath?.(`/toggle_${id}_decode`); await hostCore.postPath(`/toggle_${id}_decode`);
} catch (error) { } catch (error) {
console.error(`${label} toggle failed`, error); console.error(`${label} toggle failed`, error);
} }
@@ -253,7 +261,7 @@ function initializeFtxDecoder(config) {
void (async () => { void (async () => {
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return; if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
try { try {
await bridge.postPath?.(`/clear_${id}_decode`); await hostCore.postPath(`/clear_${id}_decode`);
reset(); reset();
} catch (error) { } catch (error) {
console.error(`${label} history clear failed`, error); console.error(`${label} history clear failed`, error);
@@ -1,3 +1,7 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/cw.ts // src/plugins/cw.ts
var cwWindow = window; var cwWindow = window;
var cwStatusEl = document.getElementById("cw-status"); var cwStatusEl = document.getElementById("cw-status");
@@ -25,7 +29,7 @@ var cwBarCurrentLine = null;
var cwBarDismissedAtMs = 0; var cwBarDismissedAtMs = 0;
var cwAutoLocalOverride = null; var cwAutoLocalOverride = null;
function escapeCwHtml(input) { function escapeCwHtml(input) {
return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); return hostCore.escapeMapHtml(input);
} }
function applyCwAutoUi(enabled) { function applyCwAutoUi(enabled) {
if (cwAutoInput) cwAutoInput.checked = enabled; if (cwAutoInput) cwAutoInput.checked = enabled;
@@ -246,7 +250,7 @@ async function setCwTone(tone, { syncInput = true } = {}) {
cwToneInput.value = String(clamped); cwToneInput.value = String(clamped);
} }
try { try {
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) { } catch (e) {
console.error("CW tone set failed", e); console.error("CW tone set failed", e);
} }
@@ -259,7 +263,7 @@ if (cwAutoInput) {
cwAutoLocalOverride = enabled; cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled); applyCwAutoUi(enabled);
try { try {
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker(); drawCwTonePicker();
} catch (error) { } catch (error) {
console.error("CW auto toggle failed", error); console.error("CW auto toggle failed", error);
@@ -276,7 +280,7 @@ if (cwWpmInput) {
const wpm = clampCwWpm(cwWpmInput.value); const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = String(wpm); cwWpmInput.value = String(wpm);
try { try {
await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
} catch (error) { } catch (error) {
console.error("CW WPM set failed", error); console.error("CW WPM set failed", error);
} }
@@ -312,7 +316,7 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
void (async () => { void (async () => {
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await cwWindow.postPath?.("/clear_cw_decode"); await hostCore.postPath("/clear_cw_decode");
resetCwHistoryView(); resetCwHistoryView();
} catch (error) { } catch (error) {
console.error("CW history clear failed", error); console.error("CW history clear failed", error);
@@ -1,6 +1,7 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-SGMG5LG2.js"; } from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts // src/plugins/ft2.ts
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 }); initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 });
@@ -1,6 +1,7 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-SGMG5LG2.js"; } from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts // src/plugins/ft4.ts
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 }); initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 });
@@ -2,7 +2,8 @@ import {
initializeFt8FamilyBar, initializeFt8FamilyBar,
initializeFtxDecoder, initializeFtxDecoder,
installFtxCompatibilityHelpers installFtxCompatibilityHelpers
} from "./chunk-SGMG5LG2.js"; } from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts // src/plugins/ft8.ts
installFtxCompatibilityHelpers(); installFtxCompatibilityHelpers();
@@ -8,10 +8,14 @@ import {
renderAprsInfo, renderAprsInfo,
renderLocalAprsSymbol renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js"; } from "./chunk-M2I6DH4X.js";
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/hf-aprs.ts // src/plugins/hf-aprs.ts
var hfAprsWindow = window; var hfAprsWindow = window;
var escapeHfAprsHtml = (input) => hfAprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input);
var hfAprsStatus = document.getElementById("hf-aprs-status"); var hfAprsStatus = document.getElementById("hf-aprs-status");
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets"); var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
var hfAprsFilterInput = document.getElementById("hf-aprs-filter"); var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
@@ -44,8 +48,8 @@ function scheduleHfAprsHistoryRender() {
renderHfAprsHistory(); renderHfAprsHistory();
} }
function hfAprsDistanceText(pkt) { function hfAprsDistanceText(pkt) {
if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return ""; if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon); const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return ""; if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`; return `${distKm.toFixed(1)} km from TRX`;
@@ -134,10 +138,10 @@ function renderHfAprsRow(pkt, isFresh) {
const clipboard = Reflect.get(navigator, "clipboard"); const clipboard = Reflect.get(navigator, "clipboard");
if (clipboard) { if (clipboard) {
await clipboard.writeText(raw); await clipboard.writeText(raw);
hfAprsWindow.showHint?.("Coordinates copied", 1200); hostCore.showHint("Coordinates copied", 1200);
} }
} catch { } catch {
hfAprsWindow.showHint?.("Copy failed", 1500); hostCore.showHint("Copy failed", 1500);
} }
})(); })();
}); });
@@ -201,7 +205,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", () => {
void (async () => { void (async () => {
try { try {
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn); await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode"); await hostCore.postPath("/toggle_hf_aprs_decode");
} catch (e) { } catch (e) {
console.error("HF APRS toggle failed", e); console.error("HF APRS toggle failed", e);
} }
@@ -211,7 +215,7 @@ document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("cli
void (async () => { 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; 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 { try {
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode"); await hostCore.postPath("/clear_hf_aprs_decode");
resetHfAprsHistoryView(); resetHfAprsHistoryView();
} catch (e) { } catch (e) {
console.error("HF APRS history clear failed", e); console.error("HF APRS history clear failed", e);
@@ -1,3 +1,7 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/sat.ts // src/plugins/sat.ts
var satWindow = window; var satWindow = window;
var satDom = { var satDom = {
@@ -243,7 +247,7 @@ lrptDecodeToggleBtn?.addEventListener("click", () => {
void (async () => { void (async () => {
try { try {
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn); await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await satWindow.postPath?.("/toggle_lrpt_decode"); await hostCore.postPath("/toggle_lrpt_decode");
} catch (e) { } catch (e) {
console.error("LRPT toggle failed", e); console.error("LRPT toggle failed", e);
} }
@@ -264,7 +268,7 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
void (async () => { void (async () => {
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await satWindow.postPath?.("/clear_lrpt_decode"); await hostCore.postPath("/clear_lrpt_decode");
resetSatHistoryView(); resetSatHistoryView();
} catch (e) { } catch (e) {
console.error("Weather satellite history clear failed", e); console.error("Weather satellite history clear failed", e);
@@ -1,3 +1,7 @@
import {
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/scheduler.ts // src/plugins/scheduler.ts
var schedulerWindow = window; var schedulerWindow = window;
var wiredElements = /* @__PURE__ */ new WeakSet(); var wiredElements = /* @__PURE__ */ new WeakSet();
@@ -6,6 +10,9 @@ function schedulerEl(id) {
if (!element) throw new Error(`Missing scheduler element #${id}`); if (!element) throw new Error(`Missing scheduler element #${id}`);
return element; return element;
} }
function schedulerOptionalEl(id) {
return document.getElementById(id);
}
(function() { (function() {
"use strict"; "use strict";
let schedulerRole = null; let schedulerRole = null;
@@ -359,8 +366,8 @@ function schedulerEl(id) {
renderSatelliteSection(); renderSatelliteSection();
if (mode === "grayline" && currentConfig && currentConfig.grayline) { if (mode === "grayline" && currentConfig && currentConfig.grayline) {
const gl = currentConfig.grayline; const gl = currentConfig.grayline;
const lat = gl.lat ?? schedulerWindow.serverLat ?? ""; const lat = gl.lat ?? hostState.serverLat ?? "";
const lon = gl.lon ?? schedulerWindow.serverLon ?? ""; const lon = gl.lon ?? hostState.serverLon ?? "";
setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : "");
const gridEl = schedulerEl("scheduler-gl-grid"); const gridEl = schedulerEl("scheduler-gl-grid");
@@ -373,8 +380,8 @@ function schedulerEl(id) {
renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id); renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id);
renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id);
} else if (mode === "grayline") { } else if (mode === "grayline") {
const lat = schedulerWindow.serverLat ?? ""; const lat = hostState.serverLat ?? "";
const lon = schedulerWindow.serverLon ?? ""; const lon = hostState.serverLon ?? "";
setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : "");
const gridEl2 = schedulerEl("scheduler-gl-grid"); const gridEl2 = schedulerEl("scheduler-gl-grid");
@@ -624,7 +631,7 @@ function schedulerEl(id) {
return '<line class="sch-timeline-needle" x1="' + x.toFixed(1) + '" y1="2" x2="' + x.toFixed(1) + '" y2="38" /><polygon class="sch-timeline-needle-head" points="' + (x - 3).toFixed(1) + ",2 " + (x + 3).toFixed(1) + ",2 " + x.toFixed(1) + ',6" />'; return '<line class="sch-timeline-needle" x1="' + x.toFixed(1) + '" y1="2" x2="' + x.toFixed(1) + '" y2="38" /><polygon class="sch-timeline-needle-head" points="' + (x - 3).toFixed(1) + ",2 " + (x + 3).toFixed(1) + ",2 " + x.toFixed(1) + ',6" />';
} }
function renderTimelineNeedle() { function renderTimelineNeedle() {
const g = schedulerEl("sch-timeline-needle-g"); const g = schedulerOptionalEl("sch-timeline-needle-g");
if (g) g.innerHTML = timelineNeedleSvg(); if (g) g.innerHTML = timelineNeedleSvg();
} }
function schInlineEdit(tr, entry, idx) { function schInlineEdit(tr, entry, idx) {
@@ -1213,8 +1220,8 @@ function schedulerEl(id) {
markDirty: markSchedulerDirty markDirty: markSchedulerDirty
}; };
schedulerWindow.trx.modules.scheduler = schedulerService; schedulerWindow.trx.modules.scheduler = schedulerService;
if (schedulerWindow.authRole != null) { if (hostState.authRole != null) {
initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole); initScheduler(hostState.lastActiveRigId, hostState.authRole);
wireSchedulerEvents(); wireSchedulerEvents();
} }
})(); })();
@@ -1,3 +1,8 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/vchan.ts // src/plugins/vchan.ts
var vchanWindow = window; var vchanWindow = window;
var vchanSessionId = null; var vchanSessionId = null;
@@ -63,7 +68,7 @@ function vchanStartSchedulerReleasePolling() {
} }
async function vchanToggleSchedulerRelease() { async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return; if (!vchanSessionId) return;
const rigId = vchanRigId || vchanWindow.lastActiveRigId || null; const rigId = vchanRigId || hostState.lastActiveRigId || null;
try { try {
const resp = await fetch("/scheduler-control", { const resp = await fetch("/scheduler-control", {
method: "PUT", method: "PUT",
@@ -161,14 +166,12 @@ function vchanRender() {
}); });
picker.appendChild(addBtn); picker.appendChild(addBtn);
vchanSyncAccentUI(); vchanSyncAccentUI();
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { hostCore.updateDocumentTitle(hostCore.activeChannelRds());
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
}
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
} }
async function vchanAllocate() { async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return; 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 modeEl = document.getElementById("mode");
const mode = modeEl ? modeEl.value || "USB" : "USB"; const mode = modeEl ? modeEl.value || "USB" : "USB";
try { try {
@@ -251,11 +254,11 @@ async function vchanSubscribe(channelId) {
} }
function vchanReconnectAudio() { function vchanReconnectAudio() {
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
vchanWindow._audioChannelOverride = ch?.id ?? null; hostState.audioChannelOverride = ch?.id ?? null;
if (!vchanWindow.rxActive) return; if (!hostState.rxActive) return;
vchanWindow.stopRxAudio?.(); hostCore.stopRxAudio();
setTimeout(() => { setTimeout(() => {
vchanWindow.startRxAudio?.(); hostCore.startRxAudio();
}, 300); }, 300);
} }
function vchanApplyCapabilities(caps) { function vchanApplyCapabilities(caps) {
@@ -276,11 +279,7 @@ function vchanUpdateFreqDisplay() {
if (!ch) return; if (!ch) return;
const el = document.getElementById("freq"); const el = document.getElementById("freq");
if (!el) return; if (!el) return;
if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") { el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
} else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
}
} }
function vchanSyncModeDisplay() { function vchanSyncModeDisplay() {
const modeEl = document.getElementById("mode"); const modeEl = document.getElementById("mode");
@@ -290,21 +289,21 @@ function vchanSyncModeDisplay() {
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
} }
const modeUpper = (modeEl.value || "").toUpperCase(); const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof vchanWindow.lastModeName === "string") { if (typeof hostState.lastModeName === "string") {
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") { if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
vchanWindow.setJogDivisor?.(10); hostCore.setJogDivisor(10);
vchanWindow.resetRdsDisplay?.(); hostCore.resetRdsDisplay();
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") { } else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
vchanWindow.resetRdsDisplay?.(); hostCore.resetRdsDisplay();
} }
vchanWindow.lastModeName = modeUpper; hostState.lastModeName = modeUpper;
} }
vchanWindow.updateWfmControls?.(); hostCore.updateWfmControls();
vchanWindow.updateSdrSquelchControlVisibility?.(); hostCore.updateSdrSquelchControlVisibility();
if (vchanWindow.refreshRdsUi) { if (vchanWindow.refreshRdsUi) {
vchanWindow.refreshRdsUi(); vchanWindow.refreshRdsUi();
} else { } else {
vchanWindow.positionRdsPsOverlay?.(); hostCore.positionRdsPsOverlay();
} }
} }
function vchanSyncBwDisplay() { function vchanSyncBwDisplay() {
@@ -314,12 +313,12 @@ function vchanSyncBwDisplay() {
const bwEl = document.getElementById("spectrum-bw-input"); const bwEl = document.getElementById("spectrum-bw-input");
if (!bwEl) return; if (!bwEl) return;
let bwHz = ch.bandwidth_hz || 0; let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && vchanWindow.mwDefaultsForMode) { if (bwHz === 0) {
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0; bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
} }
if (bwHz > 0) { if (bwHz > 0) {
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, ""); bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
vchanWindow.currentBandwidthHz = bwHz; hostState.currentBandwidthHz = bwHz;
} }
} }
function vchanSyncAccentUI() { function vchanSyncAccentUI() {
@@ -333,25 +332,20 @@ function vchanSyncAccentUI() {
vchanSyncModeDisplay(); vchanSyncModeDisplay();
vchanSyncBwDisplay(); vchanSyncBwDisplay();
} else { } else {
origRefreshFreqDisplay?.(); hostCore.refreshFreqDisplay();
}
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
} }
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
} }
var origRefreshFreqDisplay = null;
function vchanSetChannelFreq(freqHz) { function vchanSetChannelFreq(freqHz) {
if (!vchanRigId || !vchanActiveId) return; if (!vchanRigId || !vchanActiveId) return;
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) { if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2; const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
const center = vchanWindow.lastSpectrumData.center_hz; const center = hostState.lastSpectrumData.center_hz;
if (Math.abs(freqHz - center) > halfSpan) { if (Math.abs(freqHz - center) > halfSpan) {
if (vchanWindow.showHint) { hostCore.showHint(
vchanWindow.showHint( `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, 3e3
3e3 );
);
}
return; return;
} }
} }
@@ -411,6 +405,19 @@ async function vchanInterceptBandwidth(bwHz) {
await vchanSetChannelBandwidth(bwHz); await vchanSetChannelBandwidth(bwHz);
return true; return true;
} }
function vchanInterceptFrequency(freqHz) {
if (!vchanIsOnVirtual()) return false;
const targetHz = Math.round(freqHz);
hostCore.armOptimisticFrequency(targetHz);
hostCore.applyLocalTunedFrequency(targetHz);
vchanSetChannelFreq(freqHz);
return true;
}
function vchanInterceptFreqDisplay() {
if (!vchanIsOnVirtual()) return false;
vchanUpdateFreqDisplay();
return true;
}
vchanWindow.trx ??= {}; vchanWindow.trx ??= {};
vchanWindow.trx.modules ??= {}; vchanWindow.trx.modules ??= {};
vchanWindow.trx.modules.vchan = { vchanWindow.trx.modules.vchan = {
@@ -427,27 +434,11 @@ vchanWindow.trx.modules.vchan = {
isOnVirtual: vchanIsOnVirtual, isOnVirtual: vchanIsOnVirtual,
interceptMode: vchanInterceptMode, interceptMode: vchanInterceptMode,
interceptBandwidth: vchanInterceptBandwidth, interceptBandwidth: vchanInterceptBandwidth,
interceptFrequency: vchanInterceptFrequency,
interceptFreqDisplay: vchanInterceptFreqDisplay,
takeSchedulerControl: vchanTakeSchedulerControl, takeSchedulerControl: vchanTakeSchedulerControl,
releaseToScheduler: vchanToggleSchedulerRelease releaseToScheduler: vchanToggleSchedulerRelease
}; };
(function() {
const original = vchanWindow.setRigFrequency;
vchanWindow.setRigFrequency = function(freqHz) {
if (vchanIsOnVirtual()) {
if (vchanWindow.applyLocalTunedFrequency) {
if (typeof vchanWindow._freqOptimisticSeq === "number") {
vchanWindow._freqOptimisticSeq += 1;
vchanWindow._freqOptimisticHz = Math.round(freqHz);
}
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
}
vchanSetChannelFreq(freqHz);
return;
}
void vchanTakeSchedulerControl();
original?.(freqHz);
};
})();
(function initSchedulerReleaseControl() { (function initSchedulerReleaseControl() {
const btn = document.getElementById("scheduler-release-btn"); const btn = document.getElementById("scheduler-release-btn");
if (btn) { if (btn) {
@@ -458,13 +449,3 @@ vchanWindow.trx.modules.vchan = {
vchanStartSchedulerReleasePolling(); vchanStartSchedulerReleasePolling();
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
})(); })();
(function() {
origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
vchanWindow.refreshFreqDisplay = function() {
if (vchanIsOnVirtual()) {
vchanUpdateFreqDisplay();
return;
}
origRefreshFreqDisplay?.();
};
})();
@@ -1,6 +1,10 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/vdes.ts // src/plugins/vdes.ts
var vdesWindow = window; var vdesWindow = window;
var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); var escapeVdesHtml = (input) => hostCore.escapeMapHtml(input);
var vdesStatus = document.getElementById("vdes-status"); var vdesStatus = document.getElementById("vdes-status");
var vdesMessagesEl = document.getElementById("vdes-messages"); var vdesMessagesEl = document.getElementById("vdes-messages");
var vdesFilterInput = document.getElementById("vdes-filter"); var vdesFilterInput = document.getElementById("vdes-filter");
@@ -231,7 +235,7 @@ document.getElementById("settings-clear-vdes-history")?.addEventListener("click"
void (async () => { void (async () => {
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await vdesWindow.postPath?.("/clear_vdes_decode"); await hostCore.postPath("/clear_vdes_decode");
resetVdesHistoryView(); resetVdesHistoryView();
} catch (e) { } catch (e) {
console.error("VDES history clear failed", e); console.error("VDES history clear failed", e);
@@ -1,3 +1,7 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/wefax.ts // src/plugins/wefax.ts
var wefaxWindow = window; var wefaxWindow = window;
var wefaxDom = { var wefaxDom = {
@@ -299,7 +303,7 @@ if (wefaxDom.toggleBtn) {
if (wefaxWindow.takeSchedulerControlForDecoderDisable) { if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton); await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
} }
await wefaxWindow.postPath?.("/toggle_wefax_decode"); await hostCore.postPath("/toggle_wefax_decode");
} catch (e) { } catch (e) {
console.error("WEFAX toggle failed", e); console.error("WEFAX toggle failed", e);
} }
@@ -310,7 +314,7 @@ if (wefaxDom.clearBtn) {
wefaxDom.clearBtn.addEventListener("click", () => { wefaxDom.clearBtn.addEventListener("click", () => {
void (async () => { void (async () => {
try { try {
await wefaxWindow.postPath?.("/clear_wefax_decode"); await hostCore.postPath("/clear_wefax_decode");
resetWefaxHistoryView(); resetWefaxHistoryView();
} catch (e) { } catch (e) {
console.error("WEFAX clear failed", e); console.error("WEFAX clear failed", e);
@@ -1,3 +1,7 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/wspr.ts // src/plugins/wspr.ts
var wsprWindow = window; var wsprWindow = window;
var wsprStatus = document.getElementById("wspr-status"); var wsprStatus = document.getElementById("wspr-status");
@@ -229,7 +233,7 @@ wsprDecodeToggleBtn?.addEventListener("click", () => {
void (async () => { void (async () => {
try { try {
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
await wsprWindow.postPath?.("/toggle_wspr_decode"); await hostCore.postPath("/toggle_wspr_decode");
} catch (error) { } catch (error) {
console.error("WSPR toggle failed", error); console.error("WSPR toggle failed", error);
} }
@@ -239,7 +243,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
void (async () => { void (async () => {
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await wsprWindow.postPath?.("/clear_wspr_decode"); await hostCore.postPath("/clear_wspr_decode");
resetWsprHistoryView(); resetWsprHistoryView();
} catch (error) { } catch (error) {
console.error("WSPR history clear failed", error); console.error("WSPR history clear failed", error);
@@ -42,6 +42,9 @@ source change.
- `core/` contains dependency-light shared helpers. - `core/` contains dependency-light shared helpers.
- `features/` contains application behavior grouped by responsibility. - `features/` contains application behavior grouped by responsibility.
- `plugins/` contains lazy decoder and high-coupling feature entries. - `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 - Files under `assets/web/vendor/` are vendored JavaScript and are not part of
the TypeScript migration. the TypeScript migration.
@@ -15,6 +15,7 @@ import {
applyDecoderRegistryVisibility, applyDecoderRegistryVisibility,
decoderRegistry, decoderRegistry,
loadDecoderRegistry, loadDecoderRegistry,
onDecoderRegistryReady,
} from "./core/decoder-registry.js"; } from "./core/decoder-registry.js";
import { import {
fetchAuthSession, fetchAuthSession,
@@ -201,6 +202,8 @@ interface TrxModules {
handleChannels(data: string): void; handleChannels(data: string): void;
handleSession(data: string): void; handleSession(data: string): void;
interceptBandwidth(bandwidthHz: number): Promise<boolean>; interceptBandwidth(bandwidthHz: number): Promise<boolean>;
interceptFrequency(frequencyHz: number): boolean;
interceptFreqDisplay(): boolean;
interceptMode(mode: string): Promise<boolean>; interceptMode(mode: string): Promise<boolean>;
isOnVirtual(): boolean; isOnVirtual(): boolean;
takeSchedulerControl(): Promise<void>; takeSchedulerControl(): Promise<void>;
@@ -217,6 +220,7 @@ interface TrxState {
readonly lastRigDisplayNames: Record<string, string>; readonly lastRigDisplayNames: Record<string, string>;
readonly initialMapZoom: number; readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number; readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean;
readonly authRole: AuthRole | null; readonly authRole: AuthRole | null;
readonly decoderRegistry: typeof decoderRegistry; readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null; readonly sseSessionId: string | null;
@@ -233,7 +237,10 @@ interface TrxState {
readonly aisVesselUrlBase: string | null; readonly aisVesselUrlBase: string | null;
readonly serverRigs: RigListItem[]; readonly serverRigs: RigListItem[];
readonly serverActiveRigId: string | null; readonly serverActiveRigId: string | null;
readonly lastModeName: string; lastModeName: string;
readonly jogUnit: number;
readonly rxActive: boolean;
audioChannelOverride: string | null;
readonly lastSpectrumData: SpectrumFrame | null; readonly lastSpectrumData: SpectrumFrame | null;
readonly lastSpectrumRenderData: SpectrumFrame | null; readonly lastSpectrumRenderData: SpectrumFrame | null;
currentBandwidthHz: number; currentBandwidthHz: number;
@@ -1984,6 +1991,9 @@ function refreshWavelengthDisplay(hz: number) {
} }
function refreshFreqDisplay() { 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; if (lastFreqHz == null || freqDirty) return;
freqEl.value = formatFreqForStep(lastFreqHz, jogUnit); freqEl.value = formatFreqForStep(lastFreqHz, jogUnit);
refreshWavelengthDisplay(lastFreqHz); refreshWavelengthDisplay(lastFreqHz);
@@ -2476,12 +2486,26 @@ async function ensureTunedBandwidthCoverage(freqHz: number, bandwidthHz = covera
let _freqOptimisticHz: number | null = null; let _freqOptimisticHz: number | null = null;
let _freqOptimisticSeq = 0; let _freqOptimisticSeq = 0;
// Lazy features paint their own optimistic frequency before starting the tune
// round-trip (a bookmark updates the marker before mode and bandwidth settle).
// Arming the guard from there keeps SSE from snapping back to the stale value.
function armOptimisticFrequency(freqHz: number) {
if (!isFiniteNumber(freqHz)) return;
_freqOptimisticSeq += 1;
_freqOptimisticHz = Math.round(freqHz);
}
function setRigFrequency(freqHz: number) { function setRigFrequency(freqHz: number) {
const targetHz = Math.round(freqHz); const targetHz = Math.round(freqHz);
if (!freqAllowed(targetHz)) { if (!freqAllowed(targetHz)) {
showUnsupportedFreqPopup(targetHz); showUnsupportedFreqPopup(targetHz);
throw new Error(`Unsupported frequency: ${targetHz}`); throw new Error(`Unsupported frequency: ${targetHz}`);
} }
// A virtual channel owns its own tuning path: the plugin applies the
// optimistic update and posts to the channel API instead of the rig.
if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return;
// Scheduler control is fire-and-forget — don't block the freq change.
void window.trx?.modules.vchan?.takeSchedulerControl();
// Optimistic local update — visual is instant via CSS overlay + guard. // Optimistic local update — visual is instant via CSS overlay + guard.
const prevFreqHz = lastFreqHz; const prevFreqHz = lastFreqHz;
const seq = ++_freqOptimisticSeq; const seq = ++_freqOptimisticSeq;
@@ -4822,6 +4846,7 @@ Object.defineProperties(trxState, {
lastRigDisplayNames: { get() { return lastRigDisplayNames; } }, lastRigDisplayNames: { get() { return lastRigDisplayNames; } },
initialMapZoom: { get() { return initialMapZoom; } }, initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } }, authRole: { get() { return authRole; } },
decoderRegistry: { get() { return decoderRegistry; } }, decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } }, sseSessionId: { get() { return sseSessionId; } },
@@ -4838,7 +4863,10 @@ Object.defineProperties(trxState, {
aisVesselUrlBase: { get() { return aisVesselUrlBase; } }, aisVesselUrlBase: { get() { return aisVesselUrlBase; } },
serverRigs: { get() { return serverRigs; } }, serverRigs: { get() { return serverRigs; } },
serverActiveRigId: { get() { return serverActiveRigId; } }, 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; } }, lastSpectrumData: { get() { return lastSpectrumData; } },
lastSpectrumRenderData: { get() { return lastSpectrumRenderData; } }, lastSpectrumRenderData: { get() { return lastSpectrumRenderData; } },
currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v: number) { currentBandwidthHz = v; window.currentBandwidthHz = v; } }, currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v: number) { currentBandwidthHz = v; window.currentBandwidthHz = v; } },
@@ -4855,6 +4883,11 @@ const trxCore = Object.freeze({
saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans, saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal, formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor, postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady,
formatFreqForStep, refreshFreqDisplay, setJogDivisor, mwDefaultsForMode,
resetRdsDisplay, positionRdsPsOverlay, updateWfmControls,
updateSdrSquelchControlVisibility, startRxAudio, stopRxAudio,
latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm, latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm,
formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText, formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText,
nearestBookmarkForHz, currentDecodeHistoryRetentionMs, nearestBookmarkForHz, currentDecodeHistoryRetentionMs,
@@ -5475,8 +5508,9 @@ function extractAudioFrameChannels(frame: AudioData) {
return out; return out;
} }
// Optional channel_id injected by vchan.js when connecting to a virtual channel. // Optional channel_id set by the vchan feature when a virtual channel is
const _audioChannelOverride: string | null = null; // 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. */ /** Schedule decoded PCM channels for playback via Web Audio API. */
function scheduleDecodedAudio(channelData: Float32Array[], frameCount: number, sampleRate: number) { function scheduleDecodedAudio(channelData: Float32Array[], frameCount: number, sampleRate: number) {
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
export {}; export {};
import type { PluginRuntimeWindow } from "./runtime-contract"; import type { PluginRuntimeWindow } from "./runtime-contract";
@@ -27,22 +29,15 @@ interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
interface AisBridge { interface AisBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
escapeMapHtml?: (input: string) => string;
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null; 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; aisMapAddVessel?: (message: AisMessage) => void;
clearMapMarkersByType?: (type: string) => void; clearMapMarkersByType?: (type: string) => void;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
updateAisBar?: () => void; updateAisBar?: () => void;
clearAisBar?: () => void; clearAisBar?: () => void;
} }
const aisWindow = window as unknown as AisBridge; const aisWindow = window as unknown as AisBridge;
const escapeAisHtml = (input: string): string => aisWindow.escapeMapHtml?.(input) ?? input const escapeAisHtml = (input: string): string => hostCore.escapeMapHtml(input);
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
// --- AIS Decoder Plugin (server-side decode) --- // --- AIS Decoder Plugin (server-side decode) ---
const aisStatus = document.getElementById("ais-status"); const aisStatus = document.getElementById("ais-status");
@@ -176,10 +171,10 @@ function aisRouteText(msg: AisMessage): string {
} }
function aisDistanceText(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 ""; 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 (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km 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 () => { 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; if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await aisWindow.postPath?.("/clear_ais_decode"); await hostCore.postPath("/clear_ais_decode");
resetAisHistoryView(); resetAisHistoryView();
} catch (e) { } catch (e) {
console.error("AIS history clear failed", e); console.error("AIS history clear failed", e);
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import { import {
aprsAgeText, aprsAgeText,
aprsCategoryLabel, aprsCategoryLabel,
@@ -18,26 +20,18 @@ import type { PluginRuntimeWindow } from "./runtime-contract";
interface AprsBridge { interface AprsBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; 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; navigateToAprsMap?: (lat: number, lon: number) => void;
showHint?: (message: string, durationMs: number) => void;
clearMapMarkersByType?: (type: string) => 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; aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
getDecodeRigMeta?: () => unknown; getDecodeRigMeta?: () => unknown;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
updateAprsBar?: () => void; updateAprsBar?: () => void;
clearAprsBar?: () => void; clearAprsBar?: () => void;
closeAprsBar?: () => void; closeAprsBar?: () => void;
} }
const aprsWindow = window as unknown as AprsBridge; const aprsWindow = window as unknown as AprsBridge;
const escapeAprsHtml = (input: string): string => aprsWindow.escapeMapHtml?.(input) ?? input const escapeAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
.replaceAll("&", "&amp;").replaceAll("<", "&lt;") const showAprsHint = (message: string, durationMs: number): void => { hostCore.showHint(message, durationMs); };
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const showAprsHint = (message: string, durationMs: number): void => { aprsWindow.showHint?.(message, durationMs); };
// --- APRS Decoder Plugin (server-side decode) --- // --- APRS Decoder Plugin (server-side decode) ---
const aprsStatus = document.getElementById("aprs-status"); const aprsStatus = document.getElementById("aprs-status");
@@ -87,8 +81,8 @@ function scheduleAprsBarUpdate() {
} }
function aprsDistanceText(pkt: AprsPacket): string { function aprsDistanceText(pkt: AprsPacket): string {
if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return ""; if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon); const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return ""; if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km 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 () => { 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; if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await aprsWindow.postPath?.("/clear_aprs_decode"); await hostCore.postPath("/clear_aprs_decode");
resetAprsHistoryView(); resetAprsHistoryView();
} catch (e) { } catch (e) {
console.error("APRS history clear failed", e); console.error("APRS history clear failed", e);
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
export {}; export {};
interface DecoderDescriptor { interface DecoderDescriptor {
@@ -38,7 +40,6 @@ interface BackgroundDecodeStatus {
} }
interface BackgroundBridge { interface BackgroundBridge {
decoderRegistry?: DecoderDescriptor[]; decoderRegistry?: DecoderDescriptor[];
authEnabled?: boolean;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } }; trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
} }
@@ -170,7 +171,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
setCheckbox("background-decode-enabled", currentConfig.enabled); setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist(); renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || bgdWindow.authEnabled === false; const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const panel = document.getElementById("background-decode-panel"); const panel = document.getElementById("background-decode-panel");
if (panel) { if (panel) {
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) { panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
export {}; export {};
/* DOM IDs in the server-owned page are required by this feature; bmEl throws /* DOM IDs in the server-owned page are required by this feature; bmEl throws
@@ -19,14 +21,6 @@ interface Bookmark {
scope?: string; scope?: string;
} }
interface DecoderDescriptor {
id: string;
label: string;
activation?: string;
active_modes?: string[];
bookmark_selectable?: boolean;
}
interface BookmarkService { interface BookmarkService {
readonly overlayList: readonly Bookmark[]; readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number; readonly overlayRevision: number;
@@ -44,31 +38,12 @@ interface VirtualChannelService {
} }
interface BookmarkBridge { interface BookmarkBridge {
authEnabled?: boolean; trx: { modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
authRole?: string | null;
lastActiveRigId?: string | null;
lastRigIds?: string[];
lastRigDisplayNames?: Record<string, string>;
lastFreqHz?: number;
lastModeName?: string;
lastSpectrumData?: unknown;
currentBandwidthHz?: number;
modeEl?: HTMLSelectElement | null;
decoderRegistry?: DecoderDescriptor[];
trx?: { modules?: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
trxUi: { trxUi: {
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>; confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
notify?(message: string, options: { kind: "error" }): void; notify?(message: string, options: { kind: "error" }): void;
}; };
syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void; syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
scheduleSpectrumDraw?(): void;
syncBandwidthInput?(bandwidthHz: number): void;
applyLocalTunedFrequency?(frequencyHz: number, force?: boolean): void;
setRigFrequency?(frequencyHz: number): Promise<unknown>;
postPath(path: string): Promise<unknown>;
onDecoderRegistryReady?(callback: () => void): void;
_freqOptimisticSeq?: number;
_freqOptimisticHz?: number;
} }
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement; type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
@@ -79,6 +54,12 @@ function bmEl(id: string): BookmarkElement {
return element as BookmarkElement; return element as BookmarkElement;
} }
/* Decoder checkboxes and decoder toggle buttons are built from the runtime
* registry, so their elements are legitimately absent for unbuilt decoders. */
function bmOptionalEl(id: string): BookmarkElement | null {
return document.getElementById(id) as BookmarkElement | null;
}
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
@@ -119,10 +100,7 @@ function bmEsc(str: unknown): string {
} }
function bmCanControl() { function bmCanControl() {
return ( return !hostState.authEnabled || hostState.authRole === "control";
(typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) ||
(typeof bridge.authRole !== "undefined" && bridge.authRole === "control")
);
} }
// Show/hide the Add Bookmark / Select All buttons based on the current auth role. // Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -136,8 +114,7 @@ function bmSyncAccess() {
/** The listing scope: always the active rig (to merge general + rig bookmarks). */ /** The listing scope: always the active rig (to merge general + rig bookmarks). */
function bmListScope() { function bmListScope() {
const rig = (typeof bridge.lastActiveRigId !== "undefined") ? bridge.lastActiveRigId : null; return hostState.lastActiveRigId || "general";
return rig || "general";
} }
async function bmFetchOverlay() { async function bmFetchOverlay() {
@@ -154,7 +131,7 @@ async function bmFetchOverlay() {
if (typeof bridge.syncBookmarkMapLocators === "function") { if (typeof bridge.syncBookmarkMapLocators === "function") {
bridge.syncBookmarkMapLocators(bmOverlayList); bridge.syncBookmarkMapLocators(bmOverlayList);
} }
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); hostCore.scheduleSpectrumDraw();
} }
async function bmFetch(categoryFilter: string): Promise<void> { async function bmFetch(categoryFilter: string): Promise<void> {
@@ -310,19 +287,19 @@ function bmChangePage(delta: number): void {
// Read decoder checkboxes and return an array of selected decoder names. // Read decoder checkboxes and return an array of selected decoder names.
function bmReadDecoders(): string[] { function bmReadDecoders(): string[] {
return (bridge.decoderRegistry || []) return hostState.decoderRegistry
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.filter(d => bmEl("bm-dec-" + d.id)?.checked) .filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
.map(d => d.id); .map(d => d.id);
} }
// Set decoder checkboxes to match the given array. // Set decoder checkboxes to match the given array.
function bmWriteDecoders(decoders: readonly string[]): void { function bmWriteDecoders(decoders: readonly string[]): void {
const set = new Set(decoders || []); const set = new Set(decoders || []);
(bridge.decoderRegistry || []) hostState.decoderRegistry
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.forEach(d => { .forEach(d => {
const el = bmEl("bm-dec-" + d.id); const el = bmOptionalEl("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id); if (el) el.checked = set.has(d.id);
}); });
} }
@@ -332,7 +309,7 @@ function bmBuildDecoderCheckboxes() {
const container = bmEl("bm-decoder-checkboxes"); const container = bmEl("bm-decoder-checkboxes");
if (!container) return; if (!container) return;
container.innerHTML = ""; container.innerHTML = "";
(bridge.decoderRegistry || []) hostState.decoderRegistry
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.forEach(d => { .forEach(d => {
const label = document.createElement("label"); const label = document.createElement("label");
@@ -372,23 +349,21 @@ function bmCloseForm() {
} }
function bmPrefillFromStatus() { function bmPrefillFromStatus() {
// Use globals maintained by app.js (updated by SSE stream) // Read live rig state from the host contract (updated by the SSE stream).
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) { const freqHz = hostState.lastFreqHz;
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz)); if (freqHz != null && Number.isFinite(freqHz)) {
bmEl("bm-freq").value = String(Math.round(freqHz));
} }
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) { if (hostState.lastModeName) {
bmEl("bm-mode").value = bridge.lastModeName; bmEl("bm-mode").value = hostState.lastModeName;
} }
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { if (hostState.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
} }
// Prefill decoder checkboxes from current toggle button state. // Prefill decoder checkboxes from current toggle button state.
const activeDecoders = (bridge.decoderRegistry || []) const activeDecoders = hostState.decoderRegistry
.filter(d => d.bookmark_selectable && d.activation === "toggle") .filter(d => d.bookmark_selectable && d.activation === "toggle")
.filter(d => { .filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
const btn = bmEl(d.id + "-decode-toggle-btn");
return btn && btn.dataset.enabled === "true";
})
.map(d => d.id); .map(d => d.id);
bmWriteDecoders(activeDecoders); bmWriteDecoders(activeDecoders);
} }
@@ -478,58 +453,44 @@ async function bmDelete(id: string): Promise<void> {
function bmApply(bm: Bookmark): void { function bmApply(bm: Bookmark): void {
try { try {
// --- Optimistic UI updates (instant, before any network round-trips) --- // --- Optimistic UI updates (instant, before any network round-trips) ---
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) { const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
bridge.modeEl.value = (bm.mode || "").toUpperCase(); if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
if (typeof bridge.currentBandwidthHz !== "undefined") { hostState.currentBandwidthHz = bm.bandwidth_hz;
bridge.currentBandwidthHz = bm.bandwidth_hz; hostCore.syncBandwidthInput(bm.bandwidth_hz);
}
bridge.currentBandwidthHz = bm.bandwidth_hz;
if (typeof bridge.syncBandwidthInput === "function") {
bridge.syncBandwidthInput(bm.bandwidth_hz);
}
} }
if (typeof bridge.applyLocalTunedFrequency === "function") { // Set optimistic guard before applying so SSE cannot snap back.
// Set optimistic guard before applying so SSE cannot snap back. hostCore.armOptimisticFrequency(bm.freq_hz);
if (typeof bridge._freqOptimisticSeq !== "undefined") { // Force display so the BW overlay is repositioned even when freq is unchanged.
++bridge._freqOptimisticSeq; hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
bridge._freqOptimisticHz = bm.freq_hz; if (hostState.lastSpectrumData) {
} hostCore.scheduleSpectrumDraw();
// Force display so the BW overlay is repositioned even when freq is unchanged.
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
}
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
bridge.scheduleSpectrumDraw();
} }
// Take scheduler control up front, then apply mode before bandwidth so a // Take scheduler control up front, then apply mode before bandwidth so a
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz. // late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
const tunePromise = (async () => { const tunePromise = (async () => {
await bridge.trx?.modules?.vchan?.takeSchedulerControl(); await bridge.trx.modules.vchan?.takeSchedulerControl();
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false; const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) { if (!onVirtual) {
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
const bwHandledByVchan = const bwHandledByVchan =
await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
if (!bwHandledByVchan) { if (!bwHandledByVchan) {
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
} }
} }
// bridge.setRigFrequency is wrapped by vchan.js to redirect to the channel API // setRigFrequency redirects to the channel API when a virtual channel is
// when on a virtual channel, so this call works correctly in both cases. // active. It repeats the optimistic update applied above, which is a
// It also does its own optimistic update (bridge.applyLocalTunedFrequency) but // no-op because the value is unchanged.
// that's a no-op since we already set the same value above. hostCore.setRigFrequency(bm.freq_hz);
if (typeof bridge.setRigFrequency === "function") {
await bridge.setRigFrequency(bm.freq_hz);
} else {
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
}
})(); })();
// Decoder toggles — fire-and-forget. // Decoder toggles — fire-and-forget.
// - Decoders incompatible with the new mode are always turned off // - Decoders incompatible with the new mode are always turned off
@@ -539,13 +500,14 @@ function bmApply(bm: Bookmark): void {
// alone. // alone.
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase(); const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = (bridge.decoderRegistry || []).filter(d => const allToggleDecoders = hostState.decoderRegistry.filter(d =>
d.activation === "toggle" d.activation === "toggle"
); );
const decoderPromise = allToggleDecoders.length ? (async () => { const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status"; let statusUrl = "/status";
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) { const rigId = hostState.lastActiveRigId;
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId); if (rigId) {
statusUrl += "?remote=" + encodeURIComponent(rigId);
} }
const statusResp = await fetch(statusUrl); const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return; if (!statusResp.ok) return;
@@ -567,7 +529,7 @@ function bmApply(bm: Bookmark): void {
wanted = currentlyOn; wanted = currentlyOn;
} }
if (wanted !== currentlyOn) { if (wanted !== currentlyOn) {
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
} }
} }
if (toggles.length) await Promise.all(toggles); if (toggles.length) await Promise.all(toggles);
@@ -582,8 +544,6 @@ function bmApply(bm: Bookmark): void {
} }
} }
bridge.trx ??= {};
bridge.trx.modules ??= {};
bridge.trx.modules.bookmarks = { bridge.trx.modules.bookmarks = {
get overlayList() { return bmOverlayList; }, get overlayList() { return bmOverlayList; },
get overlayRevision() { return bmOverlayRevision; }, get overlayRevision() { return bmOverlayRevision; },
@@ -619,8 +579,8 @@ function bmUpdateSelectionUi() {
function bmPopulateMoveTarget() { function bmPopulateMoveTarget() {
const sel = bmEl("bm-move-target"); const sel = bmEl("bm-move-target");
if (!sel) return; if (!sel) return;
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : []; const rigIds = hostState.lastRigIds;
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {}; const displayNames = hostState.lastRigDisplayNames;
const prev = sel.value; const prev = sel.value;
sel.innerHTML = ""; sel.innerHTML = "";
if (bmScope !== "general") { if (bmScope !== "general") {
@@ -728,8 +688,8 @@ async function bmDeleteSelected() {
function bmPopulateScopePicker() { function bmPopulateScopePicker() {
const picker = bmEl("bm-scope-picker"); const picker = bmEl("bm-scope-picker");
if (!picker) return; if (!picker) return;
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : []; const rigIds = hostState.lastRigIds;
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {}; const displayNames = hostState.lastRigDisplayNames;
// Preserve current selection if still valid. // Preserve current selection if still valid.
const prev = picker.value; const prev = picker.value;
while (picker.options.length > 1) picker.remove(1); while (picker.options.length > 1) picker.remove(1);
@@ -756,9 +716,7 @@ function bmPopulateScopePicker() {
// Build decoder checkboxes from registry. The registry is fetched async // Build decoder checkboxes from registry. The registry is fetched async
// so we rebuild once it arrives to ensure checkboxes are present. // so we rebuild once it arrives to ensure checkboxes are present.
bmBuildDecoderCheckboxes(); bmBuildDecoderCheckboxes();
if (typeof bridge.onDecoderRegistryReady === "function") { hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
}
// Scope picker // Scope picker
bmPopulateScopePicker(); bmPopulateScopePicker();
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
@@ -30,8 +32,6 @@ interface CwBridge {
lastFreqHz?: number; lastFreqHz?: number;
currentBandwidthHz?: number; currentBandwidthHz?: number;
lastSpectrumData?: CwSpectrum; lastSpectrumData?: CwSpectrum;
escapeMapHtml?: (input: string) => string;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
applyCwAutoUi?: (enabled: boolean) => void; applyCwAutoUi?: (enabled: boolean) => void;
applyCwAutoUiFromServer?: (enabled: boolean) => void; applyCwAutoUiFromServer?: (enabled: boolean) => void;
@@ -75,9 +75,7 @@ let cwBarDismissedAtMs = 0;
let cwAutoLocalOverride: boolean | null = null; let cwAutoLocalOverride: boolean | null = null;
function escapeCwHtml(input: string): string { function escapeCwHtml(input: string): string {
return cwWindow.escapeMapHtml?.(input) ?? input return hostCore.escapeMapHtml(input);
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
} }
function applyCwAutoUi(enabled: boolean): void { function applyCwAutoUi(enabled: boolean): void {
@@ -344,7 +342,7 @@ async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: bool
cwToneInput.value = String(clamped); cwToneInput.value = String(clamped);
} }
try { try {
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) { } catch (e) {
console.error("CW tone set failed", e); console.error("CW tone set failed", e);
} }
@@ -358,7 +356,7 @@ if (cwAutoInput) {
cwAutoLocalOverride = enabled; cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled); applyCwAutoUi(enabled);
try { try {
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker(); drawCwTonePicker();
} catch (error: unknown) { } catch (error: unknown) {
console.error("CW auto toggle failed", error); console.error("CW auto toggle failed", error);
@@ -375,7 +373,7 @@ if (cwWpmInput) {
if (cwAutoInput?.checked) return; if (cwAutoInput?.checked) return;
const wpm = clampCwWpm(cwWpmInput.value); const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = String(wpm); 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); } catch (error: unknown) { console.error("CW WPM set failed", error); }
})(); })();
}); });
@@ -413,7 +411,7 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
void (async () => { void (async () => {
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await cwWindow.postPath?.("/clear_cw_decode"); await hostCore.postPath("/clear_cw_decode");
resetCwHistoryView(); resetCwHistoryView();
} catch (error: unknown) { } catch (error: unknown) {
console.error("CW history clear failed", error); console.error("CW history clear failed", error);
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8"; export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -45,8 +47,6 @@ interface FtxBridge {
updateFt8Bar?: () => void; updateFt8Bar?: () => void;
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void; registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
postPath?: (path: string) => Promise<unknown>;
fmtTime?: (timestampMs: number) => string;
trxUi: ConfirmApi; trxUi: ConfirmApi;
clearFt8Bar?: () => void; clearFt8Bar?: () => void;
closeFt8Bar?: () => void; closeFt8Bar?: () => void;
@@ -62,6 +62,14 @@ interface FtxConfig {
const bridge = window as unknown as FtxBridge & PluginRuntimeWindow; 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 { function finiteNumber(value: unknown): number | null {
const number = typeof value === "number" ? value : Number(value); const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : null; return Number.isFinite(number) ? number : null;
@@ -274,7 +282,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
let html = ""; let html = "";
for (const message of recent) { for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`; const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
const snr = finiteNumber(message.snr_db); const snr = finiteNumber(message.snr_db);
const delta = finiteNumber(message.dt_s); const delta = finiteNumber(message.dt_s);
const frequency = displayFrequency(message.freq_hz); const frequency = displayFrequency(message.freq_hz);
@@ -309,12 +317,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
toggle?.addEventListener("click", () => { void (async () => { toggle?.addEventListener("click", () => { void (async () => {
try { try {
await bridge.takeSchedulerControlForDecoderDisable?.(toggle); 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); } } catch (error: unknown) { console.error(`${label} toggle failed`, error); }
})(); }); })(); });
document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => { void (async () => { 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; 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); } catch (error: unknown) { console.error(`${label} history clear failed`, error); }
})(); }); })(); });
} }
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import { import {
aprsAgeText, aprsAgeText,
aprsCategoryLabel, aprsCategoryLabel,
@@ -19,21 +21,13 @@ import type { PluginRuntimeWindow } from "./runtime-contract";
interface HfAprsBridge { interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; 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; navigateToAprsMap?: (lat: number, lon: number) => void;
showHint?: (message: string, durationMs: number) => void;
getDecodeRigMeta?: () => unknown; getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
} }
const hfAprsWindow = window as unknown as HfAprsBridge; const hfAprsWindow = window as unknown as HfAprsBridge;
const escapeHfAprsHtml = (input: string): string => hfAprsWindow.escapeMapHtml?.(input) ?? input const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) --- // --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status"); const hfAprsStatus = document.getElementById("hf-aprs-status");
@@ -72,8 +66,8 @@ function scheduleHfAprsHistoryRender() {
} }
function hfAprsDistanceText(pkt: AprsPacket): string { function hfAprsDistanceText(pkt: AprsPacket): string {
if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return ""; if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon); const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return ""; if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km 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; const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (clipboard) { if (clipboard) {
await clipboard.writeText(raw); await clipboard.writeText(raw);
hfAprsWindow.showHint?.("Coordinates copied", 1200); hostCore.showHint("Coordinates copied", 1200);
} }
} catch { } 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 () => { hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
try { try {
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn); await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode"); await hostCore.postPath("/toggle_hf_aprs_decode");
} catch (e) { } catch (e) {
console.error("HF APRS toggle failed", 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 () => { 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; 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 { try {
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode"); await hostCore.postPath("/clear_hf_aprs_decode");
resetHfAprsHistoryView(); resetHfAprsHistoryView();
} catch (e) { } catch (e) {
console.error("HF APRS history clear failed", e); console.error("HF APRS history clear failed", e);
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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<string, string>;
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<unknown>;
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;
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // 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 { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
@@ -13,7 +15,6 @@ interface SatelliteBridge {
addSatMapOverlay?: (image: SatelliteImage) => void; addSatMapOverlay?: (image: SatelliteImage) => void;
clearSatMapOverlays?: () => void; clearSatMapOverlays?: () => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
satShowOnMap?: (south: number, west: number, north: number, east: number) => void; satShowOnMap?: (south: number, west: number, north: number, east: number) => void;
enableMapSourceFilter?: (source: string) => void; enableMapSourceFilter?: (source: string) => void;
@@ -305,7 +306,7 @@ const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
lrptDecodeToggleBtn?.addEventListener("click", () => { void (async () => { lrptDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
try { try {
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn); await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await satWindow.postPath?.("/toggle_lrpt_decode"); await hostCore.postPath("/toggle_lrpt_decode");
} catch (e) { } catch (e) {
console.error("LRPT toggle failed", e); console.error("LRPT toggle failed", e);
} }
@@ -327,7 +328,7 @@ document
?.addEventListener("click", () => { void (async () => { ?.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; if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await satWindow.postPath?.("/clear_lrpt_decode"); await hostCore.postPath("/clear_lrpt_decode");
resetSatHistoryView(); resetSatHistoryView();
} catch (e) { } catch (e) {
console.error("Weather satellite history clear failed", e); console.error("Weather satellite history clear failed", e);
@@ -80,9 +80,5 @@ export interface SchedulerWindow {
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>; confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
notify?(message: string, options: { kind: "error" }): void; notify?(message: string, options: { kind: "error" }): void;
}; };
authRole?: string | null;
lastActiveRigId?: string | null;
serverLat?: number | null;
serverLon?: number | null;
satScheduler?: SatelliteSchedulerApi; satScheduler?: SatelliteSchedulerApi;
} }
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import type { import type {
ScheduleEntry, ScheduleEntry,
SchedulerBookmark, SchedulerBookmark,
@@ -27,6 +29,12 @@ function schedulerEl(id: string): SchedulerElement {
return element as 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 // Background Decoding Scheduler UI
(function () { (function () {
@@ -481,8 +489,8 @@ function schedulerEl(id: string): SchedulerElement {
if (mode === "grayline" && currentConfig && currentConfig.grayline) { if (mode === "grayline" && currentConfig && currentConfig.grayline) {
const gl = currentConfig.grayline; const gl = currentConfig.grayline;
// Prefer saved value; fall back to server coordinates from app.js globals. // Prefer saved value; fall back to server coordinates from app.js globals.
const lat = gl.lat ?? schedulerWindow.serverLat ?? ""; const lat = gl.lat ?? hostState.serverLat ?? "";
const lon = gl.lon ?? schedulerWindow.serverLon ?? ""; const lon = gl.lon ?? hostState.serverLon ?? "";
setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : "");
const gridEl = schedulerEl("scheduler-gl-grid"); const gridEl = schedulerEl("scheduler-gl-grid");
@@ -496,8 +504,8 @@ function schedulerEl(id: string): SchedulerElement {
renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id);
} else if (mode === "grayline") { } else if (mode === "grayline") {
// No saved grayline config yet — pre-fill coords from server if available. // No saved grayline config yet — pre-fill coords from server if available.
const lat = schedulerWindow.serverLat ?? ""; const lat = hostState.serverLat ?? "";
const lon = schedulerWindow.serverLon ?? ""; const lon = hostState.serverLon ?? "";
setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : "");
const gridEl2 = schedulerEl("scheduler-gl-grid"); const gridEl2 = schedulerEl("scheduler-gl-grid");
@@ -821,7 +829,7 @@ function schedulerEl(id: string): SchedulerElement {
} }
function renderTimelineNeedle() { function renderTimelineNeedle() {
const g = schedulerEl("sch-timeline-needle-g"); const g = schedulerOptionalEl("sch-timeline-needle-g");
if (g) g.innerHTML = timelineNeedleSvg(); 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 eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has // when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here. // already passed that point, so we must self-initialize here.
if (schedulerWindow.authRole != null) { if (hostState.authRole != null) {
initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole); initScheduler(hostState.lastActiveRigId, hostState.authRole);
wireSchedulerEvents(); wireSchedulerEvents();
} }
})(); })();
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
export {}; export {};
interface VirtualChannel { interface VirtualChannel {
@@ -21,35 +23,12 @@ interface SchedulerReleaseState {
all_released?: boolean; all_released?: boolean;
current_session_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 { interface VirtualChannelBridge {
lastActiveRigId?: string | null;
lastFreqHz?: number;
lastModeName?: string;
lastSpectrumData?: SpectrumSnapshot | null;
currentBandwidthHz?: number;
jogUnit?: number;
rxActive?: boolean;
_audioChannelOverride?: string | null;
_freqOptimisticSeq?: number;
_freqOptimisticHz?: number;
renderRdsOverlays?: () => void; 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; refreshRdsUi?: () => void;
positionRdsPsOverlay?: () => void;
mwDefaultsForMode?: (mode: string) => [number, ...unknown[]];
showHint?: (message: string, durationMs: number) => void;
applyLocalTunedFrequency?: (frequencyHz: number) => void;
setRigFrequency?: (frequencyHz: number) => void;
refreshFreqDisplay?: () => void;
trx?: { modules?: { vchan?: VirtualChannelService } }; trx?: { modules?: { vchan?: VirtualChannelService } };
} }
@@ -63,6 +42,8 @@ interface VirtualChannelService {
isOnVirtual(): boolean; isOnVirtual(): boolean;
interceptMode(mode: string): Promise<boolean>; interceptMode(mode: string): Promise<boolean>;
interceptBandwidth(bandwidthHz: number): Promise<boolean>; interceptBandwidth(bandwidthHz: number): Promise<boolean>;
interceptFrequency(frequencyHz: number): boolean;
interceptFreqDisplay(): boolean;
takeSchedulerControl(): Promise<void>; takeSchedulerControl(): Promise<void>;
releaseToScheduler(): Promise<void>; releaseToScheduler(): Promise<void>;
} }
@@ -146,7 +127,7 @@ function vchanStartSchedulerReleasePolling() {
async function vchanToggleSchedulerRelease() { async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return; if (!vchanSessionId) return;
const rigId = vchanRigId || vchanWindow.lastActiveRigId || null; const rigId = vchanRigId || hostState.lastActiveRigId || null;
try { try {
const resp = await fetch("/scheduler-control", { const resp = await fetch("/scheduler-control", {
method: "PUT", method: "PUT",
@@ -261,9 +242,7 @@ function vchanRender() {
picker.appendChild(addBtn); picker.appendChild(addBtn);
vchanSyncAccentUI(); vchanSyncAccentUI();
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { hostCore.updateDocumentTitle(hostCore.activeChannelRds());
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
}
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
} }
@@ -271,8 +250,8 @@ async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return; if (!vchanSessionId || !vchanRigId) return;
// Use the last known rig frequency and mode as the starting point. // Use the last known rig frequency and mode as the starting point.
const freqHz = (typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0) const freqHz = (typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0)
? vchanWindow.lastFreqHz ? hostState.lastFreqHz
: 0; : 0;
const modeEl = document.getElementById("mode") as HTMLSelectElement | null; const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
const mode = modeEl ? (modeEl.value || "USB") : "USB"; const mode = modeEl ? (modeEl.value || "USB") : "USB";
@@ -374,14 +353,14 @@ function vchanReconnectAudio(): void {
// Always update the override so startRxAudio picks up the right URL, // Always update the override so startRxAudio picks up the right URL,
// even when audio isn't currently running. // even when audio isn't currently running.
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
vchanWindow._audioChannelOverride = ch?.id ?? null; hostState.audioChannelOverride = ch?.id ?? null;
if (!vchanWindow.rxActive) return; if (!hostState.rxActive) return;
vchanWindow.stopRxAudio?.(); hostCore.stopRxAudio();
// Delay so the server has time to set up the per-channel encoder. // 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 // 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. // just needs to be long enough for the WS upgrade to reach the server.
setTimeout(() => { setTimeout(() => {
vchanWindow.startRxAudio?.(); hostCore.startRxAudio();
}, 300); }, 300);
} }
@@ -414,11 +393,7 @@ function vchanUpdateFreqDisplay() {
if (!ch) return; if (!ch) return;
const el = document.getElementById("freq") as HTMLInputElement | null; const el = document.getElementById("freq") as HTMLInputElement | null;
if (!el) return; if (!el) return;
if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") { el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
} else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
}
} }
// Sync the mode picker to the active virtual channel's mode. // Sync the mode picker to the active virtual channel's mode.
@@ -432,21 +407,21 @@ function vchanSyncModeDisplay() {
} }
// When on primary channel, app.js rig-state updates handle the picker. // When on primary channel, app.js rig-state updates handle the picker.
const modeUpper = (modeEl.value || "").toUpperCase(); const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof vchanWindow.lastModeName === "string") { if (typeof hostState.lastModeName === "string") {
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") { if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
vchanWindow.setJogDivisor?.(10); hostCore.setJogDivisor(10);
vchanWindow.resetRdsDisplay?.(); hostCore.resetRdsDisplay();
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") { } else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
vchanWindow.resetRdsDisplay?.(); hostCore.resetRdsDisplay();
} }
vchanWindow.lastModeName = modeUpper; hostState.lastModeName = modeUpper;
} }
vchanWindow.updateWfmControls?.(); hostCore.updateWfmControls();
vchanWindow.updateSdrSquelchControlVisibility?.(); hostCore.updateSdrSquelchControlVisibility();
if (vchanWindow.refreshRdsUi) { if (vchanWindow.refreshRdsUi) {
vchanWindow.refreshRdsUi(); vchanWindow.refreshRdsUi();
} else { } else {
vchanWindow.positionRdsPsOverlay?.(); hostCore.positionRdsPsOverlay();
} }
} }
@@ -459,12 +434,12 @@ function vchanSyncBwDisplay() {
if (!bwEl) return; if (!bwEl) return;
// bandwidth_hz == 0 means mode-default; derive it from the channel mode. // bandwidth_hz == 0 means mode-default; derive it from the channel mode.
let bwHz = ch.bandwidth_hz || 0; let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && vchanWindow.mwDefaultsForMode) { if (bwHz === 0) {
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0; bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
} }
if (bwHz > 0) { if (bwHz > 0) {
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, ""); bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
vchanWindow.currentBandwidthHz = bwHz; hostState.currentBandwidthHz = bwHz;
} }
} }
@@ -480,29 +455,22 @@ function vchanSyncAccentUI() {
vchanSyncModeDisplay(); vchanSyncModeDisplay();
vchanSyncBwDisplay(); vchanSyncBwDisplay();
} else { } else {
origRefreshFreqDisplay?.(); hostCore.refreshFreqDisplay();
}
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
} }
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
} }
// Saved reference to the original refreshFreqDisplay from app.js.
let origRefreshFreqDisplay: (() => void) | null = null;
function vchanSetChannelFreq(freqHz: number): void { function vchanSetChannelFreq(freqHz: number): void {
if (!vchanRigId || !vchanActiveId) return; if (!vchanRigId || !vchanActiveId) return;
// Validate against current SDR capture window. // Validate against current SDR capture window.
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) { if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2; const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
const center = vchanWindow.lastSpectrumData.center_hz; const center = hostState.lastSpectrumData.center_hz;
if (Math.abs(freqHz - center) > halfSpan) { if (Math.abs(freqHz - center) > halfSpan) {
if (vchanWindow.showHint) { hostCore.showHint(
vchanWindow.showHint( `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, 3000
3000 );
);
}
return; return;
} }
} }
@@ -571,6 +539,26 @@ async function vchanInterceptBandwidth(bwHz: number): Promise<boolean> {
return true; return true;
} }
// Called by setRigFrequency before it posts /set_freq. When a non-primary
// channel is active the change belongs to the channel API instead of the rig,
// so this applies the optimistic local update and reports the tune as handled.
function vchanInterceptFrequency(freqHz: number): boolean {
if (!vchanIsOnVirtual()) return false;
const targetHz = Math.round(freqHz);
hostCore.armOptimisticFrequency(targetHz);
hostCore.applyLocalTunedFrequency(targetHz);
vchanSetChannelFreq(freqHz);
return true;
}
// Called by the application's refreshFreqDisplay. While a virtual channel is
// active the field belongs to that channel, so report the render as handled.
function vchanInterceptFreqDisplay(): boolean {
if (!vchanIsOnVirtual()) return false;
vchanUpdateFreqDisplay();
return true;
}
vchanWindow.trx ??= {}; vchanWindow.trx ??= {};
vchanWindow.trx.modules ??= {}; vchanWindow.trx.modules ??= {};
vchanWindow.trx.modules.vchan = { vchanWindow.trx.modules.vchan = {
@@ -583,34 +571,12 @@ vchanWindow.trx.modules.vchan = {
isOnVirtual: vchanIsOnVirtual, isOnVirtual: vchanIsOnVirtual,
interceptMode: vchanInterceptMode, interceptMode: vchanInterceptMode,
interceptBandwidth: vchanInterceptBandwidth, interceptBandwidth: vchanInterceptBandwidth,
interceptFrequency: vchanInterceptFrequency,
interceptFreqDisplay: vchanInterceptFreqDisplay,
takeSchedulerControl: vchanTakeSchedulerControl, takeSchedulerControl: vchanTakeSchedulerControl,
releaseToScheduler: vchanToggleSchedulerRelease, releaseToScheduler: vchanToggleSchedulerRelease,
}; };
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
// frequency changes are redirected to the active virtual channel instead of
// the server when on a non-primary channel.
(function() {
const original = vchanWindow.setRigFrequency;
vchanWindow.setRigFrequency = function(freqHz: number) {
if (vchanIsOnVirtual()) {
// Optimistic local update first, then fire-and-forget channel API.
if (vchanWindow.applyLocalTunedFrequency) {
if (typeof vchanWindow._freqOptimisticSeq === "number") {
vchanWindow._freqOptimisticSeq += 1;
vchanWindow._freqOptimisticHz = Math.round(freqHz);
}
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
}
vchanSetChannelFreq(freqHz);
return;
}
// Scheduler control is fire-and-forget — don't block the freq change.
void vchanTakeSchedulerControl();
original?.(freqHz);
};
})();
(function initSchedulerReleaseControl() { (function initSchedulerReleaseControl() {
const btn = document.getElementById("scheduler-release-btn"); const btn = document.getElementById("scheduler-release-btn");
if (btn) { if (btn) {
@@ -622,16 +588,3 @@ vchanWindow.trx.modules.vchan = {
vchanRenderSchedulerRelease(); 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?.();
};
})();
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
export {}; export {};
import type { PluginRuntimeWindow } from "./runtime-contract"; import type { PluginRuntimeWindow } from "./runtime-contract";
@@ -38,8 +40,6 @@ interface VdesMessage {
interface VdesBridge { interface VdesBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
escapeMapHtml?: (input: string) => string;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
vdesMapAddPoint?: (message: VdesMessage) => void; vdesMapAddPoint?: (message: VdesMessage) => void;
updateVdesBar?: () => void; updateVdesBar?: () => void;
@@ -47,9 +47,7 @@ interface VdesBridge {
} }
const vdesWindow = window as unknown as VdesBridge; const vdesWindow = window as unknown as VdesBridge;
const escapeVdesHtml = (input: string): string => vdesWindow.escapeMapHtml?.(input) ?? input const escapeVdesHtml = (input: string): string => hostCore.escapeMapHtml(input);
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
// --- VDES Decoder Plugin (server-side decode) --- // --- VDES Decoder Plugin (server-side decode) ---
const vdesStatus = document.getElementById("vdes-status"); 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 () => { 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; if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await vdesWindow.postPath?.("/clear_vdes_decode"); await hostCore.postPath("/clear_vdes_decode");
resetVdesHistoryView(); resetVdesHistoryView();
} catch (e) { } catch (e) {
console.error("VDES history clear failed", e); console.error("VDES history clear failed", e);
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
@@ -29,7 +31,6 @@ interface WefaxProgress {
interface WefaxBridge { interface WefaxBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
postPath?: (path: string) => Promise<unknown>;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
syncWefaxToggle?: (enabled: boolean) => void; syncWefaxToggle?: (enabled: boolean) => void;
} }
@@ -411,7 +412,7 @@ if (wefaxDom.toggleBtn) {
if (wefaxWindow.takeSchedulerControlForDecoderDisable) { if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton); await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
} }
await wefaxWindow.postPath?.('/toggle_wefax_decode'); await hostCore.postPath('/toggle_wefax_decode');
} catch (e) { } catch (e) {
console.error('WEFAX toggle failed', e); console.error('WEFAX toggle failed', e);
} }
@@ -420,7 +421,7 @@ if (wefaxDom.toggleBtn) {
if (wefaxDom.clearBtn) { if (wefaxDom.clearBtn) {
wefaxDom.clearBtn.addEventListener('click', () => { void (async () => { wefaxDom.clearBtn.addEventListener('click', () => { void (async () => {
try { try {
await wefaxWindow.postPath?.('/clear_wefax_decode'); await hostCore.postPath('/clear_wefax_decode');
resetWefaxHistoryView(); resetWefaxHistoryView();
} catch (e) { } catch (e) {
console.error('WEFAX clear failed', e); console.error('WEFAX clear failed', e);
@@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
@@ -25,7 +27,6 @@ interface WsprBridge {
navigateToMapLocator?: (grid: string, type: "wspr") => void; navigateToMapLocator?: (grid: string, type: "wspr") => void;
clearMapMarkersByType?: (type: "wspr") => void; clearMapMarkersByType?: (type: "wspr") => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
[key: string]: unknown; [key: string]: unknown;
} }
@@ -291,7 +292,7 @@ wsprDecodeToggleBtn?.addEventListener("click", () => {
void (async () => { void (async () => {
try { try {
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
await wsprWindow.postPath?.("/toggle_wspr_decode"); await hostCore.postPath("/toggle_wspr_decode");
} catch (error: unknown) { } catch (error: unknown) {
console.error("WSPR toggle failed", error); console.error("WSPR toggle failed", error);
} }
@@ -302,7 +303,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
void (async () => { void (async () => {
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await wsprWindow.postPath?.("/clear_wspr_decode"); await hostCore.postPath("/clear_wspr_decode");
resetWsprHistoryView(); resetWsprHistoryView();
} catch (error: unknown) { } catch (error: unknown) {
console.error("WSPR history clear failed", error); console.error("WSPR history clear failed", error);
@@ -3,14 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs"; import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("AIS entry forwards positioned vessels with normalized metadata", async () => { test("AIS entry forwards positioned vessels with normalized metadata", async () => {
const forwarded = []; const forwarded = [];
const window = { const window = {
...createHost(),
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
aisMapAddVessel: (message) => { forwarded.push(message); }, aisMapAddVessel: (message) => { forwarded.push(message); },
}; };
@@ -25,7 +26,7 @@ test("AIS entry forwards positioned vessels with normalized metadata", async ()
console, console,
}); });
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); 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(runtime).runInContext(context);
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);
@@ -6,10 +6,12 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs"; import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("APRS entry normalizes positioned packets without remote symbol assets", async () => { test("APRS entry normalizes positioned packets without remote symbol assets", async () => {
const forwarded = []; const forwarded = [];
const window = { const window = {
...createHost(),
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
aprsMapAddStation: (...args) => { forwarded.push(args); }, 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][1], 54.5);
assert.equal(forwarded[0][6].rig_id, null); 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]]);
});
@@ -3,13 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; 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 () => { test("background decode loads configuration for the explicitly selected rig", async () => {
const requested = []; const requested = [];
const window = { const window = {
...createHost(),
decoderRegistry: [], decoderRegistry: [],
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
}; };
@@ -35,7 +37,7 @@ test("background decode loads configuration for the explicitly selected rig", as
Error, Error,
console, 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); new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "control"); window.trx.modules.backgroundDecode.initialize("rig/a", "control");
@@ -3,9 +3,9 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
class ElementFixture { class ElementFixture {
constructor() { constructor() {
@@ -26,6 +26,48 @@ class ElementFixture {
querySelector() { return null; } querySelector() { return null; }
} }
// Mirrors the `window.trx` host contract published by app.ts. The plugin is a
// separate bundle, so every application service it uses arrives this way.
function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0 };
const state = {
authEnabled: false,
authRole: "control",
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
lastFreqHz: 14_074_000,
lastModeName: "USB",
lastSpectrumData: null,
currentBandwidthHz: 2400,
decoderRegistry: [],
...overrides,
};
const core = {
postPath: async (path) => { calls.postPath.push(path); },
setRigFrequency: (hz) => { calls.setRigFrequency.push(hz); },
applyLocalTunedFrequency: (hz, force) => { calls.applyLocalTunedFrequency.push([hz, force]); },
armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); },
syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); },
scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; },
onDecoderRegistryReady: () => {},
};
return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls };
}
function documentFixture(element) {
return {
getElementById: element,
querySelector: () => new ElementFixture(),
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
createTextNode: (text) => ({ textContent: text }),
addEventListener() {},
};
}
const source = await bundleEntry(new URL("../src/plugins/bookmarks.ts", import.meta.url));
test("bookmarks register an explicit typed service for application consumers", async () => { test("bookmarks register an explicit typed service for application consumers", async () => {
const elements = new Map(); const elements = new Map();
const element = (id) => { const element = (id) => {
@@ -36,33 +78,15 @@ test("bookmarks register an explicit typed service for application consumers", a
element("bm-category-filter").options.push({ value: "" }); element("bm-category-filter").options.push({ value: "" });
element("bm-mode-filter").options.push({ value: "" }); element("bm-mode-filter").options.push({ value: "" });
const bookmarks = [{ id: "one", name: "Local", freq_hz: 145_500_000, mode: "FM", scope: "general" }]; const bookmarks = [{ id: "one", name: "Local", freq_hz: 145_500_000, mode: "FM", scope: "general" }];
const window = { const { window } = hostFixture();
trx: { modules: {} },
trxUi: { confirm: async () => true },
decoderRegistry: [],
};
const context = vm.createContext({ const context = vm.createContext({
window, window,
document: { document: documentFixture(element),
getElementById: element,
querySelector: () => new ElementFixture(),
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
createTextNode: (text) => ({ textContent: text }),
addEventListener() {},
},
fetch: async () => ({ ok: true, json: async () => bookmarks }), fetch: async () => ({ ok: true, json: async () => bookmarks }),
CSS: { escape: (value) => value }, CSS: { escape: (value) => value },
Element: ElementFixture, Element: ElementFixture,
Set,
Map,
Array,
Number,
String,
Promise,
console, console,
}); });
const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8");
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
@@ -73,3 +97,80 @@ test("bookmarks register an explicit typed service for application consumers", a
assert.equal(typeof window.trx.modules.bookmarks.populateScopePicker, "function"); assert.equal(typeof window.trx.modules.bookmarks.populateScopePicker, "function");
assert.equal(globalThis.bmOverlayList, undefined); assert.equal(globalThis.bmOverlayList, undefined);
}); });
test("applying a bookmark drives tuning through the host services", async () => {
const elements = new Map();
const element = (id) => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window, calls } = hostFixture({
lastActiveRigId: "sdr",
decoderRegistry: [
{ id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"], bookmark_selectable: true },
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW"], bookmark_selectable: true },
],
});
const context = vm.createContext({
window,
document: documentFixture(element),
fetch: async (url) => ({
ok: true,
json: async () => (url.startsWith("/status") ? { ft8_decode_enabled: false, cw_decode_enabled: true } : []),
}),
CSS: { escape: (value) => value },
Element: ElementFixture,
console,
});
new vm.Script(source).runInContext(context);
await new Promise((resolve) => setImmediate(resolve));
calls.postPath.length = 0;
window.trx.modules.bookmarks.apply({
id: "ft8-20m", name: "FT8 20m", freq_hz: 14_074_000, mode: "USB", bandwidth_hz: 3000, decoders: ["ft8"],
});
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
// Optimistic UI: the guard is armed before the display update so a stale SSE
// frame cannot snap the marker back while the tune is in flight.
assert.deepEqual(calls.armOptimisticFrequency, [14_074_000]);
assert.deepEqual(calls.applyLocalTunedFrequency, [[14_074_000, true]]);
assert.deepEqual(calls.syncBandwidthInput, [3000]);
assert.equal(window.trx.state.currentBandwidthHz, 3000);
assert.equal(element("mode").value, "USB");
// Rig commands: mode and bandwidth over postPath, frequency via the core
// tuning service so virtual-channel redirection still applies.
assert.deepEqual(calls.setRigFrequency, [14_074_000]);
assert.ok(calls.postPath.includes("/set_mode?mode=USB"));
assert.ok(calls.postPath.includes("/set_bandwidth?hz=3000"));
// ft8 is selected and off; cw is on but incompatible with USB.
assert.ok(calls.postPath.includes("/toggle_ft8_decode"));
assert.ok(calls.postPath.includes("/toggle_cw_decode"));
});
test("bookmark controls follow the host authentication state", async () => {
const elements = new Map();
const element = (id) => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
const context = vm.createContext({
window,
document: documentFixture(element),
fetch: async () => ({ ok: true, json: async () => [] }),
CSS: { escape: (value) => value },
Element: ElementFixture,
console,
});
new vm.Script(source).runInContext(context);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "control";
await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, "");
});
@@ -3,10 +3,10 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs"; import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
class ElementFixture { class ElementFixture {
constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; } 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 output = new ElementFixture();
const status = new ElementFixture(); const status = new ElementFixture();
const elements = new Map([["cw-output", output], ["cw-status", status]]); 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({ const context = vm.createContext({
window, window,
document: { document: {
@@ -39,7 +39,7 @@ test("CW entry appends server-decoded text and registers lifecycle callbacks", a
console, console,
}); });
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); 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(runtime).runInContext(context);
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);
@@ -6,11 +6,13 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs"; import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("FT2 entry normalizes audio offsets and registers typed callbacks", async () => { test("FT2 entry normalizes audio offsets and registers typed callbacks", async () => {
let barRenderer; let barRenderer;
const mapMessages = []; const mapMessages = [];
const window = { const window = {
...createHost(),
ft8BaseHz: 14_074_000, ft8BaseHz: 14_074_000,
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
ft8ExtractAllGrids: () => ["JO91"], 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 () => { test("FT8 entry installs shared parsing without relying on script globals", async () => {
const forwarded = []; const forwarded = [];
const window = { const window = {
...createHost(),
ft8BaseHz: 7_074_000, ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
mapAddLocator: (...args) => { forwarded.push(args); }, mapAddLocator: (...args) => { forwarded.push(args); },
@@ -6,9 +6,10 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs"; import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("HF APRS entry uses shared typed normalization and local symbols", async () => { 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({ const context = vm.createContext({
window, window,
document: { getElementById: () => null, querySelectorAll: () => [] }, document: { getElementById: () => null, querySelectorAll: () => [] },
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;"),
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,
},
};
}
@@ -3,14 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs"; import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => { test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => {
const overlays = []; const overlays = [];
const window = { const window = {
...createHost(),
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
addSatMapOverlay: (image) => { overlays.push(image); }, addSatMapOverlay: (image) => { overlays.push(image); },
}; };
@@ -28,7 +29,7 @@ test("satellite entry registers lifecycle callbacks and forwards georeferenced i
console, console,
}); });
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); 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(runtime).runInContext(context);
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);
@@ -3,12 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; 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 () => { 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({ const context = vm.createContext({
window, window,
document: { document: {
@@ -31,7 +34,7 @@ test("scheduler registers a typed module service without lifecycle globals", asy
WeakSet, WeakSet,
console, 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); new vm.Script(source).runInContext(context);
const service = window.trx.modules.scheduler; 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.initScheduler, undefined);
assert.equal(window.schedulerBridge, 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)}`);
});
@@ -3,12 +3,13 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; 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 () => { test("virtual channels expose typed SSE and interception boundaries", async () => {
const window = {}; const window = { ...createHost() };
const context = vm.createContext({ const context = vm.createContext({
window, window,
document: { getElementById: () => null }, document: { getElementById: () => null },
@@ -25,7 +26,7 @@ test("virtual channels expose typed SSE and interception boundaries", async () =
Error, Error,
console, 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); new vm.Script(source).runInContext(context);
const service = window.trx.modules.vchan; const service = window.trx.modules.vchan;
@@ -34,6 +35,8 @@ test("virtual channels expose typed SSE and interception boundaries", async () =
assert.equal(typeof service.applyCapabilities, "function"); assert.equal(typeof service.applyCapabilities, "function");
assert.equal(await service.interceptMode("USB"), false); assert.equal(await service.interceptMode("USB"), false);
assert.equal(await service.interceptBandwidth(2400), false); assert.equal(await service.interceptBandwidth(2400), false);
// No virtual channel is active, so tuning stays with the physical rig.
assert.equal(service.interceptFrequency(14_074_000), false);
assert.equal(window.vchanHandleSession, undefined); assert.equal(window.vchanHandleSession, undefined);
assert.equal(window.vchanInterceptBandwidth, undefined); assert.equal(window.vchanInterceptBandwidth, undefined);
}); });
@@ -3,14 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs"; import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("VDES entry normalizes and forwards positioned server messages", async () => { test("VDES entry normalizes and forwards positioned server messages", async () => {
const forwarded = []; const forwarded = [];
const window = { const window = {
...createHost(),
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
vdesMapAddPoint: (message) => { forwarded.push(message); }, vdesMapAddPoint: (message) => { forwarded.push(message); },
}; };
@@ -24,7 +25,7 @@ test("VDES entry normalizes and forwards positioned server messages", async () =
console, console,
}); });
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); 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(runtime).runInContext(context);
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);
@@ -3,14 +3,14 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs"; import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", async () => { test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", async () => {
const status = { textContent: "", style: { color: "" } }; const status = { textContent: "", style: { color: "" } };
const window = {}; const window = { ...createHost() };
const context = vm.createContext({ const context = vm.createContext({
window, window,
document: { getElementById: (id) => id === "wefax-status" ? status : null }, 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, console,
}); });
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); 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(runtime).runInContext(context);
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);
@@ -3,14 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs"; import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("WSPR entry forwards decoded locators at their absolute frequency", async () => { test("WSPR entry forwards decoded locators at their absolute frequency", async () => {
const forwarded = []; const forwarded = [];
const window = { const window = {
...createHost(),
ft8BaseHz: 14_095_600, ft8BaseHz: 14_095_600,
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
mapAddLocator: (...args) => { forwarded.push(args); }, mapAddLocator: (...args) => { forwarded.push(args); },
@@ -27,7 +28,7 @@ test("WSPR entry forwards decoded locators at their absolute frequency", async (
console, console,
}); });
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); 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(runtime).runInContext(context);
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);