refactor: enforce typed frontend runtime boundaries

This commit is contained in:
sjg
2026-08-01 22:19:50 +02:00
parent d929ad3bde
commit 812359c744
4 changed files with 523 additions and 341 deletions
+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;
// 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
(function() {
const leaflet = globalThis.L;
@@ -1605,6 +1559,60 @@ function estimateNoiseFloorDb(bins) {
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
function requiredElement(id) {
const element = document.getElementById(id);
@@ -1614,6 +1622,41 @@ function requiredElement(id) {
function isFiniteNumber(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);
var authRole = null;
var authEnabled = true;
@@ -1731,7 +1774,6 @@ function applyAuthRestrictions() {
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
const vfoPicker2 = document.getElementById("vfo-picker");
const jogUp = document.getElementById("jog-up");
const jogDown = document.getElementById("jog-down");
const jogButtons = document.querySelectorAll(".jog-step button");
@@ -1809,7 +1851,7 @@ function applyCapabilities(caps) {
if (txAudioBtn2) txAudioBtn2.style.display = caps.tx ? "" : "none";
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
if (!caps.tx && typeof stopTxAudio === "function" && txActive) {
stopTxAudio();
void stopTxAudio();
}
const txLimitRow2 = document.getElementById("tx-limit-row");
if (txLimitRow2) txLimitRow2.style.display = caps.tx_limit ? "" : "none";
@@ -2063,7 +2105,6 @@ function decodeHistoryMapRenderingDeferred() {
}
var lastSpectrumData = null;
window.lastSpectrumData = null;
var lastControl;
var lastTxEn = null;
var lastHasTx = true;
var lastRendered = null;
@@ -2659,7 +2700,7 @@ function applyRigList(activeRigId, rigIds, displayNames = {}) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
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();
}
@@ -2667,8 +2708,9 @@ async function refreshRigList() {
try {
const resp = await fetch("/rigs", { cache: "no-store" });
if (!resp.ok) return;
const data = await resp.json();
const rigs = Array.isArray(data.rigs) ? data.rigs : [];
const data = await responseJsonUnknown(resp);
if (!isRigListResponse(data)) return;
const rigs = data.rigs;
const rigIds = rigs.map((r) => r.remote).filter(Boolean);
const displayNames = {};
rigs.forEach((r) => {
@@ -2684,7 +2726,7 @@ async function refreshRigList() {
});
serverRigs = rigs;
refreshOperatorLayoutCapabilities();
serverActiveRigId = data.active_remote || null;
serverActiveRigId = data.active_remote;
applyRigList(data.active_remote, rigIds, displayNames);
window.trx.modules.map?.syncAprsReceiverMarker();
} catch (e) {
@@ -2740,8 +2782,7 @@ var overviewWfTexPushCount = 0;
var overviewWfTexPalKey = "";
var overviewWfTexReady = false;
function cssColorToRgba(color, alphaMul = 1) {
const parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null;
const parsed = parser ? parser(color) : [0, 0, 0, 1];
const parsed = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor(color) : [0, 0, 0, 1];
return [
parsed[0] ?? 0,
parsed[1] ?? 0,
@@ -2769,7 +2810,7 @@ function overviewWfResetTextureCache() {
overviewWfTexReady = false;
}
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() {
if (!ensureOverviewCanvasBackingStore()) return;
@@ -2837,7 +2878,7 @@ function drawSignalOverlay() {
const bwStroke = BW_OVERLAY_COLORS.stroke;
const bwHard = BW_OVERLAY_COLORS.hard;
const bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
if (Array.isArray(bmRef) && bmRef.length > 0) {
if (bmRef && bmRef.length > 0) {
const colorMap = bmCategoryColorMap();
const grouped = /* @__PURE__ */ new Map();
for (const bm of bmRef) {
@@ -2847,8 +2888,9 @@ function drawSignalOverlay() {
const x = hzToX(f);
if (!isFiniteNumber(x) || x < 0 || x > W) continue;
const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK;
if (!grouped.has(color)) grouped.set(color, []);
grouped.get(color).push(x, 0, x, H);
const segments = grouped.get(color) ?? [];
segments.push(x, 0, x, H);
grouped.set(color, segments);
}
for (const [color, segments] of grouped.entries()) {
if (!Array.isArray(segments) || segments.length === 0) continue;
@@ -3138,7 +3180,7 @@ function waterfallColorRgba(db, pal, minDb, maxDb) {
var _wfLutKey = "";
var _wfLut = new Uint8Array(256 * 4);
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;
_wfLutKey = key;
for (let i = 0; i < 256; i++) {
@@ -3256,7 +3298,7 @@ function renderRdsOverlays() {
el.innerHTML = html;
el.addEventListener("click", (evt) => {
evt.stopPropagation();
copyRdsPsToClipboard(entry.rds, entry.freq_hz);
void copyRdsPsToClipboard(entry.rds, entry.freq_hz);
});
el.addEventListener("mouseenter", () => {
el.style.zIndex = String(entries.length + 10);
@@ -3915,7 +3957,7 @@ function updateJogStepSupport(cap) {
}
function normalizeMode(modeVal) {
if (typeof modeVal === "string") return modeVal;
if (modeVal && typeof modeVal === "object") {
if (isRecord2(modeVal)) {
const entries = Object.entries(modeVal);
if (entries.length > 0) {
const firstEntry = entries[0];
@@ -4436,7 +4478,7 @@ function render(update) {
}
lastModeName = modeUpper2;
if (lastSpectrumData && !update.filter) {
applyBwDefaultForMode(mode, false);
void applyBwDefaultForMode(mode, false);
}
}
updateWfmControls();
@@ -4590,7 +4632,6 @@ function render(update) {
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
}
lastControl = update.enabled;
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit);
txLimitRow.style.display = "";
@@ -4725,9 +4766,10 @@ async function pollFreshSnapshot() {
const statusUrl = lastActiveRigId ? `/status?remote=${encodeURIComponent(lastActiveRigId)}` : "/status";
const resp = await fetch(statusUrl, { cache: "no-store" });
if (!resp.ok) return;
const data = await resp.json();
const data = await responseJsonUnknown(resp);
if (!isRigSnapshot(data)) return;
render(data);
refreshRigList();
void refreshRigList();
lastEventAt = Date.now();
} catch (e) {
}
@@ -4742,7 +4784,7 @@ function connect() {
}
stopMeterStreaming();
startMeterStreaming();
pollFreshSnapshot();
void pollFreshSnapshot();
const eventsUrl = lastActiveRigId ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` : "/events";
es = new EventSource(eventsUrl);
const source = es;
@@ -4751,13 +4793,14 @@ function connect() {
setConnLostOverlay(false);
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (!aboutUptimeStart) aboutUptimeStart = Date.now();
pollFreshSnapshot();
refreshRigList();
void pollFreshSnapshot();
void refreshRigList();
};
source.onmessage = (evt) => {
try {
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;
render(data);
lastEventAt = Date.now();
@@ -4777,21 +4820,22 @@ function connect() {
});
source.addEventListener("session", (evt) => {
try {
const d = JSON.parse(evt.data);
sseSessionId = d.session_id || null;
const eventData = messageEventData(evt);
const d = parseJsonUnknown(eventData);
sseSessionId = isRecord2(d) && typeof d.session_id === "string" ? d.session_id : null;
} catch (_) {
}
window.trx.modules.vchan?.handleSession(evt.data);
window.trx.modules.vchan?.handleSession(messageEventData(evt));
});
source.addEventListener("channels", (evt) => {
window.trx.modules.vchan?.handleChannels(evt.data);
window.trx.modules.vchan?.handleChannels(messageEventData(evt));
});
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
powerHint.textContent = "trx-client connection lost, retrying…";
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
pollFreshSnapshot();
void pollFreshSnapshot();
scheduleReconnect(1e3);
}
};
@@ -4801,7 +4845,7 @@ function connect() {
powerHint.textContent = "trx-client connection lost, retrying…";
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
pollFreshSnapshot();
void pollFreshSnapshot();
scheduleReconnect(250);
}
}, 5e3);
@@ -4915,7 +4959,7 @@ async function switchRigFromSelect(selectEl) {
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.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();
connect();
stopSpectrumStreaming();
@@ -4940,7 +4984,7 @@ async function switchRigFromSelect(selectEl) {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => {
switchRigFromSelect(headerRigSwitchSelect);
void switchRigFromSelect(headerRigSwitchSelect);
});
}
function setControlPending(control, pending) {
@@ -5037,7 +5081,7 @@ if (centerFreqEl) {
centerFreqDirty = true;
if (e.key === "Enter") {
e.preventDefault();
applyCenterFreqFromInput();
void applyCenterFreqFromInput();
} else if (e.key === "Escape") {
centerFreqDirty = false;
refreshCenterFreqDisplay();
@@ -5382,18 +5426,18 @@ if (spectrumBwInput) {
spectrumBwInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
applyBandwidthFromInput();
void applyBandwidthFromInput();
}
});
}
if (spectrumBwSetBtn) {
spectrumBwSetBtn.addEventListener("click", () => {
applyBandwidthFromInput();
void applyBandwidthFromInput();
});
}
if (spectrumBwAutoBtn) {
spectrumBwAutoBtn.addEventListener("click", () => {
applyAutoBandwidth();
void applyAutoBandwidth();
});
}
if (spectrumBwSweetBtn) {
@@ -5466,7 +5510,9 @@ function navigateToTab(name, options = {}) {
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name);
void loadPluginsForTab(name).catch((error) => {
console.error(error);
});
if (name === "map") {
_initMapWhenReady();
}
@@ -5474,7 +5520,7 @@ function navigateToTab(name, options = {}) {
window.trx.modules.map?.scheduleStatsRender();
}
if (name === "recorder") {
refreshRecorderStatus();
void refreshRecorderStatus();
}
}
window.navigateToTab = navigateToTab;
@@ -5604,7 +5650,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
});
var guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.addEventListener("click", async () => {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement("auth-passphrase").value = "";
hideAuthGate();
@@ -5630,7 +5676,7 @@ if (headerAuthBtn) {
});
}
var trxState = /* @__PURE__ */ Object.create(null);
var trxModules = /* @__PURE__ */ Object.create(null);
var trxModules = {};
Object.defineProperties(trxState, {
serverLat: { get() {
return serverLat;
@@ -5804,8 +5850,10 @@ Object.defineProperties(trxState, {
} }
});
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
if (typeof window.loadEagerPlugins === "function") window.loadEagerPlugins();
initializeApp();
void loadEagerPlugins().catch((error) => {
console.error(error);
});
void initializeApp();
window.addEventListener("resize", resizeHeaderSignalCanvas);
function bookmarkDistanceText(bm) {
if (!bm || serverLat == null || serverLon == null) return null;
@@ -5830,7 +5878,7 @@ function buildBookmarkTooltipText(bm) {
}
function nearestBookmarkForHz(hz, widthPx, range) {
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;
}
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 wfmDenoiseEl = document.getElementById("wfm-denoise");
var sdrSettingsRowEl = document.getElementById("sdr-settings-row");
var sdrGainControlsEl = document.getElementById("sdr-gain-controls");
var sdrGainEl = document.getElementById("sdr-gain-db");
var sdrGainSetBtn = document.getElementById("sdr-gain-set");
var sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls");
@@ -6056,7 +6103,7 @@ function levelFromChannels(channels, frameCount) {
return Math.min(100, rms * 220);
}
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;
return "auto";
}
@@ -6282,7 +6329,7 @@ function resetTxTimeout() {
if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
txTimeoutTimer = setTimeout(() => {
console.warn("PTT safety timeout — stopping TX");
stopTxAudio();
void stopTxAudio();
}, TX_TIMEOUT_SECS * 1e3);
}
function startTxTimeoutCountdown() {
@@ -6441,7 +6488,9 @@ function startRxAudio() {
audioWs.onmessage = (evt) => {
if (typeof evt.data === "string") {
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) {
console.error("Audio stream info parse error", e);
}
@@ -6518,7 +6567,7 @@ function startRxAudio() {
};
audioWs.onclose = () => {
if (txActive) {
stopTxAudio();
void stopTxAudio();
}
rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
@@ -6559,7 +6608,7 @@ function stopRxAudio() {
audioWs = null;
}
if (audioCtx) {
audioCtx.close();
void audioCtx.close();
audioCtx = null;
}
updateWfmControls();
@@ -6587,7 +6636,7 @@ function stopRxAudio() {
}
function startTxAudio() {
if (txActive) {
stopTxAudio();
void stopTxAudio();
return;
}
if (!hasWebCodecs) {
@@ -6780,12 +6829,15 @@ async function refreshRecorderStatus() {
fetch("/api/recorder/files")
]);
if (statusResp.ok) {
const active = await statusResp.json();
renderRecorderActive(active);
const active = await responseJsonUnknown(statusResp);
if (isRecorderActiveList(active)) renderRecorderActive(active);
}
if (filesResp.ok) {
_recorderFiles = await filesResp.json();
renderRecorderFiles();
const files = await responseJsonUnknown(filesResp);
if (isRecorderFileList(files)) {
_recorderFiles = files;
renderRecorderFiles();
}
}
} catch (e) {
console.error("Recorder status fetch failed", e);
@@ -6923,10 +6975,8 @@ function renderRecorderFiles() {
row.parentNode?.insertBefore(playerRow, row.nextSibling);
btn.setAttribute("aria-expanded", "true");
btn.textContent = "Hide";
try {
audio.play();
} catch (_) {
}
void audio.play().catch(() => {
});
});
});
el.querySelectorAll(".rec-delete-btn").forEach(function(btn) {
@@ -7011,7 +7061,6 @@ if (sdrSquelchEl) {
}
requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear());
var decodeSource = null;
var decodeConnected = false;
var decodeHistoryWorker = null;
function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText) {
if (!el) return;
@@ -7215,9 +7264,10 @@ function connectDecode() {
decodeHistoryWorker = worker;
worker.onmessage = (evt) => {
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") {
const phase = String(data.phase || "");
const phase = primitiveString(data.phase);
if (phase === "fetching") {
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
} else if (phase === "decoding") {
@@ -7235,7 +7285,8 @@ function connectDecode() {
return;
}
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;
}
if (data.type === "done") {
@@ -7246,7 +7297,7 @@ function connectDecode() {
return;
}
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();
startDecodeHistoryFallback();
}
@@ -7268,21 +7319,21 @@ function connectDecode() {
decodeSource = new EventSource("/decode");
const source = decodeSource;
source.onopen = () => {
decodeConnected = true;
updateDecodeStatus("Connected, listening for packets");
};
source.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (historySettled) dispatchDecodeMessage(msg);
else liveBuffer.push(msg);
const msg = parseJsonUnknown(evt.data);
if (!isRecord2(msg) || typeof msg.type !== "string") return;
const decoded = { ...msg, type: msg.type };
if (historySettled) dispatchDecodeMessage(decoded);
else liveBuffer.push(decoded);
} catch (e) {
}
};
source.onerror = () => {
const wasClosed = source.readyState === 2;
source.close();
decodeConnected = false;
terminateDecodeHistoryWorker();
if (!historySettled) flushLiveBuffer();
if (wasClosed) {
@@ -7612,11 +7663,12 @@ function startSpectrumStreaming() {
};
source.addEventListener("b", (evt) => {
try {
const commaA = evt.data.indexOf(",");
const commaB = evt.data.indexOf(",", commaA + 1);
const centerHz = Number(evt.data.slice(0, commaA));
const sampleRate = Number(evt.data.slice(commaA + 1, commaB));
const b64 = evt.data.slice(commaB + 1);
const eventData = messageEventData(evt);
const commaA = eventData.indexOf(",");
const commaB = eventData.indexOf(",", commaA + 1);
const centerHz = Number(eventData.slice(0, commaA));
const sampleRate = Number(eventData.slice(commaA + 1, commaB));
const b64 = eventData.slice(commaB + 1);
const hadSpectrum = !!lastSpectrumData;
const bins = decodeBase64Int8(b64);
const rds = lastSpectrumData?.rds;
@@ -7652,7 +7704,9 @@ function startSpectrumStreaming() {
});
source.addEventListener("rds", (evt) => {
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;
updateRdsPsOverlay(rds ?? null);
} catch (_) {
@@ -7660,22 +7714,20 @@ function startSpectrumStreaming() {
});
source.addEventListener("rds_vchan", (evt) => {
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 nextSig = /* @__PURE__ */ new Map();
if (Array.isArray(payload)) {
payload.forEach((entry) => {
if (entry && entry.id) {
next.set(entry.id, entry.rds ?? null);
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
}
});
}
payload.forEach((entry) => {
next.set(entry.id, entry.rds ?? null);
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
});
vchanRdsById = next;
vchanSignalDbById = nextSig;
const virtualChannelId = window.trx.modules.vchan?.activeId;
if (virtualChannelId && nextSig.has(virtualChannelId)) {
sigLastDbm = nextSig.get(virtualChannelId);
sigLastDbm = nextSig.get(virtualChannelId) ?? null;
refreshSigStrengthDisplay();
}
updateRdsPsOverlay(primaryRds);
@@ -7758,8 +7810,8 @@ function startMeterStreaming() {
meterSource = new EventSource(url);
meterSource.onmessage = (evt) => {
try {
const { sig } = JSON.parse(evt.data);
applyMeterSample(sig);
const value = parseJsonUnknown(evt.data);
if (isRecord2(value) && typeof value.sig === "number") applyMeterSample(value.sig);
} catch (_) {
}
};
@@ -7794,10 +7846,10 @@ function clearSpectrumCanvas() {
}
}
function formatOverlayPs(ps) {
return String(ps ?? "").slice(0, 8).padEnd(8, "_").replaceAll(" ", "_");
return primitiveString(ps).slice(0, 8).padEnd(8, "_").replaceAll(" ", "_");
}
function formatPsHtml(ps) {
const clipped = String(ps ?? "").slice(0, 8);
const clipped = primitiveString(ps).slice(0, 8);
let html = "";
for (let i = 0; i < 8; i += 1) {
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 --";
}
function formatOverlayPty(pty, ptyName) {
if (ptyName) return ptyName;
return pty != null ? String(pty) : "--";
const name = primitiveString(ptyName);
if (name) return name;
const code = primitiveString(pty);
return code || "--";
}
function overlayTrafficFlagHtml(label, active) {
const stateClass = active === true ? "rds-flag-active" : "rds-flag-inactive";
@@ -7913,13 +7967,13 @@ async function copyRdsRawToClipboard() {
var rdsPsValueEl = document.getElementById("rds-ps");
if (rdsPsValueEl) {
rdsPsValueEl.addEventListener("click", () => {
copyRdsPsToClipboard();
void copyRdsPsToClipboard();
});
}
var rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn");
if (rdsRawCopyBtn) {
rdsRawCopyBtn.addEventListener("click", () => {
copyRdsRawToClipboard();
void copyRdsRawToClipboard();
});
}
var rdsAfListEl = document.getElementById("rds-af-list");
@@ -8202,7 +8256,7 @@ function drawSpectrumWaterfall() {
const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90);
const view = spectrumVisibleRange(lastSpectrumData);
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 expectedSize = iW * iH * 4;
const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
@@ -8329,7 +8383,6 @@ function bmCategoryColorMap() {
function createBookmarkChip(bm, colorMap, options = {}) {
const span = document.createElement("span");
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";
if (options.sideStack) {
span.classList.add("spectrum-bookmark-chip-side");
@@ -8374,8 +8427,7 @@ function updateBookmarkAxis(range) {
const leftSideEl = document.getElementById("spectrum-bookmark-side-left");
const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
if (!axisEl) return;
const _bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
const allBookmarks = Array.isArray(_bmRef) ? _bmRef : [];
const allBookmarks = window.trx.modules.bookmarks?.overlayList ?? [];
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 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 bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
(function loadBandplanJson() {
fetch("/bandplan.json").then((r) => {
if (!r.ok) throw new Error(String(r.status));
return r.json();
}).then((d) => {
bandplanData = d;
fetch("/bandplan.json").then(async (response) => {
if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
}).then((data) => {
if (!isRecord2(data)) return;
bandplanData = data;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
}).catch(() => {
@@ -36,35 +36,18 @@ export default tseslint.config(
rules: {
"no-undef": "off",
"@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 }],
},
},
{
// 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"],
languageOptions: {
@@ -44,9 +44,10 @@ import {
} from "./features/spectrum/math.js";
import type { NumericBins } from "./features/spectrum/math.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 { loadEagerPlugins, loadPluginsForTab } from "./plugin-loader.js";
import { isRigListResponse, isRigSnapshot } from "./api/client.js";
interface SpectrumFrame {
bins: NumericBins;
@@ -224,6 +225,47 @@ interface TrxModules {
};
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 {
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;
@@ -250,9 +292,67 @@ function isFiniteNumber(value: unknown): value is number {
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 {
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;
trxPluginRuntime: TrxPluginRuntime;
lastSpectrumData: SpectrumFrame | null;
@@ -269,8 +369,6 @@ declare global {
navigateToTab(name: TabName, options?: { updateHistory?: boolean; replaceHistory?: boolean }): void;
_syncRecorderState(enabled: boolean): void;
refreshRdsUi(): void;
loadEagerPlugins?(): Promise<void>;
loadPluginsForTab?(tab: string): Promise<void>;
refreshCwTonePicker?(): void;
updateFt8RfDisplay?(): void;
clearSatPredictionDom?(): void;
@@ -430,7 +528,6 @@ function applyAuthRestrictions() {
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
const vfoPicker = document.getElementById("vfo-picker");
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
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 (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
if (!caps.tx && typeof stopTxAudio === "function" && txActive) {
stopTxAudio();
void stopTxAudio();
}
// TX limit row
@@ -814,11 +911,10 @@ function decodeHistoryMapRenderingDeferred() {
let lastSpectrumData: SpectrumFrame | null = null;
window.lastSpectrumData = null;
let lastControl;
let lastTxEn: boolean | null = null;
let lastHasTx = true;
let lastRendered: string | null = null;
let prevRenderData: Record<string, unknown> = {};
const prevRenderData: Record<string, unknown> = {};
let hintTimer: ReturnType<typeof setTimeout> | null = null;
let sigMeasuring = false;
let sigLastSUnits: number | null = null;
@@ -941,7 +1037,7 @@ function updateDocumentTitle(rds: RdsData | null = null) {
document.title = originalTitle;
return;
}
const parts = [formatFreq(freqHz as number)];
const parts = [formatFreq(freqHz)];
const ps = rds?.program_service;
if (ps && ps.length > 0) {
parts.push(ps);
@@ -1327,7 +1423,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
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();
}
@@ -1337,8 +1433,9 @@ async function refreshRigList() {
try {
const resp = await fetch("/rigs", { cache: "no-store" });
if (!resp.ok) return;
const data = await resp.json();
const rigs = Array.isArray(data.rigs) ? data.rigs : [];
const data = await responseJsonUnknown(resp);
if (!isRigListResponse(data)) return;
const rigs = data.rigs;
const rigIds = rigs.map((r: RigListItem) => r.remote).filter(Boolean);
const displayNames: Record<string, string> = {};
rigs.forEach((r: RigListItem) => {
@@ -1354,7 +1451,7 @@ async function refreshRigList() {
});
serverRigs = rigs;
refreshOperatorLayoutCapabilities();
serverActiveRigId = data.active_remote || null;
serverActiveRigId = data.active_remote;
applyRigList(data.active_remote, rigIds, displayNames);
window.trx.modules.map?.syncAprsReceiverMarker();
} catch (e) {
@@ -1399,7 +1496,7 @@ setInterval(() => {
if (el) el.textContent = formatUptime(Date.now() - aboutUptimeStart);
}, 1000);
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let overviewSignalSamples: SignalSample[] = [];
const overviewSignalSamples: SignalSample[] = [];
let overviewSignalTimer: ReturnType<typeof setInterval> | null = null;
let overviewWaterfallRows: NumericBins[] = [];
let overviewWaterfallPushCount = 0; // monotonically increments on every push
@@ -1413,8 +1510,9 @@ let overviewWfTexPalKey = "";
let overviewWfTexReady = false;
function cssColorToRgba(color: string, alphaMul = 1): Rgba {
const parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null;
const parsed = parser ? parser(color) : [0, 0, 0, 1];
const parsed = typeof window.trxParseCssColor === "function"
? window.trxParseCssColor(color)
: [0, 0, 0, 1];
return [
parsed[0] ?? 0,
parsed[1] ?? 0,
@@ -1447,7 +1545,7 @@ function overviewWfResetTextureCache() {
}
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() {
@@ -1526,9 +1624,9 @@ function drawSignalOverlay() {
const bwStroke = BW_OVERLAY_COLORS.stroke;
const bwHard = BW_OVERLAY_COLORS.hard;
const bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
if (Array.isArray(bmRef) && bmRef.length > 0) {
if (bmRef && bmRef.length > 0) {
const colorMap = bmCategoryColorMap();
const grouped = new Map();
const grouped = new Map<string, number[]>();
for (const bm of bmRef) {
const f = Number(bm?.freq_hz);
if (!isFiniteNumber(f) || f < range.visLoHz || f > range.visHiHz) continue;
@@ -1536,8 +1634,9 @@ function drawSignalOverlay() {
const x = hzToX(f);
if (!isFiniteNumber(x) || x < 0 || x > W) continue;
const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK;
if (!grouped.has(color)) grouped.set(color, []);
grouped.get(color).push(x, 0, x, H);
const segments = grouped.get(color) ?? [];
segments.push(x, 0, x, H);
grouped.set(color, segments);
}
for (const [color, segments] of grouped.entries()) {
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
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;
_wfLutKey = key;
for (let i = 0; i < 256; i++) {
@@ -2000,7 +2099,7 @@ function renderRdsOverlays() {
el.innerHTML = html;
el.addEventListener("click", (evt) => {
evt.stopPropagation();
copyRdsPsToClipboard(entry.rds, entry.freq_hz);
void copyRdsPsToClipboard(entry.rds, entry.freq_hz);
});
el.addEventListener("mouseenter", () => {
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);
}
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[] {
if (!isFiniteNumber(freqHz)) return [];
const modeUpper = String(mode || "").toUpperCase();
@@ -2273,10 +2364,6 @@ function coverageSpanForMode(freqHz: number | null, bandwidthHz = coverageGuardB
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) {
const sampleRate = Number(sampleRateHz);
if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return 0;
@@ -2785,7 +2872,7 @@ function updateJogStepSupport(cap: RigCapabilities | null) {
function normalizeMode(modeVal: unknown): string {
if (typeof modeVal === "string") return modeVal;
if (modeVal && typeof modeVal === "object") {
if (isRecord(modeVal)) {
const entries = Object.entries(modeVal);
if (entries.length > 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
// mode — but only if the server hasn't already pushed a filter_state.
if (lastSpectrumData && !update.filter) {
applyBwDefaultForMode(mode, false);
void applyBwDefaultForMode(mode, false);
}
}
updateWfmControls();
@@ -3559,7 +3646,6 @@ function render(update: AppUpdate) {
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
}
lastControl = update.enabled;
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit);
@@ -3713,9 +3799,10 @@ async function pollFreshSnapshot() {
: "/status";
const resp = await fetch(statusUrl, { cache: "no-store" });
if (!resp.ok) return;
const data = await resp.json();
const data = await responseJsonUnknown(resp);
if (!isRigSnapshot(data)) return;
render(data);
refreshRigList();
void refreshRigList();
lastEventAt = Date.now();
} catch (e) {
// Ignore network errors; connect() retry loop handles reconnection.
@@ -3732,7 +3819,7 @@ function connect() {
}
stopMeterStreaming();
startMeterStreaming();
pollFreshSnapshot();
void pollFreshSnapshot();
const eventsUrl = lastActiveRigId
? `/events?remote=${encodeURIComponent(lastActiveRigId)}`
: "/events";
@@ -3743,13 +3830,14 @@ function connect() {
setConnLostOverlay(false);
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (!aboutUptimeStart) aboutUptimeStart = Date.now();
pollFreshSnapshot();
refreshRigList();
void pollFreshSnapshot();
void refreshRigList();
};
source.onmessage = (evt) => {
source.onmessage = (evt: MessageEvent<string>) => {
try {
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;
render(data);
lastEventAt = Date.now();
@@ -3769,13 +3857,14 @@ function connect() {
});
source.addEventListener("session", evt => {
try {
const d = JSON.parse(evt.data);
sseSessionId = d.session_id || null;
const eventData = messageEventData(evt);
const d = parseJsonUnknown(eventData);
sseSessionId = isRecord(d) && typeof d.session_id === "string" ? d.session_id : null;
} catch (_) {}
window.trx.modules.vchan?.handleSession(evt.data);
window.trx.modules.vchan?.handleSession(messageEventData(evt));
});
source.addEventListener("channels", evt => {
window.trx.modules.vchan?.handleChannels(evt.data);
window.trx.modules.vchan?.handleChannels(messageEventData(evt));
});
source.onerror = () => {
// 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";
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
pollFreshSnapshot();
void pollFreshSnapshot();
scheduleReconnect(1000);
}
};
@@ -3794,7 +3883,7 @@ function connect() {
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
pollFreshSnapshot();
void pollFreshSnapshot();
scheduleReconnect(250);
}
}, 5000);
@@ -3827,12 +3916,8 @@ function disconnect() {
// Yield the main thread so the browser can paint before heavy async work.
// Uses scheduler.yield() (Chrome 115+) with a setTimeout fallback.
function yieldToMain() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
const uiFrameJobs = new Map();
let uiFrameJobsHandle: ReturnType<typeof setTimeout> | number | null = null;
const uiFrameJobs = new Map<string, () => void>();
let uiFrameJobsHandle: ReturnType<typeof setTimeout> | null = null;
function flushUiFrameJobs() {
uiFrameJobsHandle = null;
@@ -3929,7 +4014,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.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();
connect();
stopSpectrumStreaming();
@@ -3954,7 +4039,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); });
headerRigSwitchSelect.addEventListener("change", () => { void switchRigFromSelect(headerRigSwitchSelect); });
}
function setControlPending(control: HTMLButtonElement | HTMLInputElement | HTMLSelectElement | null, pending: boolean) {
@@ -4057,7 +4142,7 @@ if (centerFreqEl) {
centerFreqDirty = true;
if (e.key === "Enter") {
e.preventDefault();
applyCenterFreqFromInput();
void applyCenterFreqFromInput();
} else if (e.key === "Escape") {
centerFreqDirty = false;
refreshCenterFreqDisplay();
@@ -4443,15 +4528,15 @@ if (spectrumBwInput) {
spectrumBwInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
applyBandwidthFromInput();
void applyBandwidthFromInput();
}
});
}
if (spectrumBwSetBtn) {
spectrumBwSetBtn.addEventListener("click", () => { applyBandwidthFromInput(); });
spectrumBwSetBtn.addEventListener("click", () => { void applyBandwidthFromInput(); });
}
if (spectrumBwAutoBtn) {
spectrumBwAutoBtn.addEventListener("click", () => { applyAutoBandwidth(); });
spectrumBwAutoBtn.addEventListener("click", () => { void applyAutoBandwidth(); });
}
if (spectrumBwSweetBtn) {
spectrumBwSweetBtn.addEventListener("click", () => { applySweetSpotCenter().catch(() => {}); });
@@ -4539,7 +4624,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
window.trx.modules.map?.scheduleStatsRender();
}
if (name === "recorder") {
refreshRecorderStatus();
void refreshRecorderStatus();
}
}
window.navigateToTab = navigateToTab;
@@ -4604,10 +4689,6 @@ window.addEventListener("popstate", () => {
window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence ---
function getAvailableRigIds() {
return lastRigIds || [];
}
async function initializeApp() {
showAuthGate(false);
const authStatus = await checkAuthStatus();
@@ -4687,7 +4768,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
// Setup guest button
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
if (guestBtn) {
guestBtn.addEventListener("click", async () => {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
hideAuthGate();
@@ -4721,12 +4802,12 @@ if (headerAuthBtn) {
// Modules (map-core.js, screenshot.js) access core state and utilities via
// window.trx. Modules register their own APIs as sub-namespaces
// (e.g. window.trx.modules.map, window.trx.modules.screenshot).
const trxState = Object.create(null);
const trxModules = Object.create(null);
const trxState = Object.create(null) as TrxState;
const trxModules: TrxModules = {};
// -- State getters (backed by core-scoped variables) --
Object.defineProperties(trxState, {
serverLat: { get() { return serverLat; }, set(v) { serverLat = v; } },
serverLon: { get() { return serverLon; }, set(v) { serverLon = v; } },
serverLat: { get() { return serverLat; }, set(v: number | null) { serverLat = v; } },
serverLon: { get() { return serverLon; }, set(v: number | null) { serverLon = v; } },
lastFreqHz: { get() { return lastFreqHz; } },
lastActiveRigId: { get() { return lastActiveRigId; } },
lastRigIds: { get() { return lastRigIds; } },
@@ -4739,7 +4820,7 @@ Object.defineProperties(trxState, {
primaryRds: { get() { return primaryRds; } },
vchanRdsById: { get() { return vchanRdsById; } },
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; } },
serverBuildDate: { get() { return serverBuildDate; } },
serverCallsign: { get() { return serverCallsign; } },
@@ -4752,7 +4833,7 @@ Object.defineProperties(trxState, {
lastModeName: { get() { return lastModeName; } },
lastSpectrumData: { get() { return lastSpectrumData; } },
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; } },
spectrumRange: { get() { return spectrumRange; } },
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); });
// Start the app
initializeApp();
void initializeApp();
window.addEventListener("resize", resizeHeaderSignalCanvas);
@@ -4826,11 +4907,11 @@ function buildBookmarkTooltipText(bm: Bookmark) {
function nearestBookmarkForHz(hz: number, widthPx: number, range: SpectrumRange) {
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;
}
const maxDeltaHz = Math.max((range.visSpanHz / widthPx) * 6, 10);
let best = null;
let best: Bookmark | null = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const bm of ref) {
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 wfmDenoiseEl = document.getElementById("wfm-denoise") as HTMLSelectElement | null;
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 sdrGainSetBtn = document.getElementById("sdr-gain-set") as HTMLButtonElement | null;
const sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls");
@@ -5076,7 +5156,7 @@ function levelFromChannels(channels: Float32Array[], frameCount: number) {
}
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;
return "auto";
}
@@ -5306,7 +5386,7 @@ function resetTxTimeout() {
if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
txTimeoutTimer = setTimeout(() => {
console.warn("PTT safety timeout — stopping TX");
stopTxAudio();
void stopTxAudio();
}, 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.
let _audioChannelOverride: string | null = null;
const _audioChannelOverride: string | null = null;
/** Schedule decoded PCM channels for playback via Web Audio API. */
function scheduleDecodedAudio(channelData: Float32Array[], frameCount: number, sampleRate: number) {
@@ -5470,7 +5550,9 @@ function startRxAudio() {
if (typeof evt.data === "string") {
// Stream info JSON
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) {
console.error("Audio stream info parse error", e);
}
@@ -5543,7 +5625,7 @@ function startRxAudio() {
} catch (e) { /* ignore per-frame errors */ }
} else if (wasmOpusDecoder) {
try {
const result = wasmOpusDecoder.decodeFrame(data);
const result = wasmOpusDecoder.decodeFrame(data as Uint8Array<ArrayBufferLike>);
if (result && result.samplesDecoded > 0) {
scheduleDecodedAudio(result.channelData, result.samplesDecoded, result.sampleRate ?? streamInfo?.sample_rate ?? 48000);
}
@@ -5553,7 +5635,7 @@ function startRxAudio() {
audioWs.onclose = () => {
// If TX was active when WS closed, release PTT
if (txActive) { stopTxAudio(); }
if (txActive) { void stopTxAudio(); }
rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null;
@@ -5585,7 +5667,7 @@ function stopRxAudio() {
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null;
if (audioWs) { audioWs.close(); audioWs = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
if (audioCtx) { void audioCtx.close(); audioCtx = null; }
updateWfmControls();
rxGainNode = null;
if (opusDecoder) {
@@ -5605,7 +5687,7 @@ function stopRxAudio() {
}
function startTxAudio() {
if (txActive) { stopTxAudio(); return; }
if (txActive) { void stopTxAudio(); return; }
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -5796,12 +5878,15 @@ async function refreshRecorderStatus() {
fetch("/api/recorder/files"),
]);
if (statusResp.ok) {
const active = await statusResp.json();
renderRecorderActive(active);
const active = await responseJsonUnknown(statusResp);
if (isRecorderActiveList(active)) renderRecorderActive(active);
}
if (filesResp.ok) {
_recorderFiles = await filesResp.json();
renderRecorderFiles();
const files = await responseJsonUnknown(filesResp);
if (isRecorderFileList(files)) {
_recorderFiles = files;
renderRecorderFiles();
}
}
} catch (e) {
console.error("Recorder status fetch failed", e);
@@ -5933,7 +6018,7 @@ function renderRecorderFiles() {
row.parentNode?.insertBefore(playerRow, row.nextSibling);
btn.setAttribute("aria-expanded", "true");
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 ---
let decodeSource: EventSource | null = null;
let decodeConnected = false;
let decodeHistoryWorker: Worker | null = null;
function setModeBoundDecodeStatus(el: HTMLElement | null, activeModes: string[], inactiveText: string, connectedText: string) {
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_BATCH_DRAIN_BUDGET_MS = 8;
@@ -6249,11 +6312,12 @@ function connectDecode() {
return false;
}
decodeHistoryWorker = worker;
worker.onmessage = (evt) => {
worker.onmessage = (evt: MessageEvent<unknown>) => {
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") {
const phase = String(data.phase || "");
const phase = primitiveString(data.phase);
if (phase === "fetching") {
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
} else if (phase === "decoding") {
@@ -6271,7 +6335,10 @@ function connectDecode() {
return;
}
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;
}
if (data.type === "done") {
@@ -6282,7 +6349,7 @@ function connectDecode() {
return;
}
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();
startDecodeHistoryFallback();
}
@@ -6307,21 +6374,21 @@ function connectDecode() {
decodeSource = new EventSource("/decode");
const source = decodeSource;
source.onopen = () => {
decodeConnected = true;
updateDecodeStatus("Connected, listening for packets");
};
source.onmessage = (evt) => {
source.onmessage = (evt: MessageEvent<string>) => {
try {
const msg = JSON.parse(evt.data);
if (historySettled) dispatchDecodeMessage(msg);
else liveBuffer.push(msg);
const msg = parseJsonUnknown(evt.data);
if (!isRecord(msg) || typeof msg.type !== "string") return;
const decoded: DecodeMessage = { ...msg, type: msg.type };
if (historySettled) dispatchDecodeMessage(decoded);
else liveBuffer.push(decoded);
} catch (e) { /* ignore parse errors */ }
};
source.onerror = () => {
// readyState CLOSED (2) = server rejected (404/error), CONNECTING (0) = temporary drop
const wasClosed = source.readyState === 2;
source.close();
decodeConnected = false;
terminateDecodeHistoryWorker();
if (!historySettled) flushLiveBuffer();
if (wasClosed) {
@@ -6385,7 +6452,7 @@ let spectrumRange = 90;
let waterfallGamma = 1.0;
const SPECTRUM_HEADROOM_DB = 20;
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).
let spectrumCrosshairX: number | null = null;
let spectrumCrosshairY: number | null = null;
@@ -6534,7 +6601,7 @@ function buildSpectrumRenderData(frame: SpectrumFrame): SpectrumFrame {
prev.bins.length === n &&
prev.sample_rate === frame.sample_rate &&
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;
if (canBlend) {
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.
source.addEventListener("b", (evt) => {
try {
const commaA = evt.data.indexOf(",");
const commaB = evt.data.indexOf(",", commaA + 1);
const centerHz = Number(evt.data.slice(0, commaA));
const sampleRate = Number(evt.data.slice(commaA + 1, commaB));
const b64 = evt.data.slice(commaB + 1);
const eventData = messageEventData(evt);
const commaA = eventData.indexOf(",");
const commaB = eventData.indexOf(",", commaA + 1);
const centerHz = Number(eventData.slice(0, commaA));
const sampleRate = Number(eventData.slice(commaA + 1, commaB));
const b64 = eventData.slice(commaB + 1);
const hadSpectrum = !!lastSpectrumData;
const bins = decodeBase64ToInt8(b64);
// 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).
source.addEventListener("rds", (evt) => {
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;
updateRdsPsOverlay(rds ?? null);
} catch (_) {}
});
source.addEventListener("rds_vchan", (evt) => {
try {
const payload = evt.data === "null" ? [] : JSON.parse(evt.data);
const next = new Map();
const nextSig = new Map();
if (Array.isArray(payload)) {
payload.forEach((entry) => {
if (entry && entry.id) {
next.set(entry.id, entry.rds ?? null);
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
}
});
}
const eventData = messageEventData(evt);
const value = eventData === "null" ? [] : parseJsonUnknown(eventData);
const payload = Array.isArray(value) ? value.filter(isVchanRdsEntry) : [];
const next = new Map<string, RdsData | null>();
const nextSig = new Map<string, number>();
payload.forEach((entry) => {
next.set(entry.id, entry.rds ?? null);
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
});
vchanRdsById = next;
vchanSignalDbById = nextSig;
const virtualChannelId = window.trx.modules.vchan?.activeId;
if (virtualChannelId && nextSig.has(virtualChannelId)) {
sigLastDbm = nextSig.get(virtualChannelId);
sigLastDbm = nextSig.get(virtualChannelId) ?? null;
refreshSigStrengthDisplay();
}
updateRdsPsOverlay(primaryRds);
@@ -6893,10 +6961,10 @@ function startMeterStreaming() {
? `/meter?remote=${encodeURIComponent(lastActiveRigId)}`
: "/meter";
meterSource = new EventSource(url);
meterSource.onmessage = (evt) => {
meterSource.onmessage = (evt: MessageEvent<string>) => {
try {
const { sig } = JSON.parse(evt.data);
applyMeterSample(sig);
const value = parseJsonUnknown(evt.data);
if (isRecord(value) && typeof value.sig === "number") applyMeterSample(value.sig);
} catch (_) {}
};
meterSource.onerror = () => {
@@ -6934,14 +7002,14 @@ function clearSpectrumCanvas() {
}
function formatOverlayPs(ps: unknown) {
return String(ps ?? "")
return primitiveString(ps)
.slice(0, 8)
.padEnd(8, "_")
.replaceAll(" ", "_");
}
function formatPsHtml(ps: unknown) {
const clipped = String(ps ?? "").slice(0, 8);
const clipped = primitiveString(ps).slice(0, 8);
let html = "";
for (let i = 0; i < 8; i += 1) {
const ch = clipped[i];
@@ -6961,8 +7029,10 @@ function formatOverlayPi(pi: unknown) {
}
function formatOverlayPty(pty: unknown, ptyName: unknown) {
if (ptyName) return ptyName;
return pty != null ? String(pty) : "--";
const name = primitiveString(ptyName);
if (name) return name;
const code = primitiveString(pty);
return code || "--";
}
function overlayTrafficFlagHtml(label: string, active: boolean | null | undefined) {
@@ -7077,11 +7147,11 @@ async function copyRdsRawToClipboard() {
const rdsPsValueEl = document.getElementById("rds-ps");
if (rdsPsValueEl) {
rdsPsValueEl.addEventListener("click", () => { copyRdsPsToClipboard(); });
rdsPsValueEl.addEventListener("click", () => { void copyRdsPsToClipboard(); });
}
const rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn") as HTMLButtonElement | null;
if (rdsRawCopyBtn) {
rdsRawCopyBtn.addEventListener("click", () => { copyRdsRawToClipboard(); });
rdsRawCopyBtn.addEventListener("click", () => { void copyRdsRawToClipboard(); });
}
const rdsAfListEl = document.getElementById("rds-af-list");
if (rdsAfListEl) {
@@ -7408,7 +7478,7 @@ function drawSpectrumWaterfall() {
const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90);
const view = spectrumVisibleRange(lastSpectrumData);
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 expectedSize = iW * iH * 4;
const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
@@ -7475,13 +7545,6 @@ function drawSpectrumWaterfall() {
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.
function bmLuminance(hex: string) {
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 freqStr = window.trx.modules.bookmarks
? 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";
if (options.sideStack) {
span.classList.add("spectrum-bookmark-chip-side");
@@ -7626,8 +7687,7 @@ function updateBookmarkAxis(range: SpectrumRange) {
const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
if (!axisEl) return;
const _bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
const allBookmarks = Array.isArray(_bmRef) ? _bmRef : [];
const allBookmarks = window.trx.modules.bookmarks?.overlayList ?? [];
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)
@@ -8439,8 +8499,16 @@ const bandplanLabelsCheck = document.getElementById("bandplan-labels-check") as
(function loadBandplanJson() {
fetch("/bandplan.json")
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
.then((d) => { bandplanData = d; bandplanSegmentsCache = null; bandplanCacheKey = ""; })
.then(async (response) => {
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(() => {});
})();