Compare commits

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

Restore the broken behavior:

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

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

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

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

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

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

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

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

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

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