From 812359c74433e25df3b2e599a4670d2b918b083a Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 22:19:50 +0200 Subject: [PATCH] refactor: enforce typed frontend runtime boundaries --- docs/frontend-architecture.md | 78 ++++ .../assets/web/generated/app.js | 347 ++++++++------- .../frontend/eslint.config.js | 35 +- .../trx-frontend-http/frontend/src/app.ts | 404 ++++++++++-------- 4 files changed, 523 insertions(+), 341 deletions(-) create mode 100644 docs/frontend-architecture.md diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md new file mode 100644 index 00000000..78c4e433 --- /dev/null +++ b/docs/frontend-architecture.md @@ -0,0 +1,78 @@ + + +# 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. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 6f4a4491..99aabae9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -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, "&").replace(//g, ">"); 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(() => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js index 58c50897..caf39e54 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js @@ -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: { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index f9f545bf..73ef96a9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -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 }; } +interface TrxState { + serverLat: number | null; + serverLon: number | null; + readonly lastFreqHz: number | null; + readonly lastActiveRigId: string | null; + readonly lastRigIds: string[]; + readonly lastRigDisplayNames: Record; + 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; + readonly vchanSignalDbById: Map; + 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; 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 { + 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).data; + if (typeof data !== "string") throw new TypeError("Expected a text event payload"); + return data; +} + +async function responseJsonUnknown(response: Response): Promise { + 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; core: Record; modules: TrxModules }; + trx: { state: TrxState; core: Readonly>; 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; - loadPluginsForTab?(tab: string): Promise; 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(".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 = {}; +const prevRenderData: Record = {}; let hintTimer: ReturnType | 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 = {}; 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 | null = null; -let overviewSignalSamples: SignalSample[] = []; +const overviewSignalSamples: SignalSample[] = []; let overviewSignalTimer: ReturnType | 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(); 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) => { 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 | number | null = null; +const uiFrameJobs = new Map void>(); +let uiFrameJobsHandle: ReturnType | 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("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("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); 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) => { 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) => { 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(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(); + const nextSig = new Map(); + 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) => { 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, 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, "&").replace(//g, ">"); 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(() => {}); })();