[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>
This commit was merged in pull request #23.
This commit is contained in:
sjg
2026-08-02 11:20:17 +02:00
co-authored by Claude Opus 5
parent 23dbcac5b6
commit 0d4c657b97
50 changed files with 687 additions and 398 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);
@@ -5775,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;
@@ -5832,6 +5846,16 @@ var trxCore = Object.freeze({
syncBandwidthInput, syncBandwidthInput,
scheduleSpectrumDraw, scheduleSpectrumDraw,
onDecoderRegistryReady, 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,7 +1,10 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/bookmarks.ts // src/plugins/bookmarks.ts
var bridge = window; var bridge = window;
var trxState = bridge.trx.state;
var trxCore = bridge.trx.core;
function bmEl(id) { function bmEl(id) {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) throw new Error(`Missing bookmark element #${id}`); if (!element) throw new Error(`Missing bookmark element #${id}`);
@@ -39,7 +42,7 @@ function bmEsc(str) {
return d.innerHTML; return d.innerHTML;
} }
function bmCanControl() { function bmCanControl() {
return !trxState.authEnabled || trxState.authRole === "control"; return !hostState.authEnabled || hostState.authRole === "control";
} }
function bmSyncAccess() { function bmSyncAccess() {
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
@@ -49,7 +52,7 @@ function bmSyncAccess() {
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
} }
function bmListScope() { function bmListScope() {
return trxState.lastActiveRigId || "general"; return hostState.lastActiveRigId || "general";
} }
async function bmFetchOverlay() { async function bmFetchOverlay() {
const overlayScope = bmListScope(); const overlayScope = bmListScope();
@@ -65,7 +68,7 @@ async function bmFetchOverlay() {
if (typeof bridge.syncBookmarkMapLocators === "function") { if (typeof bridge.syncBookmarkMapLocators === "function") {
bridge.syncBookmarkMapLocators(bmOverlayList); bridge.syncBookmarkMapLocators(bmOverlayList);
} }
trxCore.scheduleSpectrumDraw(); hostCore.scheduleSpectrumDraw();
} }
async function bmFetch(categoryFilter) { async function bmFetch(categoryFilter) {
let url = "/bookmarks"; let url = "/bookmarks";
@@ -188,11 +191,11 @@ function bmChangePage(delta) {
bmRender(bmFilteredList); bmRender(bmFilteredList);
} }
function bmReadDecoders() { function bmReadDecoders() {
return trxState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id); return hostState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
} }
function bmWriteDecoders(decoders) { function bmWriteDecoders(decoders) {
const set = new Set(decoders || []); const set = new Set(decoders || []);
trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => { hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
const el = bmOptionalEl("bm-dec-" + d.id); const el = bmOptionalEl("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id); if (el) el.checked = set.has(d.id);
}); });
@@ -201,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 = "";
trxState.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;
@@ -231,17 +234,17 @@ function bmCloseForm() {
if (wrap) wrap.style.display = "none"; if (wrap) wrap.style.display = "none";
} }
function bmPrefillFromStatus() { function bmPrefillFromStatus() {
const freqHz = trxState.lastFreqHz; const freqHz = hostState.lastFreqHz;
if (freqHz != null && Number.isFinite(freqHz)) { if (freqHz != null && Number.isFinite(freqHz)) {
bmEl("bm-freq").value = String(Math.round(freqHz)); bmEl("bm-freq").value = String(Math.round(freqHz));
} }
if (trxState.lastModeName) { if (hostState.lastModeName) {
bmEl("bm-mode").value = trxState.lastModeName; bmEl("bm-mode").value = hostState.lastModeName;
} }
if (trxState.currentBandwidthHz > 0) { if (hostState.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz)); bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
} }
const activeDecoders = trxState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id); const activeDecoders = hostState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id);
bmWriteDecoders(activeDecoders); bmWriteDecoders(activeDecoders);
} }
async function bmSave(e) { async function bmSave(e) {
@@ -327,36 +330,36 @@ function bmApply(bm) {
modeEl.value = (bm.mode || "").toUpperCase(); modeEl.value = (bm.mode || "").toUpperCase();
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
trxState.currentBandwidthHz = bm.bandwidth_hz; hostState.currentBandwidthHz = bm.bandwidth_hz;
trxCore.syncBandwidthInput(bm.bandwidth_hz); hostCore.syncBandwidthInput(bm.bandwidth_hz);
} }
trxCore.armOptimisticFrequency(bm.freq_hz); hostCore.armOptimisticFrequency(bm.freq_hz);
trxCore.applyLocalTunedFrequency(bm.freq_hz, true); hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
if (trxState.lastSpectrumData) { if (hostState.lastSpectrumData) {
trxCore.scheduleSpectrumDraw(); hostCore.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 trxCore.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 trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
} }
} }
trxCore.setRigFrequency(bm.freq_hz); hostCore.setRigFrequency(bm.freq_hz);
})(); })();
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase(); const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = trxState.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";
const rigId = trxState.lastActiveRigId; const rigId = hostState.lastActiveRigId;
if (rigId) { if (rigId) {
statusUrl += "?remote=" + encodeURIComponent(rigId); statusUrl += "?remote=" + encodeURIComponent(rigId);
} }
@@ -377,7 +380,7 @@ function bmApply(bm) {
wanted = currentlyOn; wanted = currentlyOn;
} }
if (wanted !== currentlyOn) { if (wanted !== currentlyOn) {
toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
} }
} }
if (toggles.length) await Promise.all(toggles); if (toggles.length) await Promise.all(toggles);
@@ -427,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 = trxState.lastRigIds; const rigIds = hostState.lastRigIds;
const displayNames = trxState.lastRigDisplayNames; const displayNames = hostState.lastRigDisplayNames;
const prev = sel.value; const prev = sel.value;
sel.innerHTML = ""; sel.innerHTML = "";
if (bmScope !== "general") { if (bmScope !== "general") {
@@ -533,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 = trxState.lastRigIds; const rigIds = hostState.lastRigIds;
const displayNames = trxState.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) => {
@@ -553,7 +556,7 @@ function bmPopulateScopePicker() {
(function initBookmarks() { (function initBookmarks() {
bmSyncAccess(); bmSyncAccess();
bmBuildDecoderCheckboxes(); bmBuildDecoderCheckboxes();
trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); hostCore.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) { hostCore.updateDocumentTitle(hostCore.activeChannelRds());
vchanWindow.updateDocumentTitle(vchanWindow.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;
} }
} }
@@ -413,13 +407,17 @@ async function vchanInterceptBandwidth(bwHz) {
} }
function vchanInterceptFrequency(freqHz) { function vchanInterceptFrequency(freqHz) {
if (!vchanIsOnVirtual()) return false; if (!vchanIsOnVirtual()) return false;
const core = vchanWindow.trx?.core;
const targetHz = Math.round(freqHz); const targetHz = Math.round(freqHz);
core?.armOptimisticFrequency(targetHz); hostCore.armOptimisticFrequency(targetHz);
core?.applyLocalTunedFrequency(targetHz); hostCore.applyLocalTunedFrequency(targetHz);
vchanSetChannelFreq(freqHz); vchanSetChannelFreq(freqHz);
return true; 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 = {
@@ -437,6 +435,7 @@ vchanWindow.trx.modules.vchan = {
interceptMode: vchanInterceptMode, interceptMode: vchanInterceptMode,
interceptBandwidth: vchanInterceptBandwidth, interceptBandwidth: vchanInterceptBandwidth,
interceptFrequency: vchanInterceptFrequency, interceptFrequency: vchanInterceptFrequency,
interceptFreqDisplay: vchanInterceptFreqDisplay,
takeSchedulerControl: vchanTakeSchedulerControl, takeSchedulerControl: vchanTakeSchedulerControl,
releaseToScheduler: vchanToggleSchedulerRelease releaseToScheduler: vchanToggleSchedulerRelease
}; };
@@ -450,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.
@@ -203,6 +203,7 @@ interface TrxModules {
handleSession(data: string): void; handleSession(data: string): void;
interceptBandwidth(bandwidthHz: number): Promise<boolean>; interceptBandwidth(bandwidthHz: number): Promise<boolean>;
interceptFrequency(frequencyHz: number): 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>;
@@ -236,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;
@@ -1987,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);
@@ -4856,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; } },
@@ -4875,6 +4885,9 @@ const trxCore = Object.freeze({
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor, postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency, setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, 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,
@@ -5495,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;
@@ -43,37 +37,8 @@ interface VirtualChannelService {
takeSchedulerControl(): Promise<void>; takeSchedulerControl(): Promise<void>;
} }
/* Application state read through the `window.trx` host contract. This plugin
* is a separate bundle, so it cannot import the application module directly. */
interface BookmarkHostState {
readonly authEnabled: boolean;
readonly authRole: string | null;
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>;
readonly lastFreqHz: number | null;
readonly lastModeName: string;
readonly lastSpectrumData: unknown;
currentBandwidthHz: number;
readonly decoderRegistry: readonly DecoderDescriptor[];
}
interface BookmarkHostCore {
postPath(path: string): Promise<unknown>;
setRigFrequency(frequencyHz: number): void;
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
armOptimisticFrequency(frequencyHz: number): void;
syncBandwidthInput(bandwidthHz: number): void;
scheduleSpectrumDraw(): void;
onDecoderRegistryReady(callback: () => void): void;
}
interface BookmarkBridge { interface BookmarkBridge {
trx: { trx: { modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
state: BookmarkHostState;
core: BookmarkHostCore;
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;
@@ -83,8 +48,6 @@ interface BookmarkBridge {
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement; type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
const bridge = window as unknown as BookmarkBridge; const bridge = window as unknown as BookmarkBridge;
const trxState = bridge.trx.state;
const trxCore = bridge.trx.core;
function bmEl(id: string): BookmarkElement { function bmEl(id: string): BookmarkElement {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) throw new Error(`Missing bookmark element #${id}`); if (!element) throw new Error(`Missing bookmark element #${id}`);
@@ -137,7 +100,7 @@ function bmEsc(str: unknown): string {
} }
function bmCanControl() { function bmCanControl() {
return !trxState.authEnabled || trxState.authRole === "control"; return !hostState.authEnabled || hostState.authRole === "control";
} }
// Show/hide the Add Bookmark / Select All buttons based on the current auth role. // Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -151,7 +114,7 @@ function bmSyncAccess() {
/** The listing scope: always the active rig (to merge general + rig bookmarks). */ /** The listing scope: always the active rig (to merge general + rig bookmarks). */
function bmListScope() { function bmListScope() {
return trxState.lastActiveRigId || "general"; return hostState.lastActiveRigId || "general";
} }
async function bmFetchOverlay() { async function bmFetchOverlay() {
@@ -168,7 +131,7 @@ async function bmFetchOverlay() {
if (typeof bridge.syncBookmarkMapLocators === "function") { if (typeof bridge.syncBookmarkMapLocators === "function") {
bridge.syncBookmarkMapLocators(bmOverlayList); bridge.syncBookmarkMapLocators(bmOverlayList);
} }
trxCore.scheduleSpectrumDraw(); hostCore.scheduleSpectrumDraw();
} }
async function bmFetch(categoryFilter: string): Promise<void> { async function bmFetch(categoryFilter: string): Promise<void> {
@@ -324,7 +287,7 @@ 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 trxState.decoderRegistry return hostState.decoderRegistry
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked) .filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
.map(d => d.id); .map(d => d.id);
@@ -333,7 +296,7 @@ function bmReadDecoders(): string[] {
// 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 || []);
trxState.decoderRegistry hostState.decoderRegistry
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.forEach(d => { .forEach(d => {
const el = bmOptionalEl("bm-dec-" + d.id); const el = bmOptionalEl("bm-dec-" + d.id);
@@ -346,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 = "";
trxState.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");
@@ -387,18 +350,18 @@ function bmCloseForm() {
function bmPrefillFromStatus() { function bmPrefillFromStatus() {
// Read live rig state from the host contract (updated by the SSE stream). // Read live rig state from the host contract (updated by the SSE stream).
const freqHz = trxState.lastFreqHz; const freqHz = hostState.lastFreqHz;
if (freqHz != null && Number.isFinite(freqHz)) { if (freqHz != null && Number.isFinite(freqHz)) {
bmEl("bm-freq").value = String(Math.round(freqHz)); bmEl("bm-freq").value = String(Math.round(freqHz));
} }
if (trxState.lastModeName) { if (hostState.lastModeName) {
bmEl("bm-mode").value = trxState.lastModeName; bmEl("bm-mode").value = hostState.lastModeName;
} }
if (trxState.currentBandwidthHz > 0) { if (hostState.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(trxState.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 = trxState.decoderRegistry const activeDecoders = hostState.decoderRegistry
.filter(d => d.bookmark_selectable && d.activation === "toggle") .filter(d => d.bookmark_selectable && d.activation === "toggle")
.filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true") .filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
.map(d => d.id); .map(d => d.id);
@@ -495,15 +458,15 @@ function bmApply(bm: Bookmark): void {
modeEl.value = (bm.mode || "").toUpperCase(); modeEl.value = (bm.mode || "").toUpperCase();
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
trxState.currentBandwidthHz = bm.bandwidth_hz; hostState.currentBandwidthHz = bm.bandwidth_hz;
trxCore.syncBandwidthInput(bm.bandwidth_hz); hostCore.syncBandwidthInput(bm.bandwidth_hz);
} }
// Set optimistic guard before applying so SSE cannot snap back. // Set optimistic guard before applying so SSE cannot snap back.
trxCore.armOptimisticFrequency(bm.freq_hz); hostCore.armOptimisticFrequency(bm.freq_hz);
// Force display so the BW overlay is repositioned even when freq is unchanged. // Force display so the BW overlay is repositioned even when freq is unchanged.
trxCore.applyLocalTunedFrequency(bm.freq_hz, true); hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
if (trxState.lastSpectrumData) { if (hostState.lastSpectrumData) {
trxCore.scheduleSpectrumDraw(); hostCore.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
@@ -513,21 +476,21 @@ function bmApply(bm: Bookmark): void {
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 trxCore.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 trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
} }
} }
// setRigFrequency redirects to the channel API when a virtual channel is // setRigFrequency redirects to the channel API when a virtual channel is
// active. It repeats the optimistic update applied above, which is a // active. It repeats the optimistic update applied above, which is a
// no-op because the value is unchanged. // no-op because the value is unchanged.
trxCore.setRigFrequency(bm.freq_hz); hostCore.setRigFrequency(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
@@ -537,12 +500,12 @@ 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 = trxState.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";
const rigId = trxState.lastActiveRigId; const rigId = hostState.lastActiveRigId;
if (rigId) { if (rigId) {
statusUrl += "?remote=" + encodeURIComponent(rigId); statusUrl += "?remote=" + encodeURIComponent(rigId);
} }
@@ -566,7 +529,7 @@ function bmApply(bm: Bookmark): void {
wanted = currentlyOn; wanted = currentlyOn;
} }
if (wanted !== currentlyOn) { if (wanted !== currentlyOn) {
toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
} }
} }
if (toggles.length) await Promise.all(toggles); if (toggles.length) await Promise.all(toggles);
@@ -616,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 = trxState.lastRigIds; const rigIds = hostState.lastRigIds;
const displayNames = trxState.lastRigDisplayNames; const displayNames = hostState.lastRigDisplayNames;
const prev = sel.value; const prev = sel.value;
sel.innerHTML = ""; sel.innerHTML = "";
if (bmScope !== "general") { if (bmScope !== "general") {
@@ -725,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 = trxState.lastRigIds; const rigIds = hostState.lastRigIds;
const displayNames = trxState.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);
@@ -753,7 +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();
trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); hostCore.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,40 +23,13 @@ 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;
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; trx?: { modules?: { vchan?: VirtualChannelService } };
mwDefaultsForMode?: (mode: string) => [number, ...unknown[]];
showHint?: (message: string, durationMs: number) => void;
refreshFreqDisplay?: () => void;
trx?: {
core?: VirtualChannelCoreServices;
modules?: { vchan?: VirtualChannelService };
};
}
interface VirtualChannelCoreServices {
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
armOptimisticFrequency(frequencyHz: number): void;
} }
interface VirtualChannelService { interface VirtualChannelService {
@@ -68,6 +43,7 @@ interface VirtualChannelService {
interceptMode(mode: string): Promise<boolean>; interceptMode(mode: string): Promise<boolean>;
interceptBandwidth(bandwidthHz: number): Promise<boolean>; interceptBandwidth(bandwidthHz: number): Promise<boolean>;
interceptFrequency(frequencyHz: number): boolean; interceptFrequency(frequencyHz: number): boolean;
interceptFreqDisplay(): boolean;
takeSchedulerControl(): Promise<void>; takeSchedulerControl(): Promise<void>;
releaseToScheduler(): Promise<void>; releaseToScheduler(): Promise<void>;
} }
@@ -151,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",
@@ -266,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();
} }
@@ -276,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";
@@ -379,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);
} }
@@ -419,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.
@@ -437,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();
} }
} }
@@ -464,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;
} }
} }
@@ -485,29 +455,22 @@ function vchanSyncAccentUI() {
vchanSyncModeDisplay(); vchanSyncModeDisplay();
vchanSyncBwDisplay(); vchanSyncBwDisplay();
} else { } else {
origRefreshFreqDisplay?.(); hostCore.refreshFreqDisplay();
} }
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { hostCore.updateDocumentTitle(hostCore.activeChannelRds());
vchanWindow.updateDocumentTitle(vchanWindow.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;
} }
} }
@@ -581,14 +544,21 @@ async function vchanInterceptBandwidth(bwHz: number): Promise<boolean> {
// so this applies the optimistic local update and reports the tune as handled. // so this applies the optimistic local update and reports the tune as handled.
function vchanInterceptFrequency(freqHz: number): boolean { function vchanInterceptFrequency(freqHz: number): boolean {
if (!vchanIsOnVirtual()) return false; if (!vchanIsOnVirtual()) return false;
const core = vchanWindow.trx?.core;
const targetHz = Math.round(freqHz); const targetHz = Math.round(freqHz);
core?.armOptimisticFrequency(targetHz); hostCore.armOptimisticFrequency(targetHz);
core?.applyLocalTunedFrequency(targetHz); hostCore.applyLocalTunedFrequency(targetHz);
vchanSetChannelFreq(freqHz); vchanSetChannelFreq(freqHz);
return true; 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 = {
@@ -602,6 +572,7 @@ vchanWindow.trx.modules.vchan = {
interceptMode: vchanInterceptMode, interceptMode: vchanInterceptMode,
interceptBandwidth: vchanInterceptBandwidth, interceptBandwidth: vchanInterceptBandwidth,
interceptFrequency: vchanInterceptFrequency, interceptFrequency: vchanInterceptFrequency,
interceptFreqDisplay: vchanInterceptFreqDisplay,
takeSchedulerControl: vchanTakeSchedulerControl, takeSchedulerControl: vchanTakeSchedulerControl,
releaseToScheduler: vchanToggleSchedulerRelease, releaseToScheduler: vchanToggleSchedulerRelease,
}; };
@@ -617,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() {
@@ -66,7 +66,7 @@ function documentFixture(element) {
}; };
} }
const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8"); const source = await bundleEntry(new URL("../src/plugins/bookmarks.ts", import.meta.url));
test("bookmarks register an explicit typed service for application consumers", async () => { test("bookmarks register an explicit typed service for application consumers", async () => {
const elements = new Map(); const elements = new Map();
@@ -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;
@@ -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);