Complete TypeScript frontend migration #22

Merged
sjg merged 51 commits from feat/typescript-frontend-migration into main 2026-08-01 23:06:19 +02:00
4 changed files with 523 additions and 341 deletions
Showing only changes of commit 812359c744 - Show all commits
+78
View File
@@ -0,0 +1,78 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Frontend architecture
The HTTP frontend is a strict TypeScript application built with esbuild. Rust
embeds deterministic JavaScript output from `assets/web/generated`; Cargo does
not run Node.js or contact the network.
## Runtime graph
`frontend/src/bootstrap.ts` is the only first-party script referenced by the
HTML document. Its imports establish startup order for WebGL support, shared UI
services, decoder dispatch, the local Leaflet AIS adapter, and the application
coordinator. Leaflet and the Opus decoder remain isolated vendored scripts.
Feature entries under `frontend/src/plugins` are ESM bundles. The typed plugin
loader imports them by feature group and keeps expensive map, scheduling, and
decoder behavior lazy. Shared code is emitted as content-hashed chunks. The
decode-history worker is an independent entry compiled against Web Worker
globals.
Dependencies point from application and feature code toward `core` and `api`.
`api/generated.ts` contains Rust wire formats; `api/client.ts` and focused
parsers validate untrusted HTTP, SSE, WebSocket, and worker data before it is
used as typed application state.
## Browser host contract
Separate lazy bundles cannot share module instances with the stable application
entry, so they use three intentional host namespaces:
| Global | Purpose | Mutation policy |
| --- | --- | --- |
| `window.trx` | Application state, core services, and feature registrations | The root is frozen; lazy features may register only their documented `modules.*` service. |
| `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. |
The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`,
`trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet
adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet
namespace.
The following transitional properties are explicitly part of the lazy-feature
host contract and are declared in `app.ts`: `lastSpectrumData`, `lastFreqHz`,
`currentBandwidthHz`, `ft8BaseHz`, `getDecodeHistoryRetentionMs`,
`applyDecodeHistoryRetention`, `getDecodeRigMeta`, `renderRdsOverlays`,
`buildAisVesselUrl`, `trxScheduleUiFrameJob`,
`takeSchedulerControlForDecoderDisable`, `navigateToTab`, `_syncRecorderState`,
and `refreshRdsUi`. Optional callbacks owned by lazy features are
`refreshCwTonePicker`, `updateFt8RfDisplay`, `clearSatPredictionDom`,
`syncWefaxToggle`, `updateAisBar`, `updateVdesBar`, `updateAprsBar`,
`updateFt8Bar`, `updateSatLiveState`, `applyCwAutoUi`, and
`applyCwAutoUiFromServer`.
This list is closed: new standalone mutable `window` properties are not an
accepted integration mechanism. Extend an existing typed service or introduce
an imported interface instead. Removing transitional properties as feature
boundaries become directly importable remains preferable.
## Build and verification
The generated directory is removed before every build, preventing orphaned
compatibility bundles. Stable feature entry names are allowlisted by the Rust
asset manifest; shared chunks use content hashes. The generic Rust handler
rejects unknown names and unsupported MIME types and serves embedded assets
with compression, ETags, and immutable caching.
CI installs from `package-lock.json`, caches only npm downloads, type-checks the
window and worker environments separately, lints, runs unit and DOM tests,
starts the application in Chromium, regenerates Rust contracts and bundles,
checks for drift, and runs REUSE validation after generation.
See `frontend/src/README.md` for local commands and
`docs/ts-migration-plan.md` for the migration decisions and completion gates.
@@ -939,52 +939,6 @@ var runtime = {
}; };
window.trxPluginRuntime = runtime; window.trxPluginRuntime = runtime;
// src/plugin-loader.ts
var pluginGroups = {
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"]
};
var loaded = /* @__PURE__ */ new Set();
var loading = /* @__PURE__ */ new Map();
async function loadPlugin(path) {
if (loaded.has(path)) return;
const pending = loading.get(path);
if (pending) return pending;
const request = import(path).then(() => {
loaded.add(path);
loading.delete(path);
}).catch((error) => {
loading.delete(path);
throw new Error(`Failed to load plugin module: ${path}`, { cause: error });
});
loading.set(path, request);
return request;
}
async function loadPlugins(group) {
if (!(group in pluginGroups)) return;
for (const path of pluginGroups[group]) await loadPlugin(path);
}
function requestPlugins(group) {
void loadPlugins(group).catch((error) => {
console.error(error);
});
}
var loaderWindow = window;
loaderWindow.loadEagerPlugins = async () => {
await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins));
};
loaderWindow.loadPluginsForTab = loadPlugins;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const tab = event.target.closest("[data-tab]")?.dataset.tab;
if (tab) requestPlugins(tab);
});
// src/leaflet-ais-tracksymbol.ts // src/leaflet-ais-tracksymbol.ts
(function() { (function() {
const leaflet = globalThis.L; const leaflet = globalThis.L;
@@ -1605,6 +1559,60 @@ function estimateNoiseFloorDb(bins) {
return nthElement(bins, Math.floor(bins.length * 0.15)); return nthElement(bins, Math.floor(bins.length * 0.15));
} }
// src/plugin-loader.ts
var pluginGroups = {
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"]
};
var loaded = /* @__PURE__ */ new Set();
var loading = /* @__PURE__ */ new Map();
async function loadPlugin(path) {
if (loaded.has(path)) return;
const pending = loading.get(path);
if (pending) return pending;
const request = import(path).then(() => {
loaded.add(path);
loading.delete(path);
}).catch((error) => {
loading.delete(path);
throw new Error(`Failed to load plugin module: ${path}`, { cause: error });
});
loading.set(path, request);
return request;
}
async function loadPlugins(group) {
if (!(group in pluginGroups)) return;
for (const path of pluginGroups[group]) await loadPlugin(path);
}
async function loadEagerPlugins() {
await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins));
}
async function loadPluginsForTab(tab) {
await loadPlugins(tab);
}
// src/api/client.ts
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isRigSnapshot(value) {
if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) {
return false;
}
const { info, status } = value;
return typeof value.initialized === "boolean" && typeof info.manufacturer === "string" && typeof info.model === "string" && isRecord(status.freq) && typeof status.freq.hz === "number" && (typeof status.mode === "string" || isRecord(status.mode)) && typeof status.tx_en === "boolean";
}
function isRigListResponse(value) {
return isRecord(value) && (value.active_remote === null || typeof value.active_remote === "string") && Array.isArray(value.rigs) && value.rigs.every(
(rig) => isRecord(rig) && typeof rig.remote === "string" && typeof rig.manufacturer === "string" && typeof rig.model === "string" && Array.isArray(rig.supported_modes) && typeof rig.tx === "boolean" && typeof rig.filter_controls === "boolean" && typeof rig.initialized === "boolean"
);
}
// src/app.ts // src/app.ts
function requiredElement(id) { function requiredElement(id) {
const element = document.getElementById(id); const element = document.getElementById(id);
@@ -1614,6 +1622,41 @@ function requiredElement(id) {
function isFiniteNumber(value) { function isFiniteNumber(value) {
return typeof value === "number" && Number.isFinite(value); return typeof value === "number" && Number.isFinite(value);
} }
function primitiveString(value) {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
}
function isRecord2(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseJsonUnknown(source) {
return JSON.parse(source);
}
function messageEventData(event) {
const data = event.data;
if (typeof data !== "string") throw new TypeError("Expected a text event payload");
return data;
}
async function responseJsonUnknown(response) {
return await response.json();
}
function isAppUpdate(value) {
return isRecord2(value) && (value.status === void 0 || isRecord2(value.status));
}
function isAudioStreamInfo(value) {
return isRecord2(value) && typeof value.sample_rate === "number" && typeof value.channels === "number";
}
function isRecorderActiveList(value) {
return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.rig_id === "string" && typeof entry.path === "string" && typeof entry.started_at === "number");
}
function isRecorderFileList(value) {
return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.name === "string" && typeof entry.size === "number");
}
function isRdsData(value) {
return isRecord2(value);
}
function isVchanRdsEntry(value) {
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
}
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null; var authRole = null;
var authEnabled = true; var authEnabled = true;
@@ -1731,7 +1774,6 @@ function applyAuthRestrictions() {
const txLimitBtn2 = document.getElementById("tx-limit-btn"); const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn"); const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row"); const txLimitRow2 = document.getElementById("tx-limit-row");
const vfoPicker2 = document.getElementById("vfo-picker");
const jogUp = document.getElementById("jog-up"); const jogUp = document.getElementById("jog-up");
const jogDown = document.getElementById("jog-down"); const jogDown = document.getElementById("jog-down");
const jogButtons = document.querySelectorAll(".jog-step button"); const jogButtons = document.querySelectorAll(".jog-step button");
@@ -1809,7 +1851,7 @@ function applyCapabilities(caps) {
if (txAudioBtn2) txAudioBtn2.style.display = caps.tx ? "" : "none"; if (txAudioBtn2) txAudioBtn2.style.display = caps.tx ? "" : "none";
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
if (!caps.tx && typeof stopTxAudio === "function" && txActive) { if (!caps.tx && typeof stopTxAudio === "function" && txActive) {
stopTxAudio(); void stopTxAudio();
} }
const txLimitRow2 = document.getElementById("tx-limit-row"); const txLimitRow2 = document.getElementById("tx-limit-row");
if (txLimitRow2) txLimitRow2.style.display = caps.tx_limit ? "" : "none"; if (txLimitRow2) txLimitRow2.style.display = caps.tx_limit ? "" : "none";
@@ -2063,7 +2105,6 @@ function decodeHistoryMapRenderingDeferred() {
} }
var lastSpectrumData = null; var lastSpectrumData = null;
window.lastSpectrumData = null; window.lastSpectrumData = null;
var lastControl;
var lastTxEn = null; var lastTxEn = null;
var lastHasTx = true; var lastHasTx = true;
var lastRendered = null; var lastRendered = null;
@@ -2659,7 +2700,7 @@ function applyRigList(activeRigId, rigIds, displayNames = {}) {
window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker(); window.trx.modules.bookmarks?.populateScopePicker();
window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || ""); void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
} }
window.trx.modules.map?.updateMapRigFilter(); window.trx.modules.map?.updateMapRigFilter();
} }
@@ -2667,8 +2708,9 @@ async function refreshRigList() {
try { try {
const resp = await fetch("/rigs", { cache: "no-store" }); const resp = await fetch("/rigs", { cache: "no-store" });
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await responseJsonUnknown(resp);
const rigs = Array.isArray(data.rigs) ? data.rigs : []; if (!isRigListResponse(data)) return;
const rigs = data.rigs;
const rigIds = rigs.map((r) => r.remote).filter(Boolean); const rigIds = rigs.map((r) => r.remote).filter(Boolean);
const displayNames = {}; const displayNames = {};
rigs.forEach((r) => { rigs.forEach((r) => {
@@ -2684,7 +2726,7 @@ async function refreshRigList() {
}); });
serverRigs = rigs; serverRigs = rigs;
refreshOperatorLayoutCapabilities(); refreshOperatorLayoutCapabilities();
serverActiveRigId = data.active_remote || null; serverActiveRigId = data.active_remote;
applyRigList(data.active_remote, rigIds, displayNames); applyRigList(data.active_remote, rigIds, displayNames);
window.trx.modules.map?.syncAprsReceiverMarker(); window.trx.modules.map?.syncAprsReceiverMarker();
} catch (e) { } catch (e) {
@@ -2740,8 +2782,7 @@ var overviewWfTexPushCount = 0;
var overviewWfTexPalKey = ""; var overviewWfTexPalKey = "";
var overviewWfTexReady = false; var overviewWfTexReady = false;
function cssColorToRgba(color, alphaMul = 1) { function cssColorToRgba(color, alphaMul = 1) {
const parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null; const parsed = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor(color) : [0, 0, 0, 1];
const parsed = parser ? parser(color) : [0, 0, 0, 1];
return [ return [
parsed[0] ?? 0, parsed[0] ?? 0,
parsed[1] ?? 0, parsed[1] ?? 0,
@@ -2769,7 +2810,7 @@ function overviewWfResetTextureCache() {
overviewWfTexReady = false; overviewWfTexReady = false;
} }
function overviewWfPaletteKey(pal, viewKey = "") { function overviewWfPaletteKey(pal, viewKey = "") {
return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; return `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
} }
function resizeHeaderSignalCanvas() { function resizeHeaderSignalCanvas() {
if (!ensureOverviewCanvasBackingStore()) return; if (!ensureOverviewCanvasBackingStore()) return;
@@ -2837,7 +2878,7 @@ function drawSignalOverlay() {
const bwStroke = BW_OVERLAY_COLORS.stroke; const bwStroke = BW_OVERLAY_COLORS.stroke;
const bwHard = BW_OVERLAY_COLORS.hard; const bwHard = BW_OVERLAY_COLORS.hard;
const bmRef = window.trx.modules.bookmarks?.overlayList ?? null; const bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
if (Array.isArray(bmRef) && bmRef.length > 0) { if (bmRef && bmRef.length > 0) {
const colorMap = bmCategoryColorMap(); const colorMap = bmCategoryColorMap();
const grouped = /* @__PURE__ */ new Map(); const grouped = /* @__PURE__ */ new Map();
for (const bm of bmRef) { for (const bm of bmRef) {
@@ -2847,8 +2888,9 @@ function drawSignalOverlay() {
const x = hzToX(f); const x = hzToX(f);
if (!isFiniteNumber(x) || x < 0 || x > W) continue; if (!isFiniteNumber(x) || x < 0 || x > W) continue;
const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK; const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK;
if (!grouped.has(color)) grouped.set(color, []); const segments = grouped.get(color) ?? [];
grouped.get(color).push(x, 0, x, H); segments.push(x, 0, x, H);
grouped.set(color, segments);
} }
for (const [color, segments] of grouped.entries()) { for (const [color, segments] of grouped.entries()) {
if (!Array.isArray(segments) || segments.length === 0) continue; if (!Array.isArray(segments) || segments.length === 0) continue;
@@ -3138,7 +3180,7 @@ function waterfallColorRgba(db, pal, minDb, maxDb) {
var _wfLutKey = ""; var _wfLutKey = "";
var _wfLut = new Uint8Array(256 * 4); var _wfLut = new Uint8Array(256 * 4);
function ensureWaterfallLut(pal, minDb, maxDb) { function ensureWaterfallLut(pal, minDb, maxDb) {
const key = `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${minDb}|${maxDb}|${waterfallGamma}`; const key = `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${minDb}|${maxDb}|${waterfallGamma}`;
if (key === _wfLutKey) return; if (key === _wfLutKey) return;
_wfLutKey = key; _wfLutKey = key;
for (let i = 0; i < 256; i++) { for (let i = 0; i < 256; i++) {
@@ -3256,7 +3298,7 @@ function renderRdsOverlays() {
el.innerHTML = html; el.innerHTML = html;
el.addEventListener("click", (evt) => { el.addEventListener("click", (evt) => {
evt.stopPropagation(); evt.stopPropagation();
copyRdsPsToClipboard(entry.rds, entry.freq_hz); void copyRdsPsToClipboard(entry.rds, entry.freq_hz);
}); });
el.addEventListener("mouseenter", () => { el.addEventListener("mouseenter", () => {
el.style.zIndex = String(entries.length + 10); el.style.zIndex = String(entries.length + 10);
@@ -3915,7 +3957,7 @@ function updateJogStepSupport(cap) {
} }
function normalizeMode(modeVal) { function normalizeMode(modeVal) {
if (typeof modeVal === "string") return modeVal; if (typeof modeVal === "string") return modeVal;
if (modeVal && typeof modeVal === "object") { if (isRecord2(modeVal)) {
const entries = Object.entries(modeVal); const entries = Object.entries(modeVal);
if (entries.length > 0) { if (entries.length > 0) {
const firstEntry = entries[0]; const firstEntry = entries[0];
@@ -4436,7 +4478,7 @@ function render(update) {
} }
lastModeName = modeUpper2; lastModeName = modeUpper2;
if (lastSpectrumData && !update.filter) { if (lastSpectrumData && !update.filter) {
applyBwDefaultForMode(mode, false); void applyBwDefaultForMode(mode, false);
} }
} }
updateWfmControls(); updateWfmControls();
@@ -4590,7 +4632,6 @@ function render(update) {
powerBtn.setAttribute("aria-pressed", "false"); powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown"; powerHint.textContent = "State unknown";
} }
lastControl = update.enabled;
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") { if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit); txLimitInput.value = String(update.status.tx.limit);
txLimitRow.style.display = ""; txLimitRow.style.display = "";
@@ -4725,9 +4766,10 @@ async function pollFreshSnapshot() {
const statusUrl = lastActiveRigId ? `/status?remote=${encodeURIComponent(lastActiveRigId)}` : "/status"; const statusUrl = lastActiveRigId ? `/status?remote=${encodeURIComponent(lastActiveRigId)}` : "/status";
const resp = await fetch(statusUrl, { cache: "no-store" }); const resp = await fetch(statusUrl, { cache: "no-store" });
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await responseJsonUnknown(resp);
if (!isRigSnapshot(data)) return;
render(data); render(data);
refreshRigList(); void refreshRigList();
lastEventAt = Date.now(); lastEventAt = Date.now();
} catch (e) { } catch (e) {
} }
@@ -4742,7 +4784,7 @@ function connect() {
} }
stopMeterStreaming(); stopMeterStreaming();
startMeterStreaming(); startMeterStreaming();
pollFreshSnapshot(); void pollFreshSnapshot();
const eventsUrl = lastActiveRigId ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` : "/events"; const eventsUrl = lastActiveRigId ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` : "/events";
es = new EventSource(eventsUrl); es = new EventSource(eventsUrl);
const source = es; const source = es;
@@ -4751,13 +4793,14 @@ function connect() {
setConnLostOverlay(false); setConnLostOverlay(false);
if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (!aboutUptimeStart) aboutUptimeStart = Date.now(); if (!aboutUptimeStart) aboutUptimeStart = Date.now();
pollFreshSnapshot(); void pollFreshSnapshot();
refreshRigList(); void refreshRigList();
}; };
source.onmessage = (evt) => { source.onmessage = (evt) => {
try { try {
if (evt.data === lastRendered) return; if (evt.data === lastRendered) return;
const data = JSON.parse(evt.data); const data = parseJsonUnknown(evt.data);
if (!isAppUpdate(data)) throw new TypeError("Unexpected status event shape");
lastRendered = evt.data; lastRendered = evt.data;
render(data); render(data);
lastEventAt = Date.now(); lastEventAt = Date.now();
@@ -4777,21 +4820,22 @@ function connect() {
}); });
source.addEventListener("session", (evt) => { source.addEventListener("session", (evt) => {
try { try {
const d = JSON.parse(evt.data); const eventData = messageEventData(evt);
sseSessionId = d.session_id || null; const d = parseJsonUnknown(eventData);
sseSessionId = isRecord2(d) && typeof d.session_id === "string" ? d.session_id : null;
} catch (_) { } catch (_) {
} }
window.trx.modules.vchan?.handleSession(evt.data); window.trx.modules.vchan?.handleSession(messageEventData(evt));
}); });
source.addEventListener("channels", (evt) => { source.addEventListener("channels", (evt) => {
window.trx.modules.vchan?.handleChannels(evt.data); window.trx.modules.vchan?.handleChannels(messageEventData(evt));
}); });
source.onerror = () => { source.onerror = () => {
if (source.readyState === EventSource.CLOSED) { if (source.readyState === EventSource.CLOSED) {
powerHint.textContent = "trx-client connection lost, retrying…"; powerHint.textContent = "trx-client connection lost, retrying…";
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true); setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close(); source.close();
pollFreshSnapshot(); void pollFreshSnapshot();
scheduleReconnect(1e3); scheduleReconnect(1e3);
} }
}; };
@@ -4801,7 +4845,7 @@ function connect() {
powerHint.textContent = "trx-client connection lost, retrying…"; powerHint.textContent = "trx-client connection lost, retrying…";
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true); setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close(); source.close();
pollFreshSnapshot(); void pollFreshSnapshot();
scheduleReconnect(250); scheduleReconnect(250);
} }
}, 5e3); }, 5e3);
@@ -4915,7 +4959,7 @@ async function switchRigFromSelect(selectEl) {
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || ""); void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
window.trx.modules.map?.syncAprsReceiverMarker(); window.trx.modules.map?.syncAprsReceiverMarker();
connect(); connect();
stopSpectrumStreaming(); stopSpectrumStreaming();
@@ -4940,7 +4984,7 @@ async function switchRigFromSelect(selectEl) {
} }
if (headerRigSwitchSelect) { if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => { headerRigSwitchSelect.addEventListener("change", () => {
switchRigFromSelect(headerRigSwitchSelect); void switchRigFromSelect(headerRigSwitchSelect);
}); });
} }
function setControlPending(control, pending) { function setControlPending(control, pending) {
@@ -5037,7 +5081,7 @@ if (centerFreqEl) {
centerFreqDirty = true; centerFreqDirty = true;
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
applyCenterFreqFromInput(); void applyCenterFreqFromInput();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
centerFreqDirty = false; centerFreqDirty = false;
refreshCenterFreqDisplay(); refreshCenterFreqDisplay();
@@ -5382,18 +5426,18 @@ if (spectrumBwInput) {
spectrumBwInput.addEventListener("keydown", (e) => { spectrumBwInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
applyBandwidthFromInput(); void applyBandwidthFromInput();
} }
}); });
} }
if (spectrumBwSetBtn) { if (spectrumBwSetBtn) {
spectrumBwSetBtn.addEventListener("click", () => { spectrumBwSetBtn.addEventListener("click", () => {
applyBandwidthFromInput(); void applyBandwidthFromInput();
}); });
} }
if (spectrumBwAutoBtn) { if (spectrumBwAutoBtn) {
spectrumBwAutoBtn.addEventListener("click", () => { spectrumBwAutoBtn.addEventListener("click", () => {
applyAutoBandwidth(); void applyAutoBandwidth();
}); });
} }
if (spectrumBwSweetBtn) { if (spectrumBwSweetBtn) {
@@ -5466,7 +5510,9 @@ function navigateToTab(name, options = {}) {
updateTabHistory(name, replaceHistory); updateTabHistory(name, replaceHistory);
} }
scheduleSpectrumLayout(); scheduleSpectrumLayout();
if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name); void loadPluginsForTab(name).catch((error) => {
console.error(error);
});
if (name === "map") { if (name === "map") {
_initMapWhenReady(); _initMapWhenReady();
} }
@@ -5474,7 +5520,7 @@ function navigateToTab(name, options = {}) {
window.trx.modules.map?.scheduleStatsRender(); window.trx.modules.map?.scheduleStatsRender();
} }
if (name === "recorder") { if (name === "recorder") {
refreshRecorderStatus(); void refreshRecorderStatus();
} }
} }
window.navigateToTab = navigateToTab; window.navigateToTab = navigateToTab;
@@ -5604,7 +5650,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
}); });
var guestBtn = document.getElementById("auth-guest-btn"); var guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) { if (guestBtn) {
guestBtn.addEventListener("click", async () => { guestBtn.addEventListener("click", () => {
authRole = "rx"; authRole = "rx";
requiredElement("auth-passphrase").value = ""; requiredElement("auth-passphrase").value = "";
hideAuthGate(); hideAuthGate();
@@ -5630,7 +5676,7 @@ if (headerAuthBtn) {
}); });
} }
var trxState = /* @__PURE__ */ Object.create(null); var trxState = /* @__PURE__ */ Object.create(null);
var trxModules = /* @__PURE__ */ Object.create(null); var trxModules = {};
Object.defineProperties(trxState, { Object.defineProperties(trxState, {
serverLat: { get() { serverLat: { get() {
return serverLat; return serverLat;
@@ -5804,8 +5850,10 @@ Object.defineProperties(trxState, {
} } } }
}); });
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules }); window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
if (typeof window.loadEagerPlugins === "function") window.loadEagerPlugins(); void loadEagerPlugins().catch((error) => {
initializeApp(); console.error(error);
});
void initializeApp();
window.addEventListener("resize", resizeHeaderSignalCanvas); window.addEventListener("resize", resizeHeaderSignalCanvas);
function bookmarkDistanceText(bm) { function bookmarkDistanceText(bm) {
if (!bm || serverLat == null || serverLon == null) return null; if (!bm || serverLat == null || serverLon == null) return null;
@@ -5830,7 +5878,7 @@ function buildBookmarkTooltipText(bm) {
} }
function nearestBookmarkForHz(hz, widthPx, range) { function nearestBookmarkForHz(hz, widthPx, range) {
const ref = window.trx.modules.bookmarks?.overlayList ?? null; const ref = window.trx.modules.bookmarks?.overlayList ?? null;
if (!Array.isArray(ref) || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) { if (!ref || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) {
return null; return null;
} }
const maxDeltaHz = Math.max(range.visSpanHz / widthPx * 6, 10); const maxDeltaHz = Math.max(range.visSpanHz / widthPx * 6, 10);
@@ -5951,7 +5999,6 @@ var wfmDeemphasisEl = document.getElementById("wfm-deemphasis");
var wfmAudioModeEl = document.getElementById("wfm-audio-mode"); var wfmAudioModeEl = document.getElementById("wfm-audio-mode");
var wfmDenoiseEl = document.getElementById("wfm-denoise"); var wfmDenoiseEl = document.getElementById("wfm-denoise");
var sdrSettingsRowEl = document.getElementById("sdr-settings-row"); var sdrSettingsRowEl = document.getElementById("sdr-settings-row");
var sdrGainControlsEl = document.getElementById("sdr-gain-controls");
var sdrGainEl = document.getElementById("sdr-gain-db"); var sdrGainEl = document.getElementById("sdr-gain-db");
var sdrGainSetBtn = document.getElementById("sdr-gain-set"); var sdrGainSetBtn = document.getElementById("sdr-gain-set");
var sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls"); var sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls");
@@ -6056,7 +6103,7 @@ function levelFromChannels(channels, frameCount) {
return Math.min(100, rms * 220); return Math.min(100, rms * 220);
} }
function normalizeWfmDenoiseLevel(value) { function normalizeWfmDenoiseLevel(value) {
const next = String(value ?? "").toLowerCase(); const next = primitiveString(value).toLowerCase();
if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next; if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
return "auto"; return "auto";
} }
@@ -6282,7 +6329,7 @@ function resetTxTimeout() {
if (txTimeoutTimer) clearTimeout(txTimeoutTimer); if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
txTimeoutTimer = setTimeout(() => { txTimeoutTimer = setTimeout(() => {
console.warn("PTT safety timeout — stopping TX"); console.warn("PTT safety timeout — stopping TX");
stopTxAudio(); void stopTxAudio();
}, TX_TIMEOUT_SECS * 1e3); }, TX_TIMEOUT_SECS * 1e3);
} }
function startTxTimeoutCountdown() { function startTxTimeoutCountdown() {
@@ -6441,7 +6488,9 @@ function startRxAudio() {
audioWs.onmessage = (evt) => { audioWs.onmessage = (evt) => {
if (typeof evt.data === "string") { if (typeof evt.data === "string") {
try { try {
configureRxStream(JSON.parse(evt.data)); const info = parseJsonUnknown(evt.data);
if (!isAudioStreamInfo(info)) throw new TypeError("Unexpected audio stream metadata");
configureRxStream(info);
} catch (e) { } catch (e) {
console.error("Audio stream info parse error", e); console.error("Audio stream info parse error", e);
} }
@@ -6518,7 +6567,7 @@ function startRxAudio() {
}; };
audioWs.onclose = () => { audioWs.onclose = () => {
if (txActive) { if (txActive) {
stopTxAudio(); void stopTxAudio();
} }
rxActive = false; rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" }); window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
@@ -6559,7 +6608,7 @@ function stopRxAudio() {
audioWs = null; audioWs = null;
} }
if (audioCtx) { if (audioCtx) {
audioCtx.close(); void audioCtx.close();
audioCtx = null; audioCtx = null;
} }
updateWfmControls(); updateWfmControls();
@@ -6587,7 +6636,7 @@ function stopRxAudio() {
} }
function startTxAudio() { function startTxAudio() {
if (txActive) { if (txActive) {
stopTxAudio(); void stopTxAudio();
return; return;
} }
if (!hasWebCodecs) { if (!hasWebCodecs) {
@@ -6780,12 +6829,15 @@ async function refreshRecorderStatus() {
fetch("/api/recorder/files") fetch("/api/recorder/files")
]); ]);
if (statusResp.ok) { if (statusResp.ok) {
const active = await statusResp.json(); const active = await responseJsonUnknown(statusResp);
renderRecorderActive(active); if (isRecorderActiveList(active)) renderRecorderActive(active);
} }
if (filesResp.ok) { if (filesResp.ok) {
_recorderFiles = await filesResp.json(); const files = await responseJsonUnknown(filesResp);
renderRecorderFiles(); if (isRecorderFileList(files)) {
_recorderFiles = files;
renderRecorderFiles();
}
} }
} catch (e) { } catch (e) {
console.error("Recorder status fetch failed", e); console.error("Recorder status fetch failed", e);
@@ -6923,10 +6975,8 @@ function renderRecorderFiles() {
row.parentNode?.insertBefore(playerRow, row.nextSibling); row.parentNode?.insertBefore(playerRow, row.nextSibling);
btn.setAttribute("aria-expanded", "true"); btn.setAttribute("aria-expanded", "true");
btn.textContent = "Hide"; btn.textContent = "Hide";
try { void audio.play().catch(() => {
audio.play(); });
} catch (_) {
}
}); });
}); });
el.querySelectorAll(".rec-delete-btn").forEach(function(btn) { el.querySelectorAll(".rec-delete-btn").forEach(function(btn) {
@@ -7011,7 +7061,6 @@ if (sdrSquelchEl) {
} }
requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear()); requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear());
var decodeSource = null; var decodeSource = null;
var decodeConnected = false;
var decodeHistoryWorker = null; var decodeHistoryWorker = null;
function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText) { function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText) {
if (!el) return; if (!el) return;
@@ -7215,9 +7264,10 @@ function connectDecode() {
decodeHistoryWorker = worker; decodeHistoryWorker = worker;
worker.onmessage = (evt) => { worker.onmessage = (evt) => {
if (historySettled || worker !== decodeHistoryWorker) return; if (historySettled || worker !== decodeHistoryWorker) return;
const data = evt?.data || {}; const data = evt.data;
if (!isRecord2(data) || typeof data.type !== "string") return;
if (data.type === "status") { if (data.type === "status") {
const phase = String(data.phase || ""); const phase = primitiveString(data.phase);
if (phase === "fetching") { if (phase === "fetching") {
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer"); setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
} else if (phase === "decoding") { } else if (phase === "decoding") {
@@ -7235,7 +7285,8 @@ function connectDecode() {
return; return;
} }
if (data.type === "group") { if (data.type === "group") {
enqueueDecodeHistoryGroup(String(data.kind || ""), data.messages); const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message) && typeof message.type === "string") : [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return; return;
} }
if (data.type === "done") { if (data.type === "done") {
@@ -7246,7 +7297,7 @@ function connectDecode() {
return; return;
} }
if (data.type === "error") { if (data.type === "error") {
console.error("Decode history worker failed", data.message || "unknown worker failure"); console.error("Decode history worker failed", typeof data.message === "string" ? data.message : "unknown worker failure");
terminateDecodeHistoryWorker(); terminateDecodeHistoryWorker();
startDecodeHistoryFallback(); startDecodeHistoryFallback();
} }
@@ -7268,21 +7319,21 @@ function connectDecode() {
decodeSource = new EventSource("/decode"); decodeSource = new EventSource("/decode");
const source = decodeSource; const source = decodeSource;
source.onopen = () => { source.onopen = () => {
decodeConnected = true;
updateDecodeStatus("Connected, listening for packets"); updateDecodeStatus("Connected, listening for packets");
}; };
source.onmessage = (evt) => { source.onmessage = (evt) => {
try { try {
const msg = JSON.parse(evt.data); const msg = parseJsonUnknown(evt.data);
if (historySettled) dispatchDecodeMessage(msg); if (!isRecord2(msg) || typeof msg.type !== "string") return;
else liveBuffer.push(msg); const decoded = { ...msg, type: msg.type };
if (historySettled) dispatchDecodeMessage(decoded);
else liveBuffer.push(decoded);
} catch (e) { } catch (e) {
} }
}; };
source.onerror = () => { source.onerror = () => {
const wasClosed = source.readyState === 2; const wasClosed = source.readyState === 2;
source.close(); source.close();
decodeConnected = false;
terminateDecodeHistoryWorker(); terminateDecodeHistoryWorker();
if (!historySettled) flushLiveBuffer(); if (!historySettled) flushLiveBuffer();
if (wasClosed) { if (wasClosed) {
@@ -7612,11 +7663,12 @@ function startSpectrumStreaming() {
}; };
source.addEventListener("b", (evt) => { source.addEventListener("b", (evt) => {
try { try {
const commaA = evt.data.indexOf(","); const eventData = messageEventData(evt);
const commaB = evt.data.indexOf(",", commaA + 1); const commaA = eventData.indexOf(",");
const centerHz = Number(evt.data.slice(0, commaA)); const commaB = eventData.indexOf(",", commaA + 1);
const sampleRate = Number(evt.data.slice(commaA + 1, commaB)); const centerHz = Number(eventData.slice(0, commaA));
const b64 = evt.data.slice(commaB + 1); const sampleRate = Number(eventData.slice(commaA + 1, commaB));
const b64 = eventData.slice(commaB + 1);
const hadSpectrum = !!lastSpectrumData; const hadSpectrum = !!lastSpectrumData;
const bins = decodeBase64Int8(b64); const bins = decodeBase64Int8(b64);
const rds = lastSpectrumData?.rds; const rds = lastSpectrumData?.rds;
@@ -7652,7 +7704,9 @@ function startSpectrumStreaming() {
}); });
source.addEventListener("rds", (evt) => { source.addEventListener("rds", (evt) => {
try { try {
const rds = evt.data === "null" ? void 0 : JSON.parse(evt.data); const eventData = messageEventData(evt);
const value = eventData === "null" ? void 0 : parseJsonUnknown(eventData);
const rds = value !== void 0 && isRdsData(value) ? value : null;
if (lastSpectrumData) lastSpectrumData.rds = rds; if (lastSpectrumData) lastSpectrumData.rds = rds;
updateRdsPsOverlay(rds ?? null); updateRdsPsOverlay(rds ?? null);
} catch (_) { } catch (_) {
@@ -7660,22 +7714,20 @@ function startSpectrumStreaming() {
}); });
source.addEventListener("rds_vchan", (evt) => { source.addEventListener("rds_vchan", (evt) => {
try { try {
const payload = evt.data === "null" ? [] : JSON.parse(evt.data); const eventData = messageEventData(evt);
const value = eventData === "null" ? [] : parseJsonUnknown(eventData);
const payload = Array.isArray(value) ? value.filter(isVchanRdsEntry) : [];
const next = /* @__PURE__ */ new Map(); const next = /* @__PURE__ */ new Map();
const nextSig = /* @__PURE__ */ new Map(); const nextSig = /* @__PURE__ */ new Map();
if (Array.isArray(payload)) { payload.forEach((entry) => {
payload.forEach((entry) => { next.set(entry.id, entry.rds ?? null);
if (entry && entry.id) { if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
next.set(entry.id, entry.rds ?? null); });
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
}
});
}
vchanRdsById = next; vchanRdsById = next;
vchanSignalDbById = nextSig; vchanSignalDbById = nextSig;
const virtualChannelId = window.trx.modules.vchan?.activeId; const virtualChannelId = window.trx.modules.vchan?.activeId;
if (virtualChannelId && nextSig.has(virtualChannelId)) { if (virtualChannelId && nextSig.has(virtualChannelId)) {
sigLastDbm = nextSig.get(virtualChannelId); sigLastDbm = nextSig.get(virtualChannelId) ?? null;
refreshSigStrengthDisplay(); refreshSigStrengthDisplay();
} }
updateRdsPsOverlay(primaryRds); updateRdsPsOverlay(primaryRds);
@@ -7758,8 +7810,8 @@ function startMeterStreaming() {
meterSource = new EventSource(url); meterSource = new EventSource(url);
meterSource.onmessage = (evt) => { meterSource.onmessage = (evt) => {
try { try {
const { sig } = JSON.parse(evt.data); const value = parseJsonUnknown(evt.data);
applyMeterSample(sig); if (isRecord2(value) && typeof value.sig === "number") applyMeterSample(value.sig);
} catch (_) { } catch (_) {
} }
}; };
@@ -7794,10 +7846,10 @@ function clearSpectrumCanvas() {
} }
} }
function formatOverlayPs(ps) { function formatOverlayPs(ps) {
return String(ps ?? "").slice(0, 8).padEnd(8, "_").replaceAll(" ", "_"); return primitiveString(ps).slice(0, 8).padEnd(8, "_").replaceAll(" ", "_");
} }
function formatPsHtml(ps) { function formatPsHtml(ps) {
const clipped = String(ps ?? "").slice(0, 8); const clipped = primitiveString(ps).slice(0, 8);
let html = ""; let html = "";
for (let i = 0; i < 8; i += 1) { for (let i = 0; i < 8; i += 1) {
const ch = clipped[i]; const ch = clipped[i];
@@ -7813,8 +7865,10 @@ function formatOverlayPi(pi) {
return pi != null ? `PI 0x${Number(pi).toString(16).toUpperCase().padStart(4, "0")}` : "PI --"; return pi != null ? `PI 0x${Number(pi).toString(16).toUpperCase().padStart(4, "0")}` : "PI --";
} }
function formatOverlayPty(pty, ptyName) { function formatOverlayPty(pty, ptyName) {
if (ptyName) return ptyName; const name = primitiveString(ptyName);
return pty != null ? String(pty) : "--"; if (name) return name;
const code = primitiveString(pty);
return code || "--";
} }
function overlayTrafficFlagHtml(label, active) { function overlayTrafficFlagHtml(label, active) {
const stateClass = active === true ? "rds-flag-active" : "rds-flag-inactive"; const stateClass = active === true ? "rds-flag-active" : "rds-flag-inactive";
@@ -7913,13 +7967,13 @@ async function copyRdsRawToClipboard() {
var rdsPsValueEl = document.getElementById("rds-ps"); var rdsPsValueEl = document.getElementById("rds-ps");
if (rdsPsValueEl) { if (rdsPsValueEl) {
rdsPsValueEl.addEventListener("click", () => { rdsPsValueEl.addEventListener("click", () => {
copyRdsPsToClipboard(); void copyRdsPsToClipboard();
}); });
} }
var rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn"); var rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn");
if (rdsRawCopyBtn) { if (rdsRawCopyBtn) {
rdsRawCopyBtn.addEventListener("click", () => { rdsRawCopyBtn.addEventListener("click", () => {
copyRdsRawToClipboard(); void copyRdsRawToClipboard();
}); });
} }
var rdsAfListEl = document.getElementById("rds-af-list"); var rdsAfListEl = document.getElementById("rds-af-list");
@@ -8202,7 +8256,7 @@ function drawSpectrumWaterfall() {
const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90); const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90);
const view = spectrumVisibleRange(lastSpectrumData); const view = spectrumVisibleRange(lastSpectrumData);
const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`; const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`;
const palKey = `swf|${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; const palKey = `swf|${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
const rowStride = iW * 4; const rowStride = iW * 4;
const expectedSize = iW * iH * 4; const expectedSize = iW * iH * 4;
const newPushes = spectrumWfPushCount - spectrumWfTexPushCount; const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
@@ -8329,7 +8383,6 @@ function bmCategoryColorMap() {
function createBookmarkChip(bm, colorMap, options = {}) { function createBookmarkChip(bm, colorMap, options = {}) {
const span = document.createElement("span"); const span = document.createElement("span");
const freqStr = window.trx.modules.bookmarks ? window.trx.modules.bookmarks.formatFrequency(bm.freq_hz) : bm.freq_hz + "Hz"; const freqStr = window.trx.modules.bookmarks ? window.trx.modules.bookmarks.formatFrequency(bm.freq_hz) : bm.freq_hz + "Hz";
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
span.className = "spectrum-bookmark-chip"; span.className = "spectrum-bookmark-chip";
if (options.sideStack) { if (options.sideStack) {
span.classList.add("spectrum-bookmark-chip-side"); span.classList.add("spectrum-bookmark-chip-side");
@@ -8374,8 +8427,7 @@ function updateBookmarkAxis(range) {
const leftSideEl = document.getElementById("spectrum-bookmark-side-left"); const leftSideEl = document.getElementById("spectrum-bookmark-side-left");
const rightSideEl = document.getElementById("spectrum-bookmark-side-right"); const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
if (!axisEl) return; if (!axisEl) return;
const _bmRef = window.trx.modules.bookmarks?.overlayList ?? null; const allBookmarks = window.trx.modules.bookmarks?.overlayList ?? [];
const allBookmarks = Array.isArray(_bmRef) ? _bmRef : [];
const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz); const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz);
const leftBookmarks = allBookmarks.filter((bm) => bm.freq_hz < range.visLoHz).sort((a, b) => b.freq_hz - a.freq_hz).slice(0, 3); const leftBookmarks = allBookmarks.filter((bm) => bm.freq_hz < range.visLoHz).sort((a, b) => b.freq_hz - a.freq_hz).slice(0, 3);
const rightBookmarks = allBookmarks.filter((bm) => bm.freq_hz > range.visHiHz).sort((a, b) => a.freq_hz - b.freq_hz).slice(0, 3); const rightBookmarks = allBookmarks.filter((bm) => bm.freq_hz > range.visHiHz).sort((a, b) => a.freq_hz - b.freq_hz).slice(0, 3);
@@ -9106,11 +9158,12 @@ var bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
var bandplanRegionSelect = document.getElementById("bandplan-region-select"); var bandplanRegionSelect = document.getElementById("bandplan-region-select");
var bandplanLabelsCheck = document.getElementById("bandplan-labels-check"); var bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
(function loadBandplanJson() { (function loadBandplanJson() {
fetch("/bandplan.json").then((r) => { fetch("/bandplan.json").then(async (response) => {
if (!r.ok) throw new Error(String(r.status)); if (!response.ok) throw new Error(String(response.status));
return r.json(); return await responseJsonUnknown(response);
}).then((d) => { }).then((data) => {
bandplanData = d; if (!isRecord2(data)) return;
bandplanData = data;
bandplanSegmentsCache = null; bandplanSegmentsCache = null;
bandplanCacheKey = ""; bandplanCacheKey = "";
}).catch(() => { }).catch(() => {
@@ -36,35 +36,18 @@ export default tseslint.config(
rules: { rules: {
"no-undef": "off", "no-undef": "off",
"@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
caughtErrors: "none",
varsIgnorePattern: "^_",
}],
// DOM event targets intentionally ignore listener return values. Async
// listeners handle their own failures; keep the rule for all other
// promise misuse sites.
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
}, },
}, },
{
// app.ts is the final migrated monolith. The compiler checks it with the
// full strict baseline; rules that require runtime schemas or broad async
// rewrites stay disabled here until those responsibilities are extracted.
files: ["src/app.ts"],
rules: {
"prefer-const": "off",
"@typescript-eslint/no-base-to-string": "off",
"@typescript-eslint/no-duplicate-type-constituents": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/no-unnecessary-condition": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-unnecessary-type-conversion": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/unbound-method": "off",
},
},
{ {
files: ["src/decode-history-worker.ts"], files: ["src/decode-history-worker.ts"],
languageOptions: { languageOptions: {
@@ -44,9 +44,10 @@ import {
} from "./features/spectrum/math.js"; } from "./features/spectrum/math.js";
import type { NumericBins } from "./features/spectrum/math.js"; import type { NumericBins } from "./features/spectrum/math.js";
import type { AuthRole } from "./api/auth.js"; import type { AuthRole } from "./api/auth.js";
import type { RdsData, RigCapabilities, RigListItem, RigSnapshot } from "./api/generated.js"; import type { RdsData, RigCapabilities, RigListItem, RigSnapshot, VchanRdsEntry } from "./api/generated.js";
import type { TrxPluginRuntime } from "./plugins/runtime-contract.js"; import type { TrxPluginRuntime } from "./plugins/runtime-contract.js";
import { loadEagerPlugins, loadPluginsForTab } from "./plugin-loader.js"; import { loadEagerPlugins, loadPluginsForTab } from "./plugin-loader.js";
import { isRigListResponse, isRigSnapshot } from "./api/client.js";
interface SpectrumFrame { interface SpectrumFrame {
bins: NumericBins; bins: NumericBins;
@@ -224,6 +225,47 @@ interface TrxModules {
}; };
screenshot?: { captureSpectrumScreenshot(): Promise<boolean> }; screenshot?: { captureSpectrumScreenshot(): Promise<boolean> };
} }
interface TrxState {
serverLat: number | null;
serverLon: number | null;
readonly lastFreqHz: number | null;
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>;
readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number;
readonly authRole: AuthRole | null;
readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null;
readonly primaryRds: RdsData | null;
readonly vchanRdsById: Map<string, RdsData | null>;
readonly vchanSignalDbById: Map<string, number>;
lastCityLabel: string;
readonly serverVersion: string | null;
readonly serverBuildDate: string | null;
readonly serverCallsign: string | null;
readonly ownerCallsign: string | null;
readonly ownerWebsiteUrl: string | null;
readonly ownerWebsiteName: string | null;
readonly aisVesselUrlBase: string | null;
readonly serverRigs: RigListItem[];
readonly serverActiveRigId: string | null;
readonly lastModeName: string;
readonly lastSpectrumData: SpectrumFrame | null;
readonly lastSpectrumRenderData: SpectrumFrame | null;
currentBandwidthHz: number;
readonly spectrumFloor: number;
readonly spectrumRange: number;
readonly spectrumCanvas: HTMLCanvasElement;
readonly overviewCanvas: HTMLCanvasElement;
readonly overviewGl: TrxWebGlRenderer;
readonly spectrumGl: TrxWebGlRenderer;
readonly signalOverlayGl: TrxWebGlRenderer;
readonly decodeHistoryReplayActive: boolean;
readonly decodeMapSyncPending: boolean;
readonly _activeTab: TabName;
readonly locationSubtitle: HTMLElement | null;
}
interface TrxUi { interface TrxUi {
confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>; confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>;
notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement; notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement;
@@ -250,9 +292,67 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value); return typeof value === "number" && Number.isFinite(value);
} }
function primitiveString(value: unknown): string {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? String(value)
: "";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseJsonUnknown(source: string): unknown {
return JSON.parse(source) as unknown;
}
function messageEventData(event: Event): string {
const data: unknown = (event as MessageEvent<unknown>).data;
if (typeof data !== "string") throw new TypeError("Expected a text event payload");
return data;
}
async function responseJsonUnknown(response: Response): Promise<unknown> {
return await response.json() as unknown;
}
function isAppUpdate(value: unknown): value is AppUpdate {
return isRecord(value) && (value.status === undefined || isRecord(value.status));
}
function isAudioStreamInfo(value: unknown): value is AudioStreamInfo {
return isRecord(value)
&& typeof value.sample_rate === "number"
&& typeof value.channels === "number";
}
function isRecorderActiveList(value: unknown): value is RecorderActive[] {
return Array.isArray(value) && value.every((entry) => isRecord(entry)
&& typeof entry.rig_id === "string"
&& typeof entry.path === "string"
&& typeof entry.started_at === "number");
}
function isRecorderFileList(value: unknown): value is RecorderFile[] {
return Array.isArray(value) && value.every((entry) => isRecord(entry)
&& typeof entry.name === "string"
&& typeof entry.size === "number");
}
function isRdsData(value: unknown): value is RdsData {
return isRecord(value);
}
function isVchanRdsEntry(value: unknown): value is VchanRdsEntry {
return isRecord(value)
&& typeof value.id === "string"
&& (value.rds === undefined || value.rds === null || isRdsData(value.rds))
&& (value.signal_db === undefined || value.signal_db === null || typeof value.signal_db === "number");
}
declare global { declare global {
interface Window { interface Window {
trx: { state: Record<string, unknown>; core: Record<string, unknown>; modules: TrxModules }; trx: { state: TrxState; core: Readonly<Record<string, unknown>>; modules: TrxModules };
trxUi: TrxUi; trxUi: TrxUi;
trxPluginRuntime: TrxPluginRuntime; trxPluginRuntime: TrxPluginRuntime;
lastSpectrumData: SpectrumFrame | null; lastSpectrumData: SpectrumFrame | null;
@@ -269,8 +369,6 @@ declare global {
navigateToTab(name: TabName, options?: { updateHistory?: boolean; replaceHistory?: boolean }): void; navigateToTab(name: TabName, options?: { updateHistory?: boolean; replaceHistory?: boolean }): void;
_syncRecorderState(enabled: boolean): void; _syncRecorderState(enabled: boolean): void;
refreshRdsUi(): void; refreshRdsUi(): void;
loadEagerPlugins?(): Promise<void>;
loadPluginsForTab?(tab: string): Promise<void>;
refreshCwTonePicker?(): void; refreshCwTonePicker?(): void;
updateFt8RfDisplay?(): void; updateFt8RfDisplay?(): void;
clearSatPredictionDom?(): void; clearSatPredictionDom?(): void;
@@ -430,7 +528,6 @@ function applyAuthRestrictions() {
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null; const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null; const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row"); const txLimitRow = document.getElementById("tx-limit-row");
const vfoPicker = document.getElementById("vfo-picker");
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null; const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null; const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button"); const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
@@ -526,7 +623,7 @@ function applyCapabilities(caps: RigCapabilities | null) {
if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none"; if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none";
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
if (!caps.tx && typeof stopTxAudio === "function" && txActive) { if (!caps.tx && typeof stopTxAudio === "function" && txActive) {
stopTxAudio(); void stopTxAudio();
} }
// TX limit row // TX limit row
@@ -814,11 +911,10 @@ function decodeHistoryMapRenderingDeferred() {
let lastSpectrumData: SpectrumFrame | null = null; let lastSpectrumData: SpectrumFrame | null = null;
window.lastSpectrumData = null; window.lastSpectrumData = null;
let lastControl;
let lastTxEn: boolean | null = null; let lastTxEn: boolean | null = null;
let lastHasTx = true; let lastHasTx = true;
let lastRendered: string | null = null; let lastRendered: string | null = null;
let prevRenderData: Record<string, unknown> = {}; const prevRenderData: Record<string, unknown> = {};
let hintTimer: ReturnType<typeof setTimeout> | null = null; let hintTimer: ReturnType<typeof setTimeout> | null = null;
let sigMeasuring = false; let sigMeasuring = false;
let sigLastSUnits: number | null = null; let sigLastSUnits: number | null = null;
@@ -941,7 +1037,7 @@ function updateDocumentTitle(rds: RdsData | null = null) {
document.title = originalTitle; document.title = originalTitle;
return; return;
} }
const parts = [formatFreq(freqHz as number)]; const parts = [formatFreq(freqHz)];
const ps = rds?.program_service; const ps = rds?.program_service;
if (ps && ps.length > 0) { if (ps && ps.length > 0) {
parts.push(ps); parts.push(ps);
@@ -1327,7 +1423,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker(); window.trx.modules.bookmarks?.populateScopePicker();
window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || ""); void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
} }
window.trx.modules.map?.updateMapRigFilter(); window.trx.modules.map?.updateMapRigFilter();
} }
@@ -1337,8 +1433,9 @@ async function refreshRigList() {
try { try {
const resp = await fetch("/rigs", { cache: "no-store" }); const resp = await fetch("/rigs", { cache: "no-store" });
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await responseJsonUnknown(resp);
const rigs = Array.isArray(data.rigs) ? data.rigs : []; if (!isRigListResponse(data)) return;
const rigs = data.rigs;
const rigIds = rigs.map((r: RigListItem) => r.remote).filter(Boolean); const rigIds = rigs.map((r: RigListItem) => r.remote).filter(Boolean);
const displayNames: Record<string, string> = {}; const displayNames: Record<string, string> = {};
rigs.forEach((r: RigListItem) => { rigs.forEach((r: RigListItem) => {
@@ -1354,7 +1451,7 @@ async function refreshRigList() {
}); });
serverRigs = rigs; serverRigs = rigs;
refreshOperatorLayoutCapabilities(); refreshOperatorLayoutCapabilities();
serverActiveRigId = data.active_remote || null; serverActiveRigId = data.active_remote;
applyRigList(data.active_remote, rigIds, displayNames); applyRigList(data.active_remote, rigIds, displayNames);
window.trx.modules.map?.syncAprsReceiverMarker(); window.trx.modules.map?.syncAprsReceiverMarker();
} catch (e) { } catch (e) {
@@ -1399,7 +1496,7 @@ setInterval(() => {
if (el) el.textContent = formatUptime(Date.now() - aboutUptimeStart); if (el) el.textContent = formatUptime(Date.now() - aboutUptimeStart);
}, 1000); }, 1000);
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let overviewSignalSamples: SignalSample[] = []; const overviewSignalSamples: SignalSample[] = [];
let overviewSignalTimer: ReturnType<typeof setInterval> | null = null; let overviewSignalTimer: ReturnType<typeof setInterval> | null = null;
let overviewWaterfallRows: NumericBins[] = []; let overviewWaterfallRows: NumericBins[] = [];
let overviewWaterfallPushCount = 0; // monotonically increments on every push let overviewWaterfallPushCount = 0; // monotonically increments on every push
@@ -1413,8 +1510,9 @@ let overviewWfTexPalKey = "";
let overviewWfTexReady = false; let overviewWfTexReady = false;
function cssColorToRgba(color: string, alphaMul = 1): Rgba { function cssColorToRgba(color: string, alphaMul = 1): Rgba {
const parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null; const parsed = typeof window.trxParseCssColor === "function"
const parsed = parser ? parser(color) : [0, 0, 0, 1]; ? window.trxParseCssColor(color)
: [0, 0, 0, 1];
return [ return [
parsed[0] ?? 0, parsed[0] ?? 0,
parsed[1] ?? 0, parsed[1] ?? 0,
@@ -1447,7 +1545,7 @@ function overviewWfResetTextureCache() {
} }
function overviewWfPaletteKey(pal: CanvasPalette, viewKey = "") { function overviewWfPaletteKey(pal: CanvasPalette, viewKey = "") {
return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; return `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
} }
function resizeHeaderSignalCanvas() { function resizeHeaderSignalCanvas() {
@@ -1526,9 +1624,9 @@ function drawSignalOverlay() {
const bwStroke = BW_OVERLAY_COLORS.stroke; const bwStroke = BW_OVERLAY_COLORS.stroke;
const bwHard = BW_OVERLAY_COLORS.hard; const bwHard = BW_OVERLAY_COLORS.hard;
const bmRef = window.trx.modules.bookmarks?.overlayList ?? null; const bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
if (Array.isArray(bmRef) && bmRef.length > 0) { if (bmRef && bmRef.length > 0) {
const colorMap = bmCategoryColorMap(); const colorMap = bmCategoryColorMap();
const grouped = new Map(); const grouped = new Map<string, number[]>();
for (const bm of bmRef) { for (const bm of bmRef) {
const f = Number(bm?.freq_hz); const f = Number(bm?.freq_hz);
if (!isFiniteNumber(f) || f < range.visLoHz || f > range.visHiHz) continue; if (!isFiniteNumber(f) || f < range.visLoHz || f > range.visHiHz) continue;
@@ -1536,8 +1634,9 @@ function drawSignalOverlay() {
const x = hzToX(f); const x = hzToX(f);
if (!isFiniteNumber(x) || x < 0 || x > W) continue; if (!isFiniteNumber(x) || x < 0 || x > W) continue;
const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK; const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK;
if (!grouped.has(color)) grouped.set(color, []); const segments = grouped.get(color) ?? [];
grouped.get(color).push(x, 0, x, H); segments.push(x, 0, x, H);
grouped.set(color, segments);
} }
for (const [color, segments] of grouped.entries()) { for (const [color, segments] of grouped.entries()) {
if (!Array.isArray(segments) || segments.length === 0) continue; if (!Array.isArray(segments) || segments.length === 0) continue;
@@ -1856,7 +1955,7 @@ let _wfLutKey = "";
const _wfLut = new Uint8Array(256 * 4); // [r,g,b,a] × 256 entries, 0-255 range const _wfLut = new Uint8Array(256 * 4); // [r,g,b,a] × 256 entries, 0-255 range
function ensureWaterfallLut(pal: CanvasPalette, minDb: number, maxDb: number) { function ensureWaterfallLut(pal: CanvasPalette, minDb: number, maxDb: number) {
const key = `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${minDb}|${maxDb}|${waterfallGamma}`; const key = `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${minDb}|${maxDb}|${waterfallGamma}`;
if (key === _wfLutKey) return; if (key === _wfLutKey) return;
_wfLutKey = key; _wfLutKey = key;
for (let i = 0; i < 256; i++) { for (let i = 0; i < 256; i++) {
@@ -2000,7 +2099,7 @@ function renderRdsOverlays() {
el.innerHTML = html; el.innerHTML = html;
el.addEventListener("click", (evt) => { el.addEventListener("click", (evt) => {
evt.stopPropagation(); evt.stopPropagation();
copyRdsPsToClipboard(entry.rds, entry.freq_hz); void copyRdsPsToClipboard(entry.rds, entry.freq_hz);
}); });
el.addEventListener("mouseenter", () => { el.addEventListener("mouseenter", () => {
el.style.zIndex = String(entries.length + 10); el.style.zIndex = String(entries.length + 10);
@@ -2211,14 +2310,6 @@ function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") {
return Math.max(0, isFiniteNumber(maxBw) ? maxBw : currentBandwidthHz); return Math.max(0, isFiniteNumber(maxBw) ? maxBw : currentBandwidthHz);
} }
function isAisMode(mode = modeEl ? modeEl.value : "") {
return String(mode || "").toUpperCase() === "AIS";
}
function isVdesMode(mode = modeEl ? modeEl.value : "") {
return String(mode || "").toUpperCase() === "VDES";
}
function visibleBandwidthSpecs(freqHz: number | null = lastFreqHz, mode = modeEl ? modeEl.value : ""): BandwidthSpec[] { function visibleBandwidthSpecs(freqHz: number | null = lastFreqHz, mode = modeEl ? modeEl.value : ""): BandwidthSpec[] {
if (!isFiniteNumber(freqHz)) return []; if (!isFiniteNumber(freqHz)) return [];
const modeUpper = String(mode || "").toUpperCase(); const modeUpper = String(mode || "").toUpperCase();
@@ -2273,10 +2364,6 @@ function coverageSpanForMode(freqHz: number | null, bandwidthHz = coverageGuardB
return { loHz, hiHz }; return { loHz, hiHz };
} }
function visibleBandwidthCenters(freqHz: number | null = lastFreqHz, mode = modeEl ? modeEl.value : "") {
return visibleBandwidthSpecs(freqHz, mode).map((spec) => spec.centerHz);
}
function effectiveSpectrumCoverageSpanHz(sampleRateHz: number) { function effectiveSpectrumCoverageSpanHz(sampleRateHz: number) {
const sampleRate = Number(sampleRateHz); const sampleRate = Number(sampleRateHz);
if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return 0; if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return 0;
@@ -2785,7 +2872,7 @@ function updateJogStepSupport(cap: RigCapabilities | null) {
function normalizeMode(modeVal: unknown): string { function normalizeMode(modeVal: unknown): string {
if (typeof modeVal === "string") return modeVal; if (typeof modeVal === "string") return modeVal;
if (modeVal && typeof modeVal === "object") { if (isRecord(modeVal)) {
const entries = Object.entries(modeVal); const entries = Object.entries(modeVal);
if (entries.length > 0) { if (entries.length > 0) {
const firstEntry = entries[0]; const firstEntry = entries[0];
@@ -3402,7 +3489,7 @@ function render(update: AppUpdate) {
// When SDR backend is active (spectrum visible), apply BW default for new // When SDR backend is active (spectrum visible), apply BW default for new
// mode — but only if the server hasn't already pushed a filter_state. // mode — but only if the server hasn't already pushed a filter_state.
if (lastSpectrumData && !update.filter) { if (lastSpectrumData && !update.filter) {
applyBwDefaultForMode(mode, false); void applyBwDefaultForMode(mode, false);
} }
} }
updateWfmControls(); updateWfmControls();
@@ -3559,7 +3646,6 @@ function render(update: AppUpdate) {
powerBtn.setAttribute("aria-pressed", "false"); powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown"; powerHint.textContent = "State unknown";
} }
lastControl = update.enabled;
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") { if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit); txLimitInput.value = String(update.status.tx.limit);
@@ -3713,9 +3799,10 @@ async function pollFreshSnapshot() {
: "/status"; : "/status";
const resp = await fetch(statusUrl, { cache: "no-store" }); const resp = await fetch(statusUrl, { cache: "no-store" });
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await responseJsonUnknown(resp);
if (!isRigSnapshot(data)) return;
render(data); render(data);
refreshRigList(); void refreshRigList();
lastEventAt = Date.now(); lastEventAt = Date.now();
} catch (e) { } catch (e) {
// Ignore network errors; connect() retry loop handles reconnection. // Ignore network errors; connect() retry loop handles reconnection.
@@ -3732,7 +3819,7 @@ function connect() {
} }
stopMeterStreaming(); stopMeterStreaming();
startMeterStreaming(); startMeterStreaming();
pollFreshSnapshot(); void pollFreshSnapshot();
const eventsUrl = lastActiveRigId const eventsUrl = lastActiveRigId
? `/events?remote=${encodeURIComponent(lastActiveRigId)}` ? `/events?remote=${encodeURIComponent(lastActiveRigId)}`
: "/events"; : "/events";
@@ -3743,13 +3830,14 @@ function connect() {
setConnLostOverlay(false); setConnLostOverlay(false);
if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (!aboutUptimeStart) aboutUptimeStart = Date.now(); if (!aboutUptimeStart) aboutUptimeStart = Date.now();
pollFreshSnapshot(); void pollFreshSnapshot();
refreshRigList(); void refreshRigList();
}; };
source.onmessage = (evt) => { source.onmessage = (evt: MessageEvent<string>) => {
try { try {
if (evt.data === lastRendered) return; if (evt.data === lastRendered) return;
const data = JSON.parse(evt.data); const data = parseJsonUnknown(evt.data);
if (!isAppUpdate(data)) throw new TypeError("Unexpected status event shape");
lastRendered = evt.data; lastRendered = evt.data;
render(data); render(data);
lastEventAt = Date.now(); lastEventAt = Date.now();
@@ -3769,13 +3857,14 @@ function connect() {
}); });
source.addEventListener("session", evt => { source.addEventListener("session", evt => {
try { try {
const d = JSON.parse(evt.data); const eventData = messageEventData(evt);
sseSessionId = d.session_id || null; const d = parseJsonUnknown(eventData);
sseSessionId = isRecord(d) && typeof d.session_id === "string" ? d.session_id : null;
} catch (_) {} } catch (_) {}
window.trx.modules.vchan?.handleSession(evt.data); window.trx.modules.vchan?.handleSession(messageEventData(evt));
}); });
source.addEventListener("channels", evt => { source.addEventListener("channels", evt => {
window.trx.modules.vchan?.handleChannels(evt.data); window.trx.modules.vchan?.handleChannels(messageEventData(evt));
}); });
source.onerror = () => { source.onerror = () => {
// Check if this is an auth error by looking at readyState // Check if this is an auth error by looking at readyState
@@ -3783,7 +3872,7 @@ function connect() {
powerHint.textContent = "trx-client connection lost, retrying\u2026"; powerHint.textContent = "trx-client connection lost, retrying\u2026";
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true); setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close(); source.close();
pollFreshSnapshot(); void pollFreshSnapshot();
scheduleReconnect(1000); scheduleReconnect(1000);
} }
}; };
@@ -3794,7 +3883,7 @@ function connect() {
powerHint.textContent = "trx-client connection lost, retrying\u2026"; powerHint.textContent = "trx-client connection lost, retrying\u2026";
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true); setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close(); source.close();
pollFreshSnapshot(); void pollFreshSnapshot();
scheduleReconnect(250); scheduleReconnect(250);
} }
}, 5000); }, 5000);
@@ -3827,12 +3916,8 @@ function disconnect() {
// Yield the main thread so the browser can paint before heavy async work. // Yield the main thread so the browser can paint before heavy async work.
// Uses scheduler.yield() (Chrome 115+) with a setTimeout fallback. // Uses scheduler.yield() (Chrome 115+) with a setTimeout fallback.
function yieldToMain() { const uiFrameJobs = new Map<string, () => void>();
return new Promise((resolve) => setTimeout(resolve, 0)); let uiFrameJobsHandle: ReturnType<typeof setTimeout> | null = null;
}
const uiFrameJobs = new Map();
let uiFrameJobsHandle: ReturnType<typeof setTimeout> | number | null = null;
function flushUiFrameJobs() { function flushUiFrameJobs() {
uiFrameJobsHandle = null; uiFrameJobsHandle = null;
@@ -3929,7 +4014,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || ""); void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
window.trx.modules.map?.syncAprsReceiverMarker(); window.trx.modules.map?.syncAprsReceiverMarker();
connect(); connect();
stopSpectrumStreaming(); stopSpectrumStreaming();
@@ -3954,7 +4039,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
} }
if (headerRigSwitchSelect) { if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); }); headerRigSwitchSelect.addEventListener("change", () => { void switchRigFromSelect(headerRigSwitchSelect); });
} }
function setControlPending(control: HTMLButtonElement | HTMLInputElement | HTMLSelectElement | null, pending: boolean) { function setControlPending(control: HTMLButtonElement | HTMLInputElement | HTMLSelectElement | null, pending: boolean) {
@@ -4057,7 +4142,7 @@ if (centerFreqEl) {
centerFreqDirty = true; centerFreqDirty = true;
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
applyCenterFreqFromInput(); void applyCenterFreqFromInput();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
centerFreqDirty = false; centerFreqDirty = false;
refreshCenterFreqDisplay(); refreshCenterFreqDisplay();
@@ -4443,15 +4528,15 @@ if (spectrumBwInput) {
spectrumBwInput.addEventListener("keydown", (e) => { spectrumBwInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
applyBandwidthFromInput(); void applyBandwidthFromInput();
} }
}); });
} }
if (spectrumBwSetBtn) { if (spectrumBwSetBtn) {
spectrumBwSetBtn.addEventListener("click", () => { applyBandwidthFromInput(); }); spectrumBwSetBtn.addEventListener("click", () => { void applyBandwidthFromInput(); });
} }
if (spectrumBwAutoBtn) { if (spectrumBwAutoBtn) {
spectrumBwAutoBtn.addEventListener("click", () => { applyAutoBandwidth(); }); spectrumBwAutoBtn.addEventListener("click", () => { void applyAutoBandwidth(); });
} }
if (spectrumBwSweetBtn) { if (spectrumBwSweetBtn) {
spectrumBwSweetBtn.addEventListener("click", () => { applySweetSpotCenter().catch(() => {}); }); spectrumBwSweetBtn.addEventListener("click", () => { applySweetSpotCenter().catch(() => {}); });
@@ -4539,7 +4624,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
window.trx.modules.map?.scheduleStatsRender(); window.trx.modules.map?.scheduleStatsRender();
} }
if (name === "recorder") { if (name === "recorder") {
refreshRecorderStatus(); void refreshRecorderStatus();
} }
} }
window.navigateToTab = navigateToTab; window.navigateToTab = navigateToTab;
@@ -4604,10 +4689,6 @@ window.addEventListener("popstate", () => {
window.addEventListener("resize", () => { scheduleSpectrumLayout(); }); window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence --- // --- Auth startup sequence ---
function getAvailableRigIds() {
return lastRigIds || [];
}
async function initializeApp() { async function initializeApp() {
showAuthGate(false); showAuthGate(false);
const authStatus = await checkAuthStatus(); const authStatus = await checkAuthStatus();
@@ -4687,7 +4768,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
// Setup guest button // Setup guest button
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null; const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
if (guestBtn) { if (guestBtn) {
guestBtn.addEventListener("click", async () => { guestBtn.addEventListener("click", () => {
authRole = "rx"; authRole = "rx";
requiredElement<HTMLInputElement>("auth-passphrase").value = ""; requiredElement<HTMLInputElement>("auth-passphrase").value = "";
hideAuthGate(); hideAuthGate();
@@ -4721,12 +4802,12 @@ if (headerAuthBtn) {
// Modules (map-core.js, screenshot.js) access core state and utilities via // Modules (map-core.js, screenshot.js) access core state and utilities via
// window.trx. Modules register their own APIs as sub-namespaces // window.trx. Modules register their own APIs as sub-namespaces
// (e.g. window.trx.modules.map, window.trx.modules.screenshot). // (e.g. window.trx.modules.map, window.trx.modules.screenshot).
const trxState = Object.create(null); const trxState = Object.create(null) as TrxState;
const trxModules = Object.create(null); const trxModules: TrxModules = {};
// -- State getters (backed by core-scoped variables) -- // -- State getters (backed by core-scoped variables) --
Object.defineProperties(trxState, { Object.defineProperties(trxState, {
serverLat: { get() { return serverLat; }, set(v) { serverLat = v; } }, serverLat: { get() { return serverLat; }, set(v: number | null) { serverLat = v; } },
serverLon: { get() { return serverLon; }, set(v) { serverLon = v; } }, serverLon: { get() { return serverLon; }, set(v: number | null) { serverLon = v; } },
lastFreqHz: { get() { return lastFreqHz; } }, lastFreqHz: { get() { return lastFreqHz; } },
lastActiveRigId: { get() { return lastActiveRigId; } }, lastActiveRigId: { get() { return lastActiveRigId; } },
lastRigIds: { get() { return lastRigIds; } }, lastRigIds: { get() { return lastRigIds; } },
@@ -4739,7 +4820,7 @@ Object.defineProperties(trxState, {
primaryRds: { get() { return primaryRds; } }, primaryRds: { get() { return primaryRds; } },
vchanRdsById: { get() { return vchanRdsById; } }, vchanRdsById: { get() { return vchanRdsById; } },
vchanSignalDbById: { get() { return vchanSignalDbById; } }, vchanSignalDbById: { get() { return vchanSignalDbById; } },
lastCityLabel: { get() { return lastCityLabel; }, set(v) { lastCityLabel = v; } }, lastCityLabel: { get() { return lastCityLabel; }, set(v: string) { lastCityLabel = v; } },
serverVersion: { get() { return serverVersion; } }, serverVersion: { get() { return serverVersion; } },
serverBuildDate: { get() { return serverBuildDate; } }, serverBuildDate: { get() { return serverBuildDate; } },
serverCallsign: { get() { return serverCallsign; } }, serverCallsign: { get() { return serverCallsign; } },
@@ -4752,7 +4833,7 @@ Object.defineProperties(trxState, {
lastModeName: { get() { return lastModeName; } }, lastModeName: { get() { return lastModeName; } },
lastSpectrumData: { get() { return lastSpectrumData; } }, lastSpectrumData: { get() { return lastSpectrumData; } },
lastSpectrumRenderData: { get() { return lastSpectrumRenderData; } }, lastSpectrumRenderData: { get() { return lastSpectrumRenderData; } },
currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v) { currentBandwidthHz = v; window.currentBandwidthHz = v; } }, currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v: number) { currentBandwidthHz = v; window.currentBandwidthHz = v; } },
spectrumFloor: { get() { return spectrumFloor; } }, spectrumFloor: { get() { return spectrumFloor; } },
spectrumRange: { get() { return spectrumRange; } }, spectrumRange: { get() { return spectrumRange; } },
spectrumCanvas: { get() { return spectrumCanvas; } }, spectrumCanvas: { get() { return spectrumCanvas; } },
@@ -4791,7 +4872,7 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules
void loadEagerPlugins().catch((error: unknown) => { console.error(error); }); void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
// Start the app // Start the app
initializeApp(); void initializeApp();
window.addEventListener("resize", resizeHeaderSignalCanvas); window.addEventListener("resize", resizeHeaderSignalCanvas);
@@ -4826,11 +4907,11 @@ function buildBookmarkTooltipText(bm: Bookmark) {
function nearestBookmarkForHz(hz: number, widthPx: number, range: SpectrumRange) { function nearestBookmarkForHz(hz: number, widthPx: number, range: SpectrumRange) {
const ref = window.trx.modules.bookmarks?.overlayList ?? null; const ref = window.trx.modules.bookmarks?.overlayList ?? null;
if (!Array.isArray(ref) || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) { if (!ref || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) {
return null; return null;
} }
const maxDeltaHz = Math.max((range.visSpanHz / widthPx) * 6, 10); const maxDeltaHz = Math.max((range.visSpanHz / widthPx) * 6, 10);
let best = null; let best: Bookmark | null = null;
let bestDelta = Number.POSITIVE_INFINITY; let bestDelta = Number.POSITIVE_INFINITY;
for (const bm of ref) { for (const bm of ref) {
const delta = Math.abs(Number(bm.freq_hz) - hz); const delta = Math.abs(Number(bm.freq_hz) - hz);
@@ -4961,7 +5042,6 @@ const wfmDeemphasisEl = document.getElementById("wfm-deemphasis") as HTMLSelectE
const wfmAudioModeEl = document.getElementById("wfm-audio-mode") as HTMLSelectElement | null; const wfmAudioModeEl = document.getElementById("wfm-audio-mode") as HTMLSelectElement | null;
const wfmDenoiseEl = document.getElementById("wfm-denoise") as HTMLSelectElement | null; const wfmDenoiseEl = document.getElementById("wfm-denoise") as HTMLSelectElement | null;
const sdrSettingsRowEl = document.getElementById("sdr-settings-row"); const sdrSettingsRowEl = document.getElementById("sdr-settings-row");
const sdrGainControlsEl = document.getElementById("sdr-gain-controls");
const sdrGainEl = document.getElementById("sdr-gain-db") as HTMLInputElement | null; const sdrGainEl = document.getElementById("sdr-gain-db") as HTMLInputElement | null;
const sdrGainSetBtn = document.getElementById("sdr-gain-set") as HTMLButtonElement | null; const sdrGainSetBtn = document.getElementById("sdr-gain-set") as HTMLButtonElement | null;
const sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls"); const sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls");
@@ -5076,7 +5156,7 @@ function levelFromChannels(channels: Float32Array[], frameCount: number) {
} }
function normalizeWfmDenoiseLevel(value: unknown) { function normalizeWfmDenoiseLevel(value: unknown) {
const next = String(value ?? "").toLowerCase(); const next = primitiveString(value).toLowerCase();
if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next; if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
return "auto"; return "auto";
} }
@@ -5306,7 +5386,7 @@ function resetTxTimeout() {
if (txTimeoutTimer) clearTimeout(txTimeoutTimer); if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
txTimeoutTimer = setTimeout(() => { txTimeoutTimer = setTimeout(() => {
console.warn("PTT safety timeout — stopping TX"); console.warn("PTT safety timeout — stopping TX");
stopTxAudio(); void stopTxAudio();
}, TX_TIMEOUT_SECS * 1000); }, TX_TIMEOUT_SECS * 1000);
} }
@@ -5388,7 +5468,7 @@ function extractAudioFrameChannels(frame: AudioData) {
} }
// Optional channel_id injected by vchan.js when connecting to a virtual channel. // Optional channel_id injected by vchan.js when connecting to a virtual channel.
let _audioChannelOverride: string | null = null; const _audioChannelOverride: string | null = null;
/** Schedule decoded PCM channels for playback via Web Audio API. */ /** Schedule decoded PCM channels for playback via Web Audio API. */
function scheduleDecodedAudio(channelData: Float32Array[], frameCount: number, sampleRate: number) { function scheduleDecodedAudio(channelData: Float32Array[], frameCount: number, sampleRate: number) {
@@ -5470,7 +5550,9 @@ function startRxAudio() {
if (typeof evt.data === "string") { if (typeof evt.data === "string") {
// Stream info JSON // Stream info JSON
try { try {
configureRxStream(JSON.parse(evt.data)); const info = parseJsonUnknown(evt.data);
if (!isAudioStreamInfo(info)) throw new TypeError("Unexpected audio stream metadata");
configureRxStream(info);
} catch (e) { } catch (e) {
console.error("Audio stream info parse error", e); console.error("Audio stream info parse error", e);
} }
@@ -5543,7 +5625,7 @@ function startRxAudio() {
} catch (e) { /* ignore per-frame errors */ } } catch (e) { /* ignore per-frame errors */ }
} else if (wasmOpusDecoder) { } else if (wasmOpusDecoder) {
try { try {
const result = wasmOpusDecoder.decodeFrame(data); const result = wasmOpusDecoder.decodeFrame(data as Uint8Array<ArrayBufferLike>);
if (result && result.samplesDecoded > 0) { if (result && result.samplesDecoded > 0) {
scheduleDecodedAudio(result.channelData, result.samplesDecoded, result.sampleRate ?? streamInfo?.sample_rate ?? 48000); scheduleDecodedAudio(result.channelData, result.samplesDecoded, result.sampleRate ?? streamInfo?.sample_rate ?? 48000);
} }
@@ -5553,7 +5635,7 @@ function startRxAudio() {
audioWs.onclose = () => { audioWs.onclose = () => {
// If TX was active when WS closed, release PTT // If TX was active when WS closed, release PTT
if (txActive) { stopTxAudio(); } if (txActive) { void stopTxAudio(); }
rxActive = false; rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" }); window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null; streamInfo = null;
@@ -5585,7 +5667,7 @@ function stopRxAudio() {
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" }); window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null; streamInfo = null;
if (audioWs) { audioWs.close(); audioWs = null; } if (audioWs) { audioWs.close(); audioWs = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; } if (audioCtx) { void audioCtx.close(); audioCtx = null; }
updateWfmControls(); updateWfmControls();
rxGainNode = null; rxGainNode = null;
if (opusDecoder) { if (opusDecoder) {
@@ -5605,7 +5687,7 @@ function stopRxAudio() {
} }
function startTxAudio() { function startTxAudio() {
if (txActive) { stopTxAudio(); return; } if (txActive) { void stopTxAudio(); return; }
if (!hasWebCodecs) { if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge"; audioStatus.textContent = "Audio requires Chrome/Edge";
return; return;
@@ -5796,12 +5878,15 @@ async function refreshRecorderStatus() {
fetch("/api/recorder/files"), fetch("/api/recorder/files"),
]); ]);
if (statusResp.ok) { if (statusResp.ok) {
const active = await statusResp.json(); const active = await responseJsonUnknown(statusResp);
renderRecorderActive(active); if (isRecorderActiveList(active)) renderRecorderActive(active);
} }
if (filesResp.ok) { if (filesResp.ok) {
_recorderFiles = await filesResp.json(); const files = await responseJsonUnknown(filesResp);
renderRecorderFiles(); if (isRecorderFileList(files)) {
_recorderFiles = files;
renderRecorderFiles();
}
} }
} catch (e) { } catch (e) {
console.error("Recorder status fetch failed", e); console.error("Recorder status fetch failed", e);
@@ -5933,7 +6018,7 @@ function renderRecorderFiles() {
row.parentNode?.insertBefore(playerRow, row.nextSibling); row.parentNode?.insertBefore(playerRow, row.nextSibling);
btn.setAttribute("aria-expanded", "true"); btn.setAttribute("aria-expanded", "true");
btn.textContent = "Hide"; btn.textContent = "Hide";
try { audio.play(); } catch (_) {} void audio.play().catch(() => {});
}); });
}); });
@@ -6009,7 +6094,6 @@ requiredElement("copyright-year").textContent = String(new Date().getFullYear())
// --- Server-side decode SSE --- // --- Server-side decode SSE ---
let decodeSource: EventSource | null = null; let decodeSource: EventSource | null = null;
let decodeConnected = false;
let decodeHistoryWorker: Worker | null = null; let decodeHistoryWorker: Worker | null = null;
function setModeBoundDecodeStatus(el: HTMLElement | null, activeModes: string[], inactiveText: string, connectedText: string) { function setModeBoundDecodeStatus(el: HTMLElement | null, activeModes: string[], inactiveText: string, connectedText: string) {
if (!el) return; if (!el) return;
@@ -6047,27 +6131,6 @@ function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
} }
} }
function dispatchDecodeBatch(batch: DecodeMessage[]) {
if (!Array.isArray(batch) || batch.length === 0) return;
// Record statistics for every message in the batch regardless of dispatch path.
for (const msg of batch) {
if (msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
}
}
window.trx.modules.map?.scheduleStatsRender();
const type = String(batch[0]?.type || "");
const uniformType = batch.every((msg) => String(msg?.type || "") === type);
if (uniformType && type) {
window.trxPluginRuntime.dispatchBatch(type, batch);
return;
}
for (const msg of batch) {
dispatchDecodeMessage(msg, true);
}
}
const DECODE_HISTORY_TYPE_BATCH_LIMIT = 192;
const DECODE_HISTORY_WORKER_GROUP_LIMIT = 512; const DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
const DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS = 8; const DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS = 8;
@@ -6249,11 +6312,12 @@ function connectDecode() {
return false; return false;
} }
decodeHistoryWorker = worker; decodeHistoryWorker = worker;
worker.onmessage = (evt) => { worker.onmessage = (evt: MessageEvent<unknown>) => {
if (historySettled || worker !== decodeHistoryWorker) return; if (historySettled || worker !== decodeHistoryWorker) return;
const data = evt?.data || {}; const data = evt.data;
if (!isRecord(data) || typeof data.type !== "string") return;
if (data.type === "status") { if (data.type === "status") {
const phase = String(data.phase || ""); const phase = primitiveString(data.phase);
if (phase === "fetching") { if (phase === "fetching") {
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer"); setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
} else if (phase === "decoding") { } else if (phase === "decoding") {
@@ -6271,7 +6335,10 @@ function connectDecode() {
return; return;
} }
if (data.type === "group") { if (data.type === "group") {
enqueueDecodeHistoryGroup(String(data.kind || ""), data.messages); const messages = Array.isArray(data.messages)
? data.messages.filter((message): message is DecodeMessage => isRecord(message) && typeof message.type === "string")
: [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return; return;
} }
if (data.type === "done") { if (data.type === "done") {
@@ -6282,7 +6349,7 @@ function connectDecode() {
return; return;
} }
if (data.type === "error") { if (data.type === "error") {
console.error("Decode history worker failed", data.message || "unknown worker failure"); console.error("Decode history worker failed", typeof data.message === "string" ? data.message : "unknown worker failure");
terminateDecodeHistoryWorker(); terminateDecodeHistoryWorker();
startDecodeHistoryFallback(); startDecodeHistoryFallback();
} }
@@ -6307,21 +6374,21 @@ function connectDecode() {
decodeSource = new EventSource("/decode"); decodeSource = new EventSource("/decode");
const source = decodeSource; const source = decodeSource;
source.onopen = () => { source.onopen = () => {
decodeConnected = true;
updateDecodeStatus("Connected, listening for packets"); updateDecodeStatus("Connected, listening for packets");
}; };
source.onmessage = (evt) => { source.onmessage = (evt: MessageEvent<string>) => {
try { try {
const msg = JSON.parse(evt.data); const msg = parseJsonUnknown(evt.data);
if (historySettled) dispatchDecodeMessage(msg); if (!isRecord(msg) || typeof msg.type !== "string") return;
else liveBuffer.push(msg); const decoded: DecodeMessage = { ...msg, type: msg.type };
if (historySettled) dispatchDecodeMessage(decoded);
else liveBuffer.push(decoded);
} catch (e) { /* ignore parse errors */ } } catch (e) { /* ignore parse errors */ }
}; };
source.onerror = () => { source.onerror = () => {
// readyState CLOSED (2) = server rejected (404/error), CONNECTING (0) = temporary drop // readyState CLOSED (2) = server rejected (404/error), CONNECTING (0) = temporary drop
const wasClosed = source.readyState === 2; const wasClosed = source.readyState === 2;
source.close(); source.close();
decodeConnected = false;
terminateDecodeHistoryWorker(); terminateDecodeHistoryWorker();
if (!historySettled) flushLiveBuffer(); if (!historySettled) flushLiveBuffer();
if (wasClosed) { if (wasClosed) {
@@ -6385,7 +6452,7 @@ let spectrumRange = 90;
let waterfallGamma = 1.0; let waterfallGamma = 1.0;
const SPECTRUM_HEADROOM_DB = 20; const SPECTRUM_HEADROOM_DB = 20;
const SPECTRUM_SMOOTH_ALPHA = 0.42; const SPECTRUM_SMOOTH_ALPHA = 0.42;
let _spectrumBinBuf = []; // Reusable buffer for SSE bin decoding const _spectrumBinBuf = []; // Reusable buffer for SSE bin decoding
// Crosshair state (CSS coords relative to spectrum canvas). // Crosshair state (CSS coords relative to spectrum canvas).
let spectrumCrosshairX: number | null = null; let spectrumCrosshairX: number | null = null;
let spectrumCrosshairY: number | null = null; let spectrumCrosshairY: number | null = null;
@@ -6534,7 +6601,7 @@ function buildSpectrumRenderData(frame: SpectrumFrame): SpectrumFrame {
prev.bins.length === n && prev.bins.length === n &&
prev.sample_rate === frame.sample_rate && prev.sample_rate === frame.sample_rate &&
prev.center_hz === frame.center_hz; prev.center_hz === frame.center_hz;
if (_smoothBins.length !== n) _smoothBins = new Array(n); if (_smoothBins.length !== n) _smoothBins = new Array<number>(n);
const src = frame.bins; const src = frame.bins;
if (canBlend) { if (canBlend) {
const prevBins = prev.bins; const prevBins = prev.bins;
@@ -6730,11 +6797,12 @@ function startSpectrumStreaming() {
// Bins are i8 (1 dB/step), base64-encoded for ~5× size reduction vs JSON f32 array. // Bins are i8 (1 dB/step), base64-encoded for ~5× size reduction vs JSON f32 array.
source.addEventListener("b", (evt) => { source.addEventListener("b", (evt) => {
try { try {
const commaA = evt.data.indexOf(","); const eventData = messageEventData(evt);
const commaB = evt.data.indexOf(",", commaA + 1); const commaA = eventData.indexOf(",");
const centerHz = Number(evt.data.slice(0, commaA)); const commaB = eventData.indexOf(",", commaA + 1);
const sampleRate = Number(evt.data.slice(commaA + 1, commaB)); const centerHz = Number(eventData.slice(0, commaA));
const b64 = evt.data.slice(commaB + 1); const sampleRate = Number(eventData.slice(commaA + 1, commaB));
const b64 = eventData.slice(commaB + 1);
const hadSpectrum = !!lastSpectrumData; const hadSpectrum = !!lastSpectrumData;
const bins = decodeBase64ToInt8(b64); const bins = decodeBase64ToInt8(b64);
// Preserve any RDS data from the last rds event. // Preserve any RDS data from the last rds event.
@@ -6772,29 +6840,29 @@ function startSpectrumStreaming() {
// Named "rds" event = RDS metadata changed (emitted only when it changes). // Named "rds" event = RDS metadata changed (emitted only when it changes).
source.addEventListener("rds", (evt) => { source.addEventListener("rds", (evt) => {
try { try {
const rds = evt.data === "null" ? undefined : JSON.parse(evt.data); const eventData = messageEventData(evt);
const value = eventData === "null" ? undefined : parseJsonUnknown(eventData);
const rds = value !== undefined && isRdsData(value) ? value : null;
if (lastSpectrumData) lastSpectrumData.rds = rds; if (lastSpectrumData) lastSpectrumData.rds = rds;
updateRdsPsOverlay(rds ?? null); updateRdsPsOverlay(rds ?? null);
} catch (_) {} } catch (_) {}
}); });
source.addEventListener("rds_vchan", (evt) => { source.addEventListener("rds_vchan", (evt) => {
try { try {
const payload = evt.data === "null" ? [] : JSON.parse(evt.data); const eventData = messageEventData(evt);
const next = new Map(); const value = eventData === "null" ? [] : parseJsonUnknown(eventData);
const nextSig = new Map(); const payload = Array.isArray(value) ? value.filter(isVchanRdsEntry) : [];
if (Array.isArray(payload)) { const next = new Map<string, RdsData | null>();
payload.forEach((entry) => { const nextSig = new Map<string, number>();
if (entry && entry.id) { payload.forEach((entry) => {
next.set(entry.id, entry.rds ?? null); next.set(entry.id, entry.rds ?? null);
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db); if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
} });
});
}
vchanRdsById = next; vchanRdsById = next;
vchanSignalDbById = nextSig; vchanSignalDbById = nextSig;
const virtualChannelId = window.trx.modules.vchan?.activeId; const virtualChannelId = window.trx.modules.vchan?.activeId;
if (virtualChannelId && nextSig.has(virtualChannelId)) { if (virtualChannelId && nextSig.has(virtualChannelId)) {
sigLastDbm = nextSig.get(virtualChannelId); sigLastDbm = nextSig.get(virtualChannelId) ?? null;
refreshSigStrengthDisplay(); refreshSigStrengthDisplay();
} }
updateRdsPsOverlay(primaryRds); updateRdsPsOverlay(primaryRds);
@@ -6893,10 +6961,10 @@ function startMeterStreaming() {
? `/meter?remote=${encodeURIComponent(lastActiveRigId)}` ? `/meter?remote=${encodeURIComponent(lastActiveRigId)}`
: "/meter"; : "/meter";
meterSource = new EventSource(url); meterSource = new EventSource(url);
meterSource.onmessage = (evt) => { meterSource.onmessage = (evt: MessageEvent<string>) => {
try { try {
const { sig } = JSON.parse(evt.data); const value = parseJsonUnknown(evt.data);
applyMeterSample(sig); if (isRecord(value) && typeof value.sig === "number") applyMeterSample(value.sig);
} catch (_) {} } catch (_) {}
}; };
meterSource.onerror = () => { meterSource.onerror = () => {
@@ -6934,14 +7002,14 @@ function clearSpectrumCanvas() {
} }
function formatOverlayPs(ps: unknown) { function formatOverlayPs(ps: unknown) {
return String(ps ?? "") return primitiveString(ps)
.slice(0, 8) .slice(0, 8)
.padEnd(8, "_") .padEnd(8, "_")
.replaceAll(" ", "_"); .replaceAll(" ", "_");
} }
function formatPsHtml(ps: unknown) { function formatPsHtml(ps: unknown) {
const clipped = String(ps ?? "").slice(0, 8); const clipped = primitiveString(ps).slice(0, 8);
let html = ""; let html = "";
for (let i = 0; i < 8; i += 1) { for (let i = 0; i < 8; i += 1) {
const ch = clipped[i]; const ch = clipped[i];
@@ -6961,8 +7029,10 @@ function formatOverlayPi(pi: unknown) {
} }
function formatOverlayPty(pty: unknown, ptyName: unknown) { function formatOverlayPty(pty: unknown, ptyName: unknown) {
if (ptyName) return ptyName; const name = primitiveString(ptyName);
return pty != null ? String(pty) : "--"; if (name) return name;
const code = primitiveString(pty);
return code || "--";
} }
function overlayTrafficFlagHtml(label: string, active: boolean | null | undefined) { function overlayTrafficFlagHtml(label: string, active: boolean | null | undefined) {
@@ -7077,11 +7147,11 @@ async function copyRdsRawToClipboard() {
const rdsPsValueEl = document.getElementById("rds-ps"); const rdsPsValueEl = document.getElementById("rds-ps");
if (rdsPsValueEl) { if (rdsPsValueEl) {
rdsPsValueEl.addEventListener("click", () => { copyRdsPsToClipboard(); }); rdsPsValueEl.addEventListener("click", () => { void copyRdsPsToClipboard(); });
} }
const rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn") as HTMLButtonElement | null; const rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn") as HTMLButtonElement | null;
if (rdsRawCopyBtn) { if (rdsRawCopyBtn) {
rdsRawCopyBtn.addEventListener("click", () => { copyRdsRawToClipboard(); }); rdsRawCopyBtn.addEventListener("click", () => { void copyRdsRawToClipboard(); });
} }
const rdsAfListEl = document.getElementById("rds-af-list"); const rdsAfListEl = document.getElementById("rds-af-list");
if (rdsAfListEl) { if (rdsAfListEl) {
@@ -7408,7 +7478,7 @@ function drawSpectrumWaterfall() {
const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90); const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90);
const view = spectrumVisibleRange(lastSpectrumData); const view = spectrumVisibleRange(lastSpectrumData);
const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`; const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`;
const palKey = `swf|${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; const palKey = `swf|${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
const rowStride = iW * 4; const rowStride = iW * 4;
const expectedSize = iW * iH * 4; const expectedSize = iW * iH * 4;
const newPushes = spectrumWfPushCount - spectrumWfTexPushCount; const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
@@ -7475,13 +7545,6 @@ function drawSpectrumWaterfall() {
spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true); spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true);
} }
function bmHexToRgba(hex: string, alpha: number) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
// WCAG relative luminance; threshold 0.4 splits well across the palette. // WCAG relative luminance; threshold 0.4 splits well across the palette.
function bmLuminance(hex: string) { function bmLuminance(hex: string) {
const lin = (c: number) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); const lin = (c: number) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
@@ -7559,8 +7622,6 @@ function createBookmarkChip(bm: Bookmark, colorMap: Record<string, string>, opti
const span = document.createElement("span"); const span = document.createElement("span");
const freqStr = window.trx.modules.bookmarks const freqStr = window.trx.modules.bookmarks
? window.trx.modules.bookmarks.formatFrequency(bm.freq_hz) : bm.freq_hz + "\u202fHz"; ? window.trx.modules.bookmarks.formatFrequency(bm.freq_hz) : bm.freq_hz + "\u202fHz";
const esc = (s: unknown) => String(s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
span.className = "spectrum-bookmark-chip"; span.className = "spectrum-bookmark-chip";
if (options.sideStack) { if (options.sideStack) {
span.classList.add("spectrum-bookmark-chip-side"); span.classList.add("spectrum-bookmark-chip-side");
@@ -7626,8 +7687,7 @@ function updateBookmarkAxis(range: SpectrumRange) {
const rightSideEl = document.getElementById("spectrum-bookmark-side-right"); const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
if (!axisEl) return; if (!axisEl) return;
const _bmRef = window.trx.modules.bookmarks?.overlayList ?? null; const allBookmarks = window.trx.modules.bookmarks?.overlayList ?? [];
const allBookmarks = Array.isArray(_bmRef) ? _bmRef : [];
const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz); const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz);
const leftBookmarks = allBookmarks const leftBookmarks = allBookmarks
.filter((bm) => bm.freq_hz < range.visLoHz) .filter((bm) => bm.freq_hz < range.visLoHz)
@@ -8439,8 +8499,16 @@ const bandplanLabelsCheck = document.getElementById("bandplan-labels-check") as
(function loadBandplanJson() { (function loadBandplanJson() {
fetch("/bandplan.json") fetch("/bandplan.json")
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); }) .then(async (response) => {
.then((d) => { bandplanData = d; bandplanSegmentsCache = null; bandplanCacheKey = ""; }) if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
})
.then((data) => {
if (!isRecord(data)) return;
bandplanData = data as BandplanData;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
})
.catch(() => {}); .catch(() => {});
})(); })();