diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js index b4659723..ea21ccf1 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js @@ -228,15 +228,15 @@ } aisWindow.updateAisBar = updateAisBar; aisWindow.clearAisBar = function() { - aisWindow.resetAisHistoryView?.(); + resetAisHistoryView(); }; - aisWindow.resetAisHistoryView = function() { + function resetAisHistoryView() { if (aisMessagesEl) aisMessagesEl.innerHTML = ""; aisMessageHistory = []; updateAisBar(); renderAisHistory(); aisWindow.clearMapMarkersByType?.("ais"); - }; + } function renderAisHistory() { pruneAisMessageHistory(); if (!aisMessagesEl) { @@ -272,7 +272,7 @@ rig_id: msg.rig_id || null }; } - aisWindow.onServerAisBatch = function(messages) { + function onServerAisBatch(messages) { if (!Array.isArray(messages) || messages.length === 0) return; if (aisStatus) aisStatus.textContent = "Receiving"; const normalized = []; @@ -295,21 +295,18 @@ pruneAisMessageHistory(); scheduleAisBarUpdate(); scheduleAisHistoryRender(); - }; - aisWindow.restoreAisHistory = function(messages) { - aisWindow.onServerAisBatch?.(messages); - }; - aisWindow.pruneAisHistoryView = function() { + } + function pruneAisHistoryView() { pruneAisMessageHistory(); updateAisBar(); renderAisHistory(); - }; + } document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => { if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; try { await aisWindow.postPath?.("/clear_ais_decode"); - aisWindow.resetAisHistoryView?.(); + resetAisHistoryView(); } catch (e) { console.error("AIS history clear failed", e); } @@ -321,10 +318,17 @@ renderAisHistory(); }); } - aisWindow.onServerAis = function(msg) { + function onServerAis(msg) { if (aisStatus) aisStatus.textContent = "Receiving"; addAisMessage(normalizeServerAisMessage(msg)); - }; + } updateAisSummary(); - aisWindow._trxDrainPendingDecode?.("ais"); + window.trxPluginRuntime.registerDecoder({ + id: "ais", + onMessage: onServerAis, + onBatch: onServerAisBatch, + restore: onServerAisBatch, + reset: resetAisHistoryView, + prune: pruneAisHistoryView + }); })(); 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 948ef53e..e4bdb127 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 @@ -488,10 +488,10 @@ function currentDecodeHistoryRetentionMs() { } window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; window.applyDecodeHistoryRetention = function() { - if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView(); - if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView(); - if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView(); - if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView(); + window.trxPluginRuntime.prune("aprs"); + window.trxPluginRuntime.prune("hf_aprs"); + window.trxPluginRuntime.prune("ais"); + window.trxPluginRuntime.prune("vdes"); if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView(); if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView(); }; @@ -529,37 +529,6 @@ function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; let decodeHistoryReplayActive = false; let decodeMapSyncPending = false; -const _pendingDecodeHistory = {}; -const _pendingDecodeLive = {}; -window._trxDrainPendingDecode = function(kind) { - const historyKey = { - ais: "restoreAisHistory", - vdes: "restoreVdesHistory", - aprs: "restoreAprsHistory", - hf_aprs: "restoreHfAprsHistory" - }[kind]; - if (historyKey && _pendingDecodeHistory[kind] && window[historyKey]) { - const msgs = _pendingDecodeHistory[kind]; - delete _pendingDecodeHistory[kind]; - window[historyKey](msgs); - } - const liveKey = { - ais: "onServerAis", - vdes: "onServerVdes", - aprs: "onServerAprs", - hf_aprs: "onServerHfAprs" - }[kind]; - if (liveKey && _pendingDecodeLive[kind] && window[liveKey]) { - const msgs = _pendingDecodeLive[kind]; - delete _pendingDecodeLive[kind]; - for (const msg of msgs) { - try { - window[liveKey](msg); - } catch (_) { - } - } - } -}; function markDecodeMapSyncPending() { decodeMapSyncPending = true; } @@ -5888,21 +5857,8 @@ function updateDecodeStatus(text) { } } function dispatchDecodeMessage(msg, skipStats) { - if (msg.type === "ais") { - if (window.onServerAis) window.onServerAis(msg); - else (_pendingDecodeLive.ais = _pendingDecodeLive.ais || []).push(msg); - } - if (msg.type === "vdes") { - if (window.onServerVdes) window.onServerVdes(msg); - else (_pendingDecodeLive.vdes = _pendingDecodeLive.vdes || []).push(msg); - } - if (msg.type === "aprs") { - if (window.onServerAprs) window.onServerAprs(msg); - else (_pendingDecodeLive.aprs = _pendingDecodeLive.aprs || []).push(msg); - } - if (msg.type === "hf_aprs") { - if (window.onServerHfAprs) window.onServerHfAprs(msg); - else (_pendingDecodeLive.hf_aprs = _pendingDecodeLive.hf_aprs || []).push(msg); + if (msg.type === "ais" || msg.type === "vdes" || msg.type === "aprs" || msg.type === "hf_aprs") { + window.trxPluginRuntime.dispatch(msg.type, msg); } if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg); if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg); @@ -5929,20 +5885,8 @@ function dispatchDecodeBatch(batch) { const type = String(batch[0]?.type || ""); const uniformType = batch.every((msg) => String(msg?.type || "") === type); if (uniformType) { - if (type === "ais" && window.onServerAisBatch) { - window.onServerAisBatch(batch); - return; - } - if (type === "vdes" && window.onServerVdesBatch) { - window.onServerVdesBatch(batch); - return; - } - if (type === "aprs" && window.onServerAprsBatch) { - window.onServerAprsBatch(batch); - return; - } - if (type === "hf_aprs" && window.onServerHfAprsBatch) { - window.onServerHfAprsBatch(batch); + if (type === "ais" || type === "vdes" || type === "aprs" || type === "hf_aprs") { + window.trxPluginRuntime.dispatchBatch(type, batch); return; } if (type === "ft8" && window.onServerFt8Batch) { @@ -6010,36 +5954,8 @@ function restoreDecodeHistoryGroup(kind, messages) { } window.trx.modules.map?.scheduleStatsRender(); } - if (kind === "ais") { - if (window.restoreAisHistory) { - window.restoreAisHistory(messages); - } else { - _pendingDecodeHistory.ais = (_pendingDecodeHistory.ais || []).concat(messages); - } - return; - } - if (kind === "vdes") { - if (window.restoreVdesHistory) { - window.restoreVdesHistory(messages); - } else { - _pendingDecodeHistory.vdes = (_pendingDecodeHistory.vdes || []).concat(messages); - } - return; - } - if (kind === "aprs") { - if (window.restoreAprsHistory) { - window.restoreAprsHistory(messages); - } else { - _pendingDecodeHistory.aprs = (_pendingDecodeHistory.aprs || []).concat(messages); - } - return; - } - if (kind === "hf_aprs") { - if (window.restoreHfAprsHistory) { - window.restoreHfAprsHistory(messages); - } else { - _pendingDecodeHistory.hf_aprs = (_pendingDecodeHistory.hf_aprs || []).concat(messages); - } + if (kind === "ais" || kind === "vdes" || kind === "aprs" || kind === "hf_aprs") { + window.trxPluginRuntime.restore(kind, messages); return; } if (kind === "cw" && window.restoreCwHistory) { @@ -6074,11 +5990,11 @@ function connectDecode() { terminateDecodeHistoryWorker(); decodeHistoryReplayActive = false; decodeMapSyncPending = false; - for (const k in _pendingDecodeHistory) delete _pendingDecodeHistory[k]; - for (const k in _pendingDecodeLive) delete _pendingDecodeLive[k]; - if (window.resetAisHistoryView) window.resetAisHistoryView(); - if (window.resetVdesHistoryView) window.resetVdesHistoryView(); - if (window.resetAprsHistoryView) window.resetAprsHistoryView(); + window.trxPluginRuntime.clearQueued(); + window.trxPluginRuntime.reset("ais"); + window.trxPluginRuntime.reset("vdes"); + window.trxPluginRuntime.reset("aprs"); + window.trxPluginRuntime.reset("hf_aprs"); if (window.resetCwHistoryView) window.resetCwHistoryView(); if (window.resetFt8HistoryView) window.resetFt8HistoryView(); if (window.resetFt4HistoryView) window.resetFt4HistoryView(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index 444d1036..dbb05b92 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -291,7 +291,7 @@ } aprsWindow.updateAprsBar = updateAprsBar; aprsWindow.clearAprsBar = function() { - aprsWindow.resetAprsHistoryView?.(); + resetAprsHistoryView(); }; aprsWindow.closeAprsBar = function() { aprsBarDismissedAtMs = Date.now(); @@ -300,18 +300,18 @@ aprsBarOverlay.innerHTML = ""; } }; - aprsWindow.resetAprsHistoryView = function() { + function resetAprsHistoryView() { if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; aprsPacketHistory = []; updateAprsBar(); renderAprsHistory(); aprsWindow.clearMapMarkersByType?.("aprs"); - }; - aprsWindow.pruneAprsHistoryView = function() { + } + function pruneAprsHistoryView() { pruneAprsPacketHistory(); updateAprsBar(); renderAprsHistory(); - }; + } function addAprsPacket(pkt) { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); pkt._tsMs = tsMs; @@ -327,7 +327,7 @@ function normalizeServerAprsPacket(pkt) { return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null); } - aprsWindow.onServerAprsBatch = function(packets) { + function onServerAprsBatch(packets) { if (!Array.isArray(packets) || packets.length === 0) return; if (aprsStatus) aprsStatus.textContent = "Receiving"; const normalized = []; @@ -348,16 +348,13 @@ pruneAprsPacketHistory(); if (hasCrcOk) scheduleAprsBarUpdate(); scheduleAprsHistoryRender(); - }; - aprsWindow.restoreAprsHistory = function(packets) { - aprsWindow.onServerAprsBatch?.(packets); - }; + } document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => { if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; try { await aprsWindow.postPath?.("/clear_aprs_decode"); - aprsWindow.resetAprsHistoryView?.(); + resetAprsHistoryView(); } catch (e) { console.error("APRS history clear failed", e); } @@ -395,10 +392,17 @@ renderAprsHistory(); }); } - aprsWindow.onServerAprs = function(pkt) { + function onServerAprs(pkt) { if (aprsStatus) aprsStatus.textContent = "Receiving"; addAprsPacket(normalizeServerAprsPacket(pkt)); - }; + } renderAprsHistory(); - aprsWindow._trxDrainPendingDecode?.("aprs"); + window.trxPluginRuntime.registerDecoder({ + id: "aprs", + onMessage: onServerAprs, + onBatch: onServerAprsBatch, + restore: onServerAprsBatch, + reset: resetAprsHistoryView, + prune: pruneAprsHistoryView + }); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js index 90def677..7b9cead2 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -253,15 +253,15 @@ updateHfAprsSummary(); updateHfAprsChipState(); } - hfAprsWindow.resetHfAprsHistoryView = function() { + function resetHfAprsHistoryView() { if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; hfAprsPacketHistory = []; renderHfAprsHistory(); - }; - hfAprsWindow.pruneHfAprsHistoryView = function() { + } + function pruneHfAprsHistoryView() { pruneHfAprsPacketHistory(); renderHfAprsHistory(); - }; + } function addHfAprsPacket(pkt) { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); pkt._tsMs = tsMs; @@ -273,7 +273,7 @@ function normalizeServerHfAprsPacket(pkt) { return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null); } - hfAprsWindow.onServerHfAprsBatch = function(packets) { + function onServerHfAprsBatch(packets) { if (!Array.isArray(packets) || packets.length === 0) return; if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const normalized = []; @@ -288,10 +288,7 @@ hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory); pruneHfAprsPacketHistory(); scheduleHfAprsHistoryRender(); - }; - hfAprsWindow.restoreHfAprsHistory = function(packets) { - hfAprsWindow.onServerHfAprsBatch?.(packets); - }; + } var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn"); hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => { @@ -308,7 +305,7 @@ if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; try { await hfAprsWindow.postPath?.("/clear_hf_aprs_decode"); - hfAprsWindow.resetHfAprsHistoryView?.(); + resetHfAprsHistoryView(); } catch (e) { console.error("HF APRS history clear failed", e); } @@ -346,10 +343,17 @@ renderHfAprsHistory(); }); } - hfAprsWindow.onServerHfAprs = function(pkt) { + function onServerHfAprs(pkt) { if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); - }; + } renderHfAprsHistory(); - hfAprsWindow._trxDrainPendingDecode?.("hf_aprs"); + window.trxPluginRuntime.registerDecoder({ + id: "hf_aprs", + onMessage: onServerHfAprs, + onBatch: onServerHfAprsBatch, + restore: onServerHfAprsBatch, + reset: resetHfAprsHistoryView, + prune: pruneHfAprsHistoryView + }); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js new file mode 100644 index 00000000..7f597432 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js @@ -0,0 +1,80 @@ +"use strict"; +const decoders = /* @__PURE__ */ new Map(); +const queued = /* @__PURE__ */ new Map(); +const MAX_QUEUED_ACTIONS_PER_DECODER = 512; +function enqueue(id, action) { + const actions = queued.get(id) ?? []; + actions.push(action); + if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER); + queued.set(id, actions); +} +function deliver(plugin, action) { + if (action.kind === "message" && plugin.onMessage) { + plugin.onMessage(action.payload); + return true; + } + if (action.kind === "batch" && plugin.onBatch) { + plugin.onBatch(action.payload); + return true; + } + if (action.kind === "restore" && plugin.restore) { + plugin.restore(action.payload); + return true; + } + return false; +} +function dispatchOrQueue(id, action) { + const plugin = decoders.get(id); + if (!plugin) { + enqueue(id, action); + return false; + } + if (!deliver(plugin, action)) { + if (action.kind === "batch" && plugin.onMessage) { + for (const message of action.payload) plugin.onMessage(message); + return true; + } + if (action.kind === "restore" && plugin.onBatch) { + plugin.onBatch(action.payload); + return true; + } + return false; + } + return true; +} +const runtime = { + registerDecoder(plugin) { + if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`); + const erased = plugin; + decoders.set(plugin.id, erased); + const pending = queued.get(plugin.id) ?? []; + queued.delete(plugin.id); + for (const action of pending) deliver(erased, action); + return () => { + if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id); + }; + }, + dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }), + dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }), + restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }), + reset(id) { + const plugin = decoders.get(id); + if (!plugin?.reset) return false; + plugin.reset(); + return true; + }, + resetAll() { + for (const plugin of decoders.values()) plugin.reset?.(); + }, + prune(id) { + const plugin = decoders.get(id); + if (!plugin?.prune) return false; + plugin.prune(); + return true; + }, + clearQueued() { + queued.clear(); + }, + hasDecoder: (id) => decoders.has(id) +}; +window.trxPluginRuntime = runtime; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js index abad6484..19338739 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js @@ -165,14 +165,14 @@ } vdesWindow.updateVdesBar = updateVdesBar; vdesWindow.clearVdesBar = function() { - vdesWindow.resetVdesHistoryView?.(); + resetVdesHistoryView(); }; - vdesWindow.resetVdesHistoryView = function() { + function resetVdesHistoryView() { if (vdesMessagesEl) vdesMessagesEl.innerHTML = ""; vdesMessageHistory = []; updateVdesBar(); renderVdesHistory(); - }; + } function renderVdesHistory() { pruneVdesMessageHistory(); if (!vdesMessagesEl) { @@ -205,7 +205,7 @@ rig_id: msg.rig_id || null }; } - vdesWindow.onServerVdesBatch = function(messages) { + function onServerVdesBatch(messages) { if (!Array.isArray(messages) || messages.length === 0) return; if (vdesStatus) vdesStatus.textContent = "Receiving"; const normalized = []; @@ -228,16 +228,13 @@ pruneVdesMessageHistory(); scheduleVdesBarUpdate(); scheduleVdesHistoryRender(); - }; - vdesWindow.restoreVdesHistory = function(messages) { - vdesWindow.onServerVdesBatch?.(messages); - }; + } document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { void (async () => { if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { await vdesWindow.postPath?.("/clear_vdes_decode"); - vdesWindow.resetVdesHistoryView?.(); + resetVdesHistoryView(); } catch (e) { console.error("VDES history clear failed", e); } @@ -249,19 +246,26 @@ renderVdesHistory(); }); } - vdesWindow.onServerVdes = function(msg) { + function onServerVdes(msg) { if (vdesStatus) vdesStatus.textContent = "Receiving"; const next = normalizeServerVdesMessage(msg); addVdesMessage(next); if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { vdesWindow.vdesMapAddPoint(next); } - }; - vdesWindow.pruneVdesHistoryView = function() { + } + function pruneVdesHistoryView() { pruneVdesMessageHistory(); updateVdesBar(); renderVdesHistory(); - }; + } updateVdesSummary(); - vdesWindow._trxDrainPendingDecode?.("vdes"); + window.trxPluginRuntime.registerDecoder({ + id: "vdes", + onMessage: onServerVdes, + onBatch: onServerVdesBatch, + restore: onServerVdesBatch, + reset: resetVdesHistoryView, + prune: pruneVdesHistoryView + }); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index fa2df4cb..e56fbd00 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -1640,6 +1640,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index a0818503..3d7350c6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -20,6 +20,7 @@ await build({ "ui-core": path.join(sourceDir, "ui-core.ts"), "map-core": path.join(sourceDir, "map-core.js"), "plugin-loader": path.join(sourceDir, "plugin-loader.ts"), + "plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"), screenshot: path.join(sourceDir, "screenshot.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js index b41fdb46..de5058db 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js @@ -567,10 +567,10 @@ function currentDecodeHistoryRetentionMs() { window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; window.applyDecodeHistoryRetention = function() { - if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView(); - if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView(); - if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView(); - if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView(); + window.trxPluginRuntime.prune("aprs"); + window.trxPluginRuntime.prune("hf_aprs"); + window.trxPluginRuntime.prune("ais"); + window.trxPluginRuntime.prune("vdes"); if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView(); if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView(); }; @@ -615,41 +615,6 @@ const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDec let decodeHistoryReplayActive = false; let decodeMapSyncPending = false; -// --- Pending decode data buffers --- -// Map-data plugins (ais.js, aprs.js, vdes.js, hf-aprs.js) are loaded eagerly -// but dynamically-inserted scripts have no guaranteed execution order. If -// decode history or live SSE messages arrive before the plugin handlers are -// registered, buffer them here and let each plugin drain on init. -const _pendingDecodeHistory = {}; -const _pendingDecodeLive = {}; - -window._trxDrainPendingDecode = function(kind) { - const historyKey = { - ais: "restoreAisHistory", - vdes: "restoreVdesHistory", - aprs: "restoreAprsHistory", - hf_aprs: "restoreHfAprsHistory", - }[kind]; - if (historyKey && _pendingDecodeHistory[kind] && window[historyKey]) { - const msgs = _pendingDecodeHistory[kind]; - delete _pendingDecodeHistory[kind]; - window[historyKey](msgs); - } - const liveKey = { - ais: "onServerAis", - vdes: "onServerVdes", - aprs: "onServerAprs", - hf_aprs: "onServerHfAprs", - }[kind]; - if (liveKey && _pendingDecodeLive[kind] && window[liveKey]) { - const msgs = _pendingDecodeLive[kind]; - delete _pendingDecodeLive[kind]; - for (const msg of msgs) { - try { window[liveKey](msg); } catch (_) {} - } - } -}; - function markDecodeMapSyncPending() { decodeMapSyncPending = true; } @@ -6250,10 +6215,9 @@ function updateDecodeStatus(text) { } } function dispatchDecodeMessage(msg, skipStats) { - if (msg.type === "ais") { if (window.onServerAis) window.onServerAis(msg); else (_pendingDecodeLive.ais = _pendingDecodeLive.ais || []).push(msg); } - if (msg.type === "vdes") { if (window.onServerVdes) window.onServerVdes(msg); else (_pendingDecodeLive.vdes = _pendingDecodeLive.vdes || []).push(msg); } - if (msg.type === "aprs") { if (window.onServerAprs) window.onServerAprs(msg); else (_pendingDecodeLive.aprs = _pendingDecodeLive.aprs || []).push(msg); } - if (msg.type === "hf_aprs") { if (window.onServerHfAprs) window.onServerHfAprs(msg); else (_pendingDecodeLive.hf_aprs = _pendingDecodeLive.hf_aprs || []).push(msg); } + if (msg.type === "ais" || msg.type === "vdes" || msg.type === "aprs" || msg.type === "hf_aprs") { + window.trxPluginRuntime.dispatch(msg.type, msg); + } if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg); if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg); if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg); @@ -6281,20 +6245,8 @@ function dispatchDecodeBatch(batch) { const type = String(batch[0]?.type || ""); const uniformType = batch.every((msg) => String(msg?.type || "") === type); if (uniformType) { - if (type === "ais" && window.onServerAisBatch) { - window.onServerAisBatch(batch); - return; - } - if (type === "vdes" && window.onServerVdesBatch) { - window.onServerVdesBatch(batch); - return; - } - if (type === "aprs" && window.onServerAprsBatch) { - window.onServerAprsBatch(batch); - return; - } - if (type === "hf_aprs" && window.onServerHfAprsBatch) { - window.onServerHfAprsBatch(batch); + if (type === "ais" || type === "vdes" || type === "aprs" || type === "hf_aprs") { + window.trxPluginRuntime.dispatchBatch(type, batch); return; } if (type === "ft8" && window.onServerFt8Batch) { @@ -6366,24 +6318,8 @@ function restoreDecodeHistoryGroup(kind, messages) { } window.trx.modules.map?.scheduleStatsRender(); } - if (kind === "ais") { - if (window.restoreAisHistory) { window.restoreAisHistory(messages); } - else { _pendingDecodeHistory.ais = (_pendingDecodeHistory.ais || []).concat(messages); } - return; - } - if (kind === "vdes") { - if (window.restoreVdesHistory) { window.restoreVdesHistory(messages); } - else { _pendingDecodeHistory.vdes = (_pendingDecodeHistory.vdes || []).concat(messages); } - return; - } - if (kind === "aprs") { - if (window.restoreAprsHistory) { window.restoreAprsHistory(messages); } - else { _pendingDecodeHistory.aprs = (_pendingDecodeHistory.aprs || []).concat(messages); } - return; - } - if (kind === "hf_aprs") { - if (window.restoreHfAprsHistory) { window.restoreHfAprsHistory(messages); } - else { _pendingDecodeHistory.hf_aprs = (_pendingDecodeHistory.hf_aprs || []).concat(messages); } + if (kind === "ais" || kind === "vdes" || kind === "aprs" || kind === "hf_aprs") { + window.trxPluginRuntime.restore(kind, messages); return; } if (kind === "cw" && window.restoreCwHistory) { @@ -6417,12 +6353,11 @@ function connectDecode() { terminateDecodeHistoryWorker(); decodeHistoryReplayActive = false; decodeMapSyncPending = false; - // Clear any pending buffers from a previous connection cycle. - for (const k in _pendingDecodeHistory) delete _pendingDecodeHistory[k]; - for (const k in _pendingDecodeLive) delete _pendingDecodeLive[k]; - if (window.resetAisHistoryView) window.resetAisHistoryView(); - if (window.resetVdesHistoryView) window.resetVdesHistoryView(); - if (window.resetAprsHistoryView) window.resetAprsHistoryView(); + window.trxPluginRuntime.clearQueued(); + window.trxPluginRuntime.reset("ais"); + window.trxPluginRuntime.reset("vdes"); + window.trxPluginRuntime.reset("aprs"); + window.trxPluginRuntime.reset("hf_aprs"); if (window.resetCwHistoryView) window.resetCwHistoryView(); if (window.resetFt8HistoryView) window.resetFt8HistoryView(); if (window.resetFt4HistoryView) window.resetFt4HistoryView(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts new file mode 100644 index 00000000..836c3e92 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { DecoderPlugin, PluginRuntimeWindow, TrxPluginRuntime } from "./plugins/runtime-contract"; + +type QueuedAction = + | { kind: "message"; payload: unknown } + | { kind: "batch" | "restore"; payload: unknown[] }; + +const decoders = new Map(); +const queued = new Map(); +const MAX_QUEUED_ACTIONS_PER_DECODER = 512; + +function enqueue(id: string, action: QueuedAction): void { + const actions = queued.get(id) ?? []; + actions.push(action); + if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER); + queued.set(id, actions); +} + +function deliver(plugin: DecoderPlugin, action: QueuedAction): boolean { + if (action.kind === "message" && plugin.onMessage) { + plugin.onMessage(action.payload); + return true; + } + if (action.kind === "batch" && plugin.onBatch) { + plugin.onBatch(action.payload); + return true; + } + if (action.kind === "restore" && plugin.restore) { + plugin.restore(action.payload); + return true; + } + return false; +} + +function dispatchOrQueue(id: string, action: QueuedAction): boolean { + const plugin = decoders.get(id); + if (!plugin) { + enqueue(id, action); + return false; + } + if (!deliver(plugin, action)) { + if (action.kind === "batch" && plugin.onMessage) { + for (const message of action.payload) plugin.onMessage(message); + return true; + } + if (action.kind === "restore" && plugin.onBatch) { + plugin.onBatch(action.payload); + return true; + } + return false; + } + return true; +} + +const runtime: TrxPluginRuntime = { + registerDecoder(plugin: DecoderPlugin): () => void { + if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`); + const erased = plugin as DecoderPlugin; + decoders.set(plugin.id, erased); + const pending = queued.get(plugin.id) ?? []; + queued.delete(plugin.id); + for (const action of pending) deliver(erased, action); + return () => { if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id); }; + }, + dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }), + dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }), + restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }), + reset(id) { + const plugin = decoders.get(id); + if (!plugin?.reset) return false; + plugin.reset(); + return true; + }, + resetAll() { for (const plugin of decoders.values()) plugin.reset?.(); }, + prune(id) { + const plugin = decoders.get(id); + if (!plugin?.prune) return false; + plugin.prune(); + return true; + }, + clearQueued() { queued.clear(); }, + hasDecoder: (id) => decoders.has(id), +}; + +(window as unknown as PluginRuntimeWindow).trxPluginRuntime = runtime; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts index 5b5ba892..342fd0d5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts @@ -4,6 +4,8 @@ export {}; +import type { PluginRuntimeWindow } from "./runtime-contract"; + interface AisMessage { rig_id?: string | null; channel?: string | null; @@ -36,12 +38,6 @@ interface AisBridge { trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; updateAisBar?: () => void; clearAisBar?: () => void; - resetAisHistoryView?: () => void; - onServerAisBatch?: (messages: AisMessage[]) => void; - restoreAisHistory?: (messages: AisMessage[]) => void; - pruneAisHistoryView?: () => void; - onServerAis?: (message: AisMessage) => void; - _trxDrainPendingDecode?: (decoder: string) => void; } const aisWindow = window as unknown as AisBridge; const escapeAisHtml = (input: string): string => aisWindow.escapeMapHtml?.(input) ?? input @@ -324,16 +320,16 @@ function updateAisBar() { } aisWindow.updateAisBar = updateAisBar; aisWindow.clearAisBar = function() { - aisWindow.resetAisHistoryView?.(); + resetAisHistoryView(); }; -aisWindow.resetAisHistoryView = function() { +function resetAisHistoryView(): void { if (aisMessagesEl) aisMessagesEl.innerHTML = ""; aisMessageHistory = []; updateAisBar(); renderAisHistory(); aisWindow.clearMapMarkersByType?.("ais"); -}; +} function renderAisHistory() { pruneAisMessageHistory(); @@ -375,7 +371,7 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage { }; } -aisWindow.onServerAisBatch = function(messages: AisMessage[]) { +function onServerAisBatch(messages: AisMessage[]): void { if (!Array.isArray(messages) || messages.length === 0) return; if (aisStatus) aisStatus.textContent = "Receiving"; const normalized: AisMessage[] = []; @@ -398,23 +394,19 @@ aisWindow.onServerAisBatch = function(messages: AisMessage[]) { pruneAisMessageHistory(); scheduleAisBarUpdate(); scheduleAisHistoryRender(); -}; +} -aisWindow.restoreAisHistory = function(messages: AisMessage[]) { - aisWindow.onServerAisBatch?.(messages); -}; - -aisWindow.pruneAisHistoryView = function() { +function pruneAisHistoryView(): void { pruneAisMessageHistory(); updateAisBar(); renderAisHistory(); -}; +} document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => { if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; try { await aisWindow.postPath?.("/clear_ais_decode"); - aisWindow.resetAisHistoryView?.(); + resetAisHistoryView(); } catch (e) { console.error("AIS history clear failed", e); } @@ -427,10 +419,17 @@ if (aisFilterInput) { }); } -aisWindow.onServerAis = function(msg: AisMessage) { +function onServerAis(msg: AisMessage): void { if (aisStatus) aisStatus.textContent = "Receiving"; addAisMessage(normalizeServerAisMessage(msg)); -}; +} updateAisSummary(); -aisWindow._trxDrainPendingDecode?.("ais"); +(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder({ + id: "ais", + onMessage: onServerAis, + onBatch: onServerAisBatch, + restore: onServerAisBatch, + reset: resetAisHistoryView, + prune: pruneAisHistoryView, +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts index e6e15a28..654e847e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts @@ -14,6 +14,7 @@ import { type AprsPacket, type AprsTypeFilter, } from "./aprs-shared"; +import type { PluginRuntimeWindow } from "./runtime-contract"; interface AprsBridge { getDecodeHistoryRetentionMs?: () => number; trxScheduleUiFrameJob?: (key: string, job: () => void) => void; @@ -31,12 +32,6 @@ interface AprsBridge { updateAprsBar?: () => void; clearAprsBar?: () => void; closeAprsBar?: () => void; - resetAprsHistoryView?: () => void; - pruneAprsHistoryView?: () => void; - onServerAprsBatch?: (packets: AprsPacket[]) => void; - restoreAprsHistory?: (packets: AprsPacket[]) => void; - onServerAprs?: (packet: AprsPacket) => void; - _trxDrainPendingDecode?: (decoder: string) => void; } const aprsWindow = window as unknown as AprsBridge; const escapeAprsHtml = (input: string): string => aprsWindow.escapeMapHtml?.(input) ?? input @@ -288,7 +283,7 @@ function updateAprsBar() { } aprsWindow.updateAprsBar = updateAprsBar; aprsWindow.clearAprsBar = function() { - aprsWindow.resetAprsHistoryView?.(); + resetAprsHistoryView(); }; aprsWindow.closeAprsBar = function() { aprsBarDismissedAtMs = Date.now(); @@ -298,19 +293,19 @@ aprsWindow.closeAprsBar = function() { } }; -aprsWindow.resetAprsHistoryView = function() { +function resetAprsHistoryView(): void { if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; aprsPacketHistory = []; updateAprsBar(); renderAprsHistory(); aprsWindow.clearMapMarkersByType?.("aprs"); -}; +} -aprsWindow.pruneAprsHistoryView = function() { +function pruneAprsHistoryView(): void { pruneAprsPacketHistory(); updateAprsBar(); renderAprsHistory(); -}; +} function addAprsPacket(pkt: AprsPacket): void { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); @@ -333,7 +328,7 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket { return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null); } -aprsWindow.onServerAprsBatch = function(packets: AprsPacket[]) { +function onServerAprsBatch(packets: AprsPacket[]): void { if (!Array.isArray(packets) || packets.length === 0) return; if (aprsStatus) aprsStatus.textContent = "Receiving"; const normalized: AprsPacket[] = []; @@ -354,17 +349,13 @@ aprsWindow.onServerAprsBatch = function(packets: AprsPacket[]) { pruneAprsPacketHistory(); if (hasCrcOk) scheduleAprsBarUpdate(); scheduleAprsHistoryRender(); -}; - -aprsWindow.restoreAprsHistory = function(packets: AprsPacket[]) { - aprsWindow.onServerAprsBatch?.(packets); -}; +} document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => { if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; try { await aprsWindow.postPath?.("/clear_aprs_decode"); - aprsWindow.resetAprsHistoryView?.(); + resetAprsHistoryView(); } catch (e) { console.error("APRS history clear failed", e); } @@ -408,10 +399,17 @@ if (aprsFilterInput) { } // --- Server-side APRS decode handler --- -aprsWindow.onServerAprs = function(pkt: AprsPacket) { +function onServerAprs(pkt: AprsPacket): void { if (aprsStatus) aprsStatus.textContent = "Receiving"; addAprsPacket(normalizeServerAprsPacket(pkt)); -}; +} renderAprsHistory(); -aprsWindow._trxDrainPendingDecode?.("aprs"); +(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder({ + id: "aprs", + onMessage: onServerAprs, + onBatch: onServerAprsBatch, + restore: onServerAprsBatch, + reset: resetAprsHistoryView, + prune: pruneAprsHistoryView, +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts index 47109b5b..2fc3425e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts @@ -14,6 +14,7 @@ import { type AprsPacket, type AprsTypeFilter, } from "./aprs-shared"; +import type { PluginRuntimeWindow } from "./runtime-contract"; interface HfAprsBridge { getDecodeHistoryRetentionMs?: () => number; @@ -28,12 +29,6 @@ interface HfAprsBridge { takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise; postPath?: (path: string) => Promise; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; - resetHfAprsHistoryView?: () => void; - pruneHfAprsHistoryView?: () => void; - onServerHfAprsBatch?: (packets: AprsPacket[]) => void; - restoreHfAprsHistory?: (packets: AprsPacket[]) => void; - onServerHfAprs?: (packet: AprsPacket) => void; - _trxDrainPendingDecode?: (decoder: string) => void; } const hfAprsWindow = window as unknown as HfAprsBridge; const escapeHfAprsHtml = (input: string): string => hfAprsWindow.escapeMapHtml?.(input) ?? input @@ -247,16 +242,16 @@ function renderHfAprsHistory() { updateHfAprsChipState(); } -hfAprsWindow.resetHfAprsHistoryView = function() { +function resetHfAprsHistoryView(): void { if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; hfAprsPacketHistory = []; renderHfAprsHistory(); -}; +} -hfAprsWindow.pruneHfAprsHistoryView = function() { +function pruneHfAprsHistoryView(): void { pruneHfAprsPacketHistory(); renderHfAprsHistory(); -}; +} function addHfAprsPacket(pkt: AprsPacket): void { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); @@ -273,7 +268,7 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket { return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null); } -hfAprsWindow.onServerHfAprsBatch = function(packets: AprsPacket[]) { +function onServerHfAprsBatch(packets: AprsPacket[]): void { if (!Array.isArray(packets) || packets.length === 0) return; if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const normalized: AprsPacket[] = []; @@ -288,11 +283,7 @@ hfAprsWindow.onServerHfAprsBatch = function(packets: AprsPacket[]) { hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory); pruneHfAprsPacketHistory(); scheduleHfAprsHistoryRender(); -}; - -hfAprsWindow.restoreHfAprsHistory = function(packets: AprsPacket[]) { - hfAprsWindow.onServerHfAprsBatch?.(packets); -}; +} const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn"); hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => { @@ -308,7 +299,7 @@ document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("cli if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; try { await hfAprsWindow.postPath?.("/clear_hf_aprs_decode"); - hfAprsWindow.resetHfAprsHistoryView?.(); + resetHfAprsHistoryView(); } catch (e) { console.error("HF APRS history clear failed", e); } @@ -352,10 +343,17 @@ if (hfAprsFilterInput) { } // --- Server-side HF APRS decode handler --- -hfAprsWindow.onServerHfAprs = function(pkt: AprsPacket) { +function onServerHfAprs(pkt: AprsPacket): void { if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); -}; +} renderHfAprsHistory(); -hfAprsWindow._trxDrainPendingDecode?.("hf_aprs"); +(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder({ + id: "hf_aprs", + onMessage: onServerHfAprs, + onBatch: onServerHfAprsBatch, + restore: onServerHfAprsBatch, + reset: resetHfAprsHistoryView, + prune: pruneHfAprsHistoryView, +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts new file mode 100644 index 00000000..80712f51 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +export interface DecoderPlugin { + readonly id: string; + onMessage?(message: TMessage): void; + onBatch?(messages: TMessage[]): void; + restore?(messages: TMessage[]): void; + reset?(): void; + prune?(): void; +} + +export interface TrxPluginRuntime { + registerDecoder(plugin: DecoderPlugin): () => void; + dispatch(id: string, message: unknown): boolean; + dispatchBatch(id: string, messages: unknown[]): boolean; + restore(id: string, messages: unknown[]): boolean; + reset(id: string): boolean; + resetAll(): void; + prune(id: string): boolean; + clearQueued(): void; + hasDecoder(id: string): boolean; +} + +export interface PluginRuntimeWindow { trxPluginRuntime: TrxPluginRuntime } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts index 25cbd75f..b931d905 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts @@ -4,6 +4,8 @@ export {}; +import type { PluginRuntimeWindow } from "./runtime-contract"; + interface VdesMessage { rig_id?: string | null; message_type?: number; @@ -42,12 +44,6 @@ interface VdesBridge { vdesMapAddPoint?: (message: VdesMessage) => void; updateVdesBar?: () => void; clearVdesBar?: () => void; - resetVdesHistoryView?: () => void; - onServerVdesBatch?: (messages: VdesMessage[]) => void; - restoreVdesHistory?: (messages: VdesMessage[]) => void; - onServerVdes?: (message: VdesMessage) => void; - pruneVdesHistoryView?: () => void; - _trxDrainPendingDecode?: (decoder: string) => void; } const vdesWindow = window as unknown as VdesBridge; @@ -267,15 +263,15 @@ function updateVdesBar() { } vdesWindow.updateVdesBar = updateVdesBar; vdesWindow.clearVdesBar = function() { - vdesWindow.resetVdesHistoryView?.(); + resetVdesHistoryView(); }; -vdesWindow.resetVdesHistoryView = function() { +function resetVdesHistoryView(): void { if (vdesMessagesEl) vdesMessagesEl.innerHTML = ""; vdesMessageHistory = []; updateVdesBar(); renderVdesHistory(); -}; +} function renderVdesHistory() { pruneVdesMessageHistory(); @@ -313,7 +309,7 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage { }; } -vdesWindow.onServerVdesBatch = function(messages: VdesMessage[]) { +function onServerVdesBatch(messages: VdesMessage[]): void { if (!Array.isArray(messages) || messages.length === 0) return; if (vdesStatus) vdesStatus.textContent = "Receiving"; const normalized: VdesMessage[] = []; @@ -336,17 +332,13 @@ vdesWindow.onServerVdesBatch = function(messages: VdesMessage[]) { pruneVdesMessageHistory(); scheduleVdesBarUpdate(); scheduleVdesHistoryRender(); -}; - -vdesWindow.restoreVdesHistory = function(messages: VdesMessage[]) { - vdesWindow.onServerVdesBatch?.(messages); -}; +} document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { void (async () => { if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { await vdesWindow.postPath?.("/clear_vdes_decode"); - vdesWindow.resetVdesHistoryView?.(); + resetVdesHistoryView(); } catch (e) { console.error("VDES history clear failed", e); } @@ -359,20 +351,27 @@ if (vdesFilterInput) { }); } -vdesWindow.onServerVdes = function(msg: VdesMessage) { +function onServerVdes(msg: VdesMessage): void { if (vdesStatus) vdesStatus.textContent = "Receiving"; const next = normalizeServerVdesMessage(msg); addVdesMessage(next); if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { vdesWindow.vdesMapAddPoint(next); } -}; +} -vdesWindow.pruneVdesHistoryView = function() { +function pruneVdesHistoryView(): void { pruneVdesMessageHistory(); updateVdesBar(); renderVdesHistory(); -}; +} updateVdesSummary(); -vdesWindow._trxDrainPendingDecode?.("vdes"); +(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder({ + id: "vdes", + onMessage: onServerVdes, + onBatch: onServerVdesBatch, + restore: onServerVdesBatch, + reset: resetVdesHistoryView, + prune: pruneVdesHistoryView, +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs index 3a0e2248..4a12e643 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs @@ -23,10 +23,13 @@ test("AIS entry forwards positioned vessels with normalized metadata", async () Map, console, }); + const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); const source = await readFile(new URL("../../assets/web/generated/ais.js", import.meta.url), "utf8"); + new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); - window.onServerAis({ mmsi: 261000001, lat: 54.5, lon: 18.5, channel: "A", ts_ms: Date.now() }); + window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, lat: 54.5, lon: 18.5, channel: "A", ts_ms: Date.now() }); + assert.equal(window.onServerAis, undefined); assert.equal(forwarded.length, 1); assert.equal(forwarded[0].mmsi, 261000001); assert.equal(forwarded[0].rig_id, null); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs index 4571966f..a8b95aa9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs @@ -25,11 +25,14 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as Reflect, console, }); + const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); const source = await readFile(new URL("../../assets/web/generated/aprs.js", import.meta.url), "utf8"); assert.equal(source.includes("raw.githubusercontent.com"), false); + new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); - window.onServerAprs({ src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, lat: 54.5, lon: 18.5, symbol_table: "/", symbol_code: ">" }); + window.trxPluginRuntime.dispatch("aprs", { src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, lat: 54.5, lon: 18.5, symbol_table: "/", symbol_code: ">" }); + assert.equal(window.onServerAprs, undefined); assert.equal(forwarded.length, 1); assert.equal(forwarded[0][0], "SP0ABC"); assert.equal(forwarded[0][1], 54.5); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs index 9c729b73..ab061222 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs @@ -21,11 +21,13 @@ test("HF APRS entry uses shared typed normalization and local symbols", async () Reflect, console, }); + const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); const source = await readFile(new URL("../../assets/web/generated/hf-aprs.js", import.meta.url), "utf8"); assert.equal(source.includes("raw.githubusercontent.com"), false); + new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); - window.onServerHfAprs({ src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, symbol_table: "/", symbol_code: ">" }); - assert.equal(typeof window.restoreHfAprsHistory, "function"); - assert.equal(typeof window.pruneHfAprsHistoryView, "function"); + window.trxPluginRuntime.dispatch("hf_aprs", { src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, symbol_table: "/", symbol_code: ">" }); + assert.equal(window.onServerHfAprs, undefined); + assert.equal(window.trxPluginRuntime.hasDecoder("hf_aprs"), true); }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs new file mode 100644 index 00000000..b5b1ba81 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +test("plugin runtime queues lazy decoder traffic and flushes in order on registration", async () => { + const window = {}; + const context = vm.createContext({ window, Map, Error }); + const source = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + new vm.Script(source).runInContext(context); + + assert.equal(window.trxPluginRuntime.dispatch("late", { sequence: 1 }), false); + assert.equal(window.trxPluginRuntime.restore("late", [{ sequence: 2 }]), false); + const received = []; + window.trxPluginRuntime.registerDecoder({ + id: "late", + onMessage: (message) => { received.push(["message", message.sequence]); }, + restore: (messages) => { received.push(["restore", messages[0].sequence]); }, + }); + assert.deepEqual(received, [["message", 1], ["restore", 2]]); +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs index 35d30769..df7bd10a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs @@ -22,10 +22,13 @@ test("VDES entry normalizes and forwards positioned server messages", async () = Array, console, }); + const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); const source = await readFile(new URL("../../assets/web/generated/vdes.js", import.meta.url), "utf8"); + new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); - window.onServerVdes({ callsign: "SP0ABC", lat: 54.5, lon: 18.5, bit_len: 120, ts_ms: Date.now() }); + window.trxPluginRuntime.dispatch("vdes", { callsign: "SP0ABC", lat: 54.5, lon: 18.5, bit_len: 120, ts_ms: Date.now() }); + assert.equal(window.onServerVdes, undefined); assert.equal(forwarded.length, 1); assert.equal(forwarded[0].callsign, "SP0ABC"); assert.equal(forwarded[0].rig_id, null); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs index 2632276c..63eb391f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs @@ -36,6 +36,11 @@ define_gz_cache!( status::PLUGIN_LOADER_JS, "plugin-loader.js" ); +define_gz_cache!( + gz_plugin_runtime_js, + status::PLUGIN_RUNTIME_JS, + "plugin-runtime.js" +); define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js"); define_gz_cache!( gz_decode_history_worker_js, @@ -204,6 +209,12 @@ pub(crate) async fn plugin_loader_js(req: HttpRequest) -> impl Responder { static_asset_response(&req, "application/javascript; charset=utf-8", c) } +#[get("/plugin-runtime.js")] +pub(crate) async fn plugin_runtime_js(req: HttpRequest) -> impl Responder { + let c = gz_plugin_runtime_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", c) +} + #[get("/screenshot.js")] pub(crate) async fn screenshot_js(req: HttpRequest) -> impl Responder { let c = gz_screenshot_js(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs index 9c92f34b..033a2f0f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs @@ -646,6 +646,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(assets::ui_core_js) .service(assets::map_core_js) .service(assets::plugin_loader_js) + .service(assets::plugin_runtime_js) .service(assets::screenshot_js) .service(assets::decode_history_worker_js) .service(assets::webgl_renderer_js) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs index ed88a73d..ac53300d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs @@ -15,6 +15,7 @@ pub const APP_JS: &str = include_str!("../assets/web/generated/app.js"); pub const UI_CORE_JS: &str = include_str!("../assets/web/generated/ui-core.js"); pub const MAP_CORE_JS: &str = include_str!("../assets/web/generated/map-core.js"); pub const PLUGIN_LOADER_JS: &str = include_str!("../assets/web/generated/plugin-loader.js"); +pub const PLUGIN_RUNTIME_JS: &str = include_str!("../assets/web/generated/plugin-runtime.js"); pub const SCREENSHOT_JS: &str = include_str!("../assets/web/generated/screenshot.js"); pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/generated/decode-history-worker.js"); @@ -94,6 +95,14 @@ mod tests { .find("/plugin-loader.js") .expect("typed plugin loader is loaded"); assert!(plugin_loader < app, "plugin registry must load before app"); + let plugin_runtime = html + .find("/plugin-runtime.js") + .expect("typed plugin runtime is loaded"); + assert!( + plugin_runtime < plugin_loader, + "plugin runtime must load before the lazy loader" + ); + assert!(PLUGIN_RUNTIME_JS.contains("registerDecoder")); assert!(PLUGIN_LOADER_JS.contains("import(path)")); assert!(html.contains("Scheduler controls")); assert!(html.contains("Audio controls"));