refactor: add typed decoder plugin runtime

This commit is contained in:
sjg
2026-08-01 13:02:54 +02:00
parent 6a83e2e90a
commit 508cf2ba87
23 changed files with 438 additions and 324 deletions
@@ -228,15 +228,15 @@
} }
aisWindow.updateAisBar = updateAisBar; aisWindow.updateAisBar = updateAisBar;
aisWindow.clearAisBar = function() { aisWindow.clearAisBar = function() {
aisWindow.resetAisHistoryView?.(); resetAisHistoryView();
}; };
aisWindow.resetAisHistoryView = function() { function resetAisHistoryView() {
if (aisMessagesEl) aisMessagesEl.innerHTML = ""; if (aisMessagesEl) aisMessagesEl.innerHTML = "";
aisMessageHistory = []; aisMessageHistory = [];
updateAisBar(); updateAisBar();
renderAisHistory(); renderAisHistory();
aisWindow.clearMapMarkersByType?.("ais"); aisWindow.clearMapMarkersByType?.("ais");
}; }
function renderAisHistory() { function renderAisHistory() {
pruneAisMessageHistory(); pruneAisMessageHistory();
if (!aisMessagesEl) { if (!aisMessagesEl) {
@@ -272,7 +272,7 @@
rig_id: msg.rig_id || null rig_id: msg.rig_id || null
}; };
} }
aisWindow.onServerAisBatch = function(messages) { function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving"; if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
@@ -295,21 +295,18 @@
pruneAisMessageHistory(); pruneAisMessageHistory();
scheduleAisBarUpdate(); scheduleAisBarUpdate();
scheduleAisHistoryRender(); scheduleAisHistoryRender();
}; }
aisWindow.restoreAisHistory = function(messages) { function pruneAisHistoryView() {
aisWindow.onServerAisBatch?.(messages);
};
aisWindow.pruneAisHistoryView = function() {
pruneAisMessageHistory(); pruneAisMessageHistory();
updateAisBar(); updateAisBar();
renderAisHistory(); renderAisHistory();
}; }
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => {
void (async () => { void (async () => {
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await aisWindow.postPath?.("/clear_ais_decode"); await aisWindow.postPath?.("/clear_ais_decode");
aisWindow.resetAisHistoryView?.(); resetAisHistoryView();
} catch (e) { } catch (e) {
console.error("AIS history clear failed", e); console.error("AIS history clear failed", e);
} }
@@ -321,10 +318,17 @@
renderAisHistory(); renderAisHistory();
}); });
} }
aisWindow.onServerAis = function(msg) { function onServerAis(msg) {
if (aisStatus) aisStatus.textContent = "Receiving"; if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg)); addAisMessage(normalizeServerAisMessage(msg));
}; }
updateAisSummary(); updateAisSummary();
aisWindow._trxDrainPendingDecode?.("ais"); window.trxPluginRuntime.registerDecoder({
id: "ais",
onMessage: onServerAis,
onBatch: onServerAisBatch,
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView
});
})(); })();
@@ -488,10 +488,10 @@ function currentDecodeHistoryRetentionMs() {
} }
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
window.applyDecodeHistoryRetention = function() { window.applyDecodeHistoryRetention = function() {
if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView(); window.trxPluginRuntime.prune("aprs");
if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView(); window.trxPluginRuntime.prune("hf_aprs");
if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView(); window.trxPluginRuntime.prune("ais");
if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView(); window.trxPluginRuntime.prune("vdes");
if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView(); if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView();
if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView(); 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; const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
let decodeHistoryReplayActive = false; let decodeHistoryReplayActive = false;
let decodeMapSyncPending = 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() { function markDecodeMapSyncPending() {
decodeMapSyncPending = true; decodeMapSyncPending = true;
} }
@@ -5888,21 +5857,8 @@ function updateDecodeStatus(text) {
} }
} }
function dispatchDecodeMessage(msg, skipStats) { function dispatchDecodeMessage(msg, skipStats) {
if (msg.type === "ais") { if (msg.type === "ais" || msg.type === "vdes" || msg.type === "aprs" || msg.type === "hf_aprs") {
if (window.onServerAis) window.onServerAis(msg); window.trxPluginRuntime.dispatch(msg.type, 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 === "cw" && window.onServerCw) window.onServerCw(msg); if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg); if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
@@ -5929,20 +5885,8 @@ function dispatchDecodeBatch(batch) {
const type = String(batch[0]?.type || ""); const type = String(batch[0]?.type || "");
const uniformType = batch.every((msg) => String(msg?.type || "") === type); const uniformType = batch.every((msg) => String(msg?.type || "") === type);
if (uniformType) { if (uniformType) {
if (type === "ais" && window.onServerAisBatch) { if (type === "ais" || type === "vdes" || type === "aprs" || type === "hf_aprs") {
window.onServerAisBatch(batch); window.trxPluginRuntime.dispatchBatch(type, 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);
return; return;
} }
if (type === "ft8" && window.onServerFt8Batch) { if (type === "ft8" && window.onServerFt8Batch) {
@@ -6010,36 +5954,8 @@ function restoreDecodeHistoryGroup(kind, messages) {
} }
window.trx.modules.map?.scheduleStatsRender(); window.trx.modules.map?.scheduleStatsRender();
} }
if (kind === "ais") { if (kind === "ais" || kind === "vdes" || kind === "aprs" || kind === "hf_aprs") {
if (window.restoreAisHistory) { window.trxPluginRuntime.restore(kind, messages);
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);
}
return; return;
} }
if (kind === "cw" && window.restoreCwHistory) { if (kind === "cw" && window.restoreCwHistory) {
@@ -6074,11 +5990,11 @@ function connectDecode() {
terminateDecodeHistoryWorker(); terminateDecodeHistoryWorker();
decodeHistoryReplayActive = false; decodeHistoryReplayActive = false;
decodeMapSyncPending = false; decodeMapSyncPending = false;
for (const k in _pendingDecodeHistory) delete _pendingDecodeHistory[k]; window.trxPluginRuntime.clearQueued();
for (const k in _pendingDecodeLive) delete _pendingDecodeLive[k]; window.trxPluginRuntime.reset("ais");
if (window.resetAisHistoryView) window.resetAisHistoryView(); window.trxPluginRuntime.reset("vdes");
if (window.resetVdesHistoryView) window.resetVdesHistoryView(); window.trxPluginRuntime.reset("aprs");
if (window.resetAprsHistoryView) window.resetAprsHistoryView(); window.trxPluginRuntime.reset("hf_aprs");
if (window.resetCwHistoryView) window.resetCwHistoryView(); if (window.resetCwHistoryView) window.resetCwHistoryView();
if (window.resetFt8HistoryView) window.resetFt8HistoryView(); if (window.resetFt8HistoryView) window.resetFt8HistoryView();
if (window.resetFt4HistoryView) window.resetFt4HistoryView(); if (window.resetFt4HistoryView) window.resetFt4HistoryView();
@@ -291,7 +291,7 @@
} }
aprsWindow.updateAprsBar = updateAprsBar; aprsWindow.updateAprsBar = updateAprsBar;
aprsWindow.clearAprsBar = function() { aprsWindow.clearAprsBar = function() {
aprsWindow.resetAprsHistoryView?.(); resetAprsHistoryView();
}; };
aprsWindow.closeAprsBar = function() { aprsWindow.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now(); aprsBarDismissedAtMs = Date.now();
@@ -300,18 +300,18 @@
aprsBarOverlay.innerHTML = ""; aprsBarOverlay.innerHTML = "";
} }
}; };
aprsWindow.resetAprsHistoryView = function() { function resetAprsHistoryView() {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = []; aprsPacketHistory = [];
updateAprsBar(); updateAprsBar();
renderAprsHistory(); renderAprsHistory();
aprsWindow.clearMapMarkersByType?.("aprs"); aprsWindow.clearMapMarkersByType?.("aprs");
}; }
aprsWindow.pruneAprsHistoryView = function() { function pruneAprsHistoryView() {
pruneAprsPacketHistory(); pruneAprsPacketHistory();
updateAprsBar(); updateAprsBar();
renderAprsHistory(); renderAprsHistory();
}; }
function addAprsPacket(pkt) { function addAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
@@ -327,7 +327,7 @@
function normalizeServerAprsPacket(pkt) { function normalizeServerAprsPacket(pkt) {
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null); return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
} }
aprsWindow.onServerAprsBatch = function(packets) { function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving"; if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
@@ -348,16 +348,13 @@
pruneAprsPacketHistory(); pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate(); if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender(); scheduleAprsHistoryRender();
}; }
aprsWindow.restoreAprsHistory = function(packets) {
aprsWindow.onServerAprsBatch?.(packets);
};
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => {
void (async () => { void (async () => {
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await aprsWindow.postPath?.("/clear_aprs_decode"); await aprsWindow.postPath?.("/clear_aprs_decode");
aprsWindow.resetAprsHistoryView?.(); resetAprsHistoryView();
} catch (e) { } catch (e) {
console.error("APRS history clear failed", e); console.error("APRS history clear failed", e);
} }
@@ -395,10 +392,17 @@
renderAprsHistory(); renderAprsHistory();
}); });
} }
aprsWindow.onServerAprs = function(pkt) { function onServerAprs(pkt) {
if (aprsStatus) aprsStatus.textContent = "Receiving"; if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt)); addAprsPacket(normalizeServerAprsPacket(pkt));
}; }
renderAprsHistory(); renderAprsHistory();
aprsWindow._trxDrainPendingDecode?.("aprs"); window.trxPluginRuntime.registerDecoder({
id: "aprs",
onMessage: onServerAprs,
onBatch: onServerAprsBatch,
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView
});
})(); })();
@@ -253,15 +253,15 @@
updateHfAprsSummary(); updateHfAprsSummary();
updateHfAprsChipState(); updateHfAprsChipState();
} }
hfAprsWindow.resetHfAprsHistoryView = function() { function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = []; hfAprsPacketHistory = [];
renderHfAprsHistory(); renderHfAprsHistory();
}; }
hfAprsWindow.pruneHfAprsHistoryView = function() { function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
renderHfAprsHistory(); renderHfAprsHistory();
}; }
function addHfAprsPacket(pkt) { function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
@@ -273,7 +273,7 @@
function normalizeServerHfAprsPacket(pkt) { function normalizeServerHfAprsPacket(pkt) {
return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null); return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
} }
hfAprsWindow.onServerHfAprsBatch = function(packets) { function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
@@ -288,10 +288,7 @@
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory); hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender(); scheduleHfAprsHistoryRender();
}; }
hfAprsWindow.restoreHfAprsHistory = function(packets) {
hfAprsWindow.onServerHfAprsBatch?.(packets);
};
var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn"); var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
hfAprsDecodeToggleBtn?.addEventListener("click", () => { hfAprsDecodeToggleBtn?.addEventListener("click", () => {
void (async () => { 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; 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 { try {
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode"); await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
hfAprsWindow.resetHfAprsHistoryView?.(); resetHfAprsHistoryView();
} catch (e) { } catch (e) {
console.error("HF APRS history clear failed", e); console.error("HF APRS history clear failed", e);
} }
@@ -346,10 +343,17 @@
renderHfAprsHistory(); renderHfAprsHistory();
}); });
} }
hfAprsWindow.onServerHfAprs = function(pkt) { function onServerHfAprs(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
}; }
renderHfAprsHistory(); renderHfAprsHistory();
hfAprsWindow._trxDrainPendingDecode?.("hf_aprs"); window.trxPluginRuntime.registerDecoder({
id: "hf_aprs",
onMessage: onServerHfAprs,
onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView
});
})(); })();
@@ -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;
@@ -165,14 +165,14 @@
} }
vdesWindow.updateVdesBar = updateVdesBar; vdesWindow.updateVdesBar = updateVdesBar;
vdesWindow.clearVdesBar = function() { vdesWindow.clearVdesBar = function() {
vdesWindow.resetVdesHistoryView?.(); resetVdesHistoryView();
}; };
vdesWindow.resetVdesHistoryView = function() { function resetVdesHistoryView() {
if (vdesMessagesEl) vdesMessagesEl.innerHTML = ""; if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
vdesMessageHistory = []; vdesMessageHistory = [];
updateVdesBar(); updateVdesBar();
renderVdesHistory(); renderVdesHistory();
}; }
function renderVdesHistory() { function renderVdesHistory() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
if (!vdesMessagesEl) { if (!vdesMessagesEl) {
@@ -205,7 +205,7 @@
rig_id: msg.rig_id || null rig_id: msg.rig_id || null
}; };
} }
vdesWindow.onServerVdesBatch = function(messages) { function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
@@ -228,16 +228,13 @@
pruneVdesMessageHistory(); pruneVdesMessageHistory();
scheduleVdesBarUpdate(); scheduleVdesBarUpdate();
scheduleVdesHistoryRender(); scheduleVdesHistoryRender();
}; }
vdesWindow.restoreVdesHistory = function(messages) {
vdesWindow.onServerVdesBatch?.(messages);
};
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => {
void (async () => { void (async () => {
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await vdesWindow.postPath?.("/clear_vdes_decode"); await vdesWindow.postPath?.("/clear_vdes_decode");
vdesWindow.resetVdesHistoryView?.(); resetVdesHistoryView();
} catch (e) { } catch (e) {
console.error("VDES history clear failed", e); console.error("VDES history clear failed", e);
} }
@@ -249,19 +246,26 @@
renderVdesHistory(); renderVdesHistory();
}); });
} }
vdesWindow.onServerVdes = function(msg) { function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
addVdesMessage(next); addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next); vdesWindow.vdesMapAddPoint(next);
} }
}; }
vdesWindow.pruneVdesHistoryView = function() { function pruneVdesHistoryView() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
updateVdesBar(); updateVdesBar();
renderVdesHistory(); renderVdesHistory();
}; }
updateVdesSummary(); updateVdesSummary();
vdesWindow._trxDrainPendingDecode?.("vdes"); window.trxPluginRuntime.registerDecoder({
id: "vdes",
onMessage: onServerVdes,
onBatch: onServerVdesBatch,
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView
});
})(); })();
@@ -1640,6 +1640,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<script defer src="/leaflet-ais-tracksymbol.js"></script> <script defer src="/leaflet-ais-tracksymbol.js"></script>
<script defer src="/webgl-renderer.js"></script> <script defer src="/webgl-renderer.js"></script>
<script defer src="/ui-core.js"></script> <script defer src="/ui-core.js"></script>
<script defer src="/plugin-runtime.js"></script>
<script defer src="/plugin-loader.js"></script> <script defer src="/plugin-loader.js"></script>
<script defer src="/app.js"></script> <script defer src="/app.js"></script>
<!-- Template cloning is handled by navigateToTab() in app.js --> <!-- Template cloning is handled by navigateToTab() in app.js -->
@@ -20,6 +20,7 @@ await build({
"ui-core": path.join(sourceDir, "ui-core.ts"), "ui-core": path.join(sourceDir, "ui-core.ts"),
"map-core": path.join(sourceDir, "map-core.js"), "map-core": path.join(sourceDir, "map-core.js"),
"plugin-loader": path.join(sourceDir, "plugin-loader.ts"), "plugin-loader": path.join(sourceDir, "plugin-loader.ts"),
"plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"), screenshot: path.join(sourceDir, "screenshot.ts"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
@@ -567,10 +567,10 @@ function currentDecodeHistoryRetentionMs() {
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
window.applyDecodeHistoryRetention = function() { window.applyDecodeHistoryRetention = function() {
if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView(); window.trxPluginRuntime.prune("aprs");
if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView(); window.trxPluginRuntime.prune("hf_aprs");
if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView(); window.trxPluginRuntime.prune("ais");
if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView(); window.trxPluginRuntime.prune("vdes");
if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView(); if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView();
if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView(); if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView();
}; };
@@ -615,41 +615,6 @@ const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDec
let decodeHistoryReplayActive = false; let decodeHistoryReplayActive = false;
let decodeMapSyncPending = 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() { function markDecodeMapSyncPending() {
decodeMapSyncPending = true; decodeMapSyncPending = true;
} }
@@ -6250,10 +6215,9 @@ function updateDecodeStatus(text) {
} }
} }
function dispatchDecodeMessage(msg, skipStats) { function dispatchDecodeMessage(msg, skipStats) {
if (msg.type === "ais") { if (window.onServerAis) window.onServerAis(msg); else (_pendingDecodeLive.ais = _pendingDecodeLive.ais || []).push(msg); } if (msg.type === "ais" || msg.type === "vdes" || msg.type === "aprs" || msg.type === "hf_aprs") {
if (msg.type === "vdes") { if (window.onServerVdes) window.onServerVdes(msg); else (_pendingDecodeLive.vdes = _pendingDecodeLive.vdes || []).push(msg); } window.trxPluginRuntime.dispatch(msg.type, 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 === "cw" && window.onServerCw) window.onServerCw(msg); if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg); if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg); if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg);
@@ -6281,20 +6245,8 @@ function dispatchDecodeBatch(batch) {
const type = String(batch[0]?.type || ""); const type = String(batch[0]?.type || "");
const uniformType = batch.every((msg) => String(msg?.type || "") === type); const uniformType = batch.every((msg) => String(msg?.type || "") === type);
if (uniformType) { if (uniformType) {
if (type === "ais" && window.onServerAisBatch) { if (type === "ais" || type === "vdes" || type === "aprs" || type === "hf_aprs") {
window.onServerAisBatch(batch); window.trxPluginRuntime.dispatchBatch(type, 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);
return; return;
} }
if (type === "ft8" && window.onServerFt8Batch) { if (type === "ft8" && window.onServerFt8Batch) {
@@ -6366,24 +6318,8 @@ function restoreDecodeHistoryGroup(kind, messages) {
} }
window.trx.modules.map?.scheduleStatsRender(); window.trx.modules.map?.scheduleStatsRender();
} }
if (kind === "ais") { if (kind === "ais" || kind === "vdes" || kind === "aprs" || kind === "hf_aprs") {
if (window.restoreAisHistory) { window.restoreAisHistory(messages); } window.trxPluginRuntime.restore(kind, 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); }
return; return;
} }
if (kind === "cw" && window.restoreCwHistory) { if (kind === "cw" && window.restoreCwHistory) {
@@ -6417,12 +6353,11 @@ function connectDecode() {
terminateDecodeHistoryWorker(); terminateDecodeHistoryWorker();
decodeHistoryReplayActive = false; decodeHistoryReplayActive = false;
decodeMapSyncPending = false; decodeMapSyncPending = false;
// Clear any pending buffers from a previous connection cycle. window.trxPluginRuntime.clearQueued();
for (const k in _pendingDecodeHistory) delete _pendingDecodeHistory[k]; window.trxPluginRuntime.reset("ais");
for (const k in _pendingDecodeLive) delete _pendingDecodeLive[k]; window.trxPluginRuntime.reset("vdes");
if (window.resetAisHistoryView) window.resetAisHistoryView(); window.trxPluginRuntime.reset("aprs");
if (window.resetVdesHistoryView) window.resetVdesHistoryView(); window.trxPluginRuntime.reset("hf_aprs");
if (window.resetAprsHistoryView) window.resetAprsHistoryView();
if (window.resetCwHistoryView) window.resetCwHistoryView(); if (window.resetCwHistoryView) window.resetCwHistoryView();
if (window.resetFt8HistoryView) window.resetFt8HistoryView(); if (window.resetFt8HistoryView) window.resetFt8HistoryView();
if (window.resetFt4HistoryView) window.resetFt4HistoryView(); if (window.resetFt4HistoryView) window.resetFt4HistoryView();
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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<string, DecoderPlugin>();
const queued = new Map<string, QueuedAction[]>();
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<TMessage>(plugin: DecoderPlugin<TMessage>): () => 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;
@@ -4,6 +4,8 @@
export {}; export {};
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage { interface AisMessage {
rig_id?: string | null; rig_id?: string | null;
channel?: string | null; channel?: string | null;
@@ -36,12 +38,6 @@ interface AisBridge {
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
updateAisBar?: () => void; updateAisBar?: () => void;
clearAisBar?: () => 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 aisWindow = window as unknown as AisBridge;
const escapeAisHtml = (input: string): string => aisWindow.escapeMapHtml?.(input) ?? input const escapeAisHtml = (input: string): string => aisWindow.escapeMapHtml?.(input) ?? input
@@ -324,16 +320,16 @@ function updateAisBar() {
} }
aisWindow.updateAisBar = updateAisBar; aisWindow.updateAisBar = updateAisBar;
aisWindow.clearAisBar = function() { aisWindow.clearAisBar = function() {
aisWindow.resetAisHistoryView?.(); resetAisHistoryView();
}; };
aisWindow.resetAisHistoryView = function() { function resetAisHistoryView(): void {
if (aisMessagesEl) aisMessagesEl.innerHTML = ""; if (aisMessagesEl) aisMessagesEl.innerHTML = "";
aisMessageHistory = []; aisMessageHistory = [];
updateAisBar(); updateAisBar();
renderAisHistory(); renderAisHistory();
aisWindow.clearMapMarkersByType?.("ais"); aisWindow.clearMapMarkersByType?.("ais");
}; }
function renderAisHistory() { function renderAisHistory() {
pruneAisMessageHistory(); 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 (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving"; if (aisStatus) aisStatus.textContent = "Receiving";
const normalized: AisMessage[] = []; const normalized: AisMessage[] = [];
@@ -398,23 +394,19 @@ aisWindow.onServerAisBatch = function(messages: AisMessage[]) {
pruneAisMessageHistory(); pruneAisMessageHistory();
scheduleAisBarUpdate(); scheduleAisBarUpdate();
scheduleAisHistoryRender(); scheduleAisHistoryRender();
}; }
aisWindow.restoreAisHistory = function(messages: AisMessage[]) { function pruneAisHistoryView(): void {
aisWindow.onServerAisBatch?.(messages);
};
aisWindow.pruneAisHistoryView = function() {
pruneAisMessageHistory(); pruneAisMessageHistory();
updateAisBar(); updateAisBar();
renderAisHistory(); renderAisHistory();
}; }
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => { 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; if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await aisWindow.postPath?.("/clear_ais_decode"); await aisWindow.postPath?.("/clear_ais_decode");
aisWindow.resetAisHistoryView?.(); resetAisHistoryView();
} catch (e) { } catch (e) {
console.error("AIS history clear failed", 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"; if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg)); addAisMessage(normalizeServerAisMessage(msg));
}; }
updateAisSummary(); updateAisSummary();
aisWindow._trxDrainPendingDecode?.("ais"); (window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AisMessage>({
id: "ais",
onMessage: onServerAis,
onBatch: onServerAisBatch,
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
});
@@ -14,6 +14,7 @@ import {
type AprsPacket, type AprsPacket,
type AprsTypeFilter, type AprsTypeFilter,
} from "./aprs-shared"; } from "./aprs-shared";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AprsBridge { interface AprsBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
@@ -31,12 +32,6 @@ interface AprsBridge {
updateAprsBar?: () => void; updateAprsBar?: () => void;
clearAprsBar?: () => void; clearAprsBar?: () => void;
closeAprsBar?: () => 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 aprsWindow = window as unknown as AprsBridge;
const escapeAprsHtml = (input: string): string => aprsWindow.escapeMapHtml?.(input) ?? input const escapeAprsHtml = (input: string): string => aprsWindow.escapeMapHtml?.(input) ?? input
@@ -288,7 +283,7 @@ function updateAprsBar() {
} }
aprsWindow.updateAprsBar = updateAprsBar; aprsWindow.updateAprsBar = updateAprsBar;
aprsWindow.clearAprsBar = function() { aprsWindow.clearAprsBar = function() {
aprsWindow.resetAprsHistoryView?.(); resetAprsHistoryView();
}; };
aprsWindow.closeAprsBar = function() { aprsWindow.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now(); aprsBarDismissedAtMs = Date.now();
@@ -298,19 +293,19 @@ aprsWindow.closeAprsBar = function() {
} }
}; };
aprsWindow.resetAprsHistoryView = function() { function resetAprsHistoryView(): void {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = []; aprsPacketHistory = [];
updateAprsBar(); updateAprsBar();
renderAprsHistory(); renderAprsHistory();
aprsWindow.clearMapMarkersByType?.("aprs"); aprsWindow.clearMapMarkersByType?.("aprs");
}; }
aprsWindow.pruneAprsHistoryView = function() { function pruneAprsHistoryView(): void {
pruneAprsPacketHistory(); pruneAprsPacketHistory();
updateAprsBar(); updateAprsBar();
renderAprsHistory(); renderAprsHistory();
}; }
function addAprsPacket(pkt: AprsPacket): void { function addAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); 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); 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 (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving"; if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = []; const normalized: AprsPacket[] = [];
@@ -354,17 +349,13 @@ aprsWindow.onServerAprsBatch = function(packets: AprsPacket[]) {
pruneAprsPacketHistory(); pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate(); if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender(); scheduleAprsHistoryRender();
}; }
aprsWindow.restoreAprsHistory = function(packets: AprsPacket[]) {
aprsWindow.onServerAprsBatch?.(packets);
};
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => { 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; if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await aprsWindow.postPath?.("/clear_aprs_decode"); await aprsWindow.postPath?.("/clear_aprs_decode");
aprsWindow.resetAprsHistoryView?.(); resetAprsHistoryView();
} catch (e) { } catch (e) {
console.error("APRS history clear failed", e); console.error("APRS history clear failed", e);
} }
@@ -408,10 +399,17 @@ if (aprsFilterInput) {
} }
// --- Server-side APRS decode handler --- // --- Server-side APRS decode handler ---
aprsWindow.onServerAprs = function(pkt: AprsPacket) { function onServerAprs(pkt: AprsPacket): void {
if (aprsStatus) aprsStatus.textContent = "Receiving"; if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt)); addAprsPacket(normalizeServerAprsPacket(pkt));
}; }
renderAprsHistory(); renderAprsHistory();
aprsWindow._trxDrainPendingDecode?.("aprs"); (window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AprsPacket>({
id: "aprs",
onMessage: onServerAprs,
onBatch: onServerAprsBatch,
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
});
@@ -14,6 +14,7 @@ import {
type AprsPacket, type AprsPacket,
type AprsTypeFilter, type AprsTypeFilter,
} from "./aprs-shared"; } from "./aprs-shared";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface HfAprsBridge { interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
@@ -28,12 +29,6 @@ interface HfAprsBridge {
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
postPath?: (path: string) => Promise<unknown>; postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
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 hfAprsWindow = window as unknown as HfAprsBridge;
const escapeHfAprsHtml = (input: string): string => hfAprsWindow.escapeMapHtml?.(input) ?? input const escapeHfAprsHtml = (input: string): string => hfAprsWindow.escapeMapHtml?.(input) ?? input
@@ -247,16 +242,16 @@ function renderHfAprsHistory() {
updateHfAprsChipState(); updateHfAprsChipState();
} }
hfAprsWindow.resetHfAprsHistoryView = function() { function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = []; hfAprsPacketHistory = [];
renderHfAprsHistory(); renderHfAprsHistory();
}; }
hfAprsWindow.pruneHfAprsHistoryView = function() { function pruneHfAprsHistoryView(): void {
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
renderHfAprsHistory(); renderHfAprsHistory();
}; }
function addHfAprsPacket(pkt: AprsPacket): void { function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); 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); 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 (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = []; const normalized: AprsPacket[] = [];
@@ -288,11 +283,7 @@ hfAprsWindow.onServerHfAprsBatch = function(packets: AprsPacket[]) {
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory); hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender(); scheduleHfAprsHistoryRender();
}; }
hfAprsWindow.restoreHfAprsHistory = function(packets: AprsPacket[]) {
hfAprsWindow.onServerHfAprsBatch?.(packets);
};
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn"); const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => { 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; 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 { try {
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode"); await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
hfAprsWindow.resetHfAprsHistoryView?.(); resetHfAprsHistoryView();
} catch (e) { } catch (e) {
console.error("HF APRS history clear failed", e); console.error("HF APRS history clear failed", e);
} }
@@ -352,10 +343,17 @@ if (hfAprsFilterInput) {
} }
// --- Server-side HF APRS decode handler --- // --- Server-side HF APRS decode handler ---
hfAprsWindow.onServerHfAprs = function(pkt: AprsPacket) { function onServerHfAprs(pkt: AprsPacket): void {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
}; }
renderHfAprsHistory(); renderHfAprsHistory();
hfAprsWindow._trxDrainPendingDecode?.("hf_aprs"); (window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AprsPacket>({
id: "hf_aprs",
onMessage: onServerHfAprs,
onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView,
});
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export interface DecoderPlugin<TMessage = unknown> {
readonly id: string;
onMessage?(message: TMessage): void;
onBatch?(messages: TMessage[]): void;
restore?(messages: TMessage[]): void;
reset?(): void;
prune?(): void;
}
export interface TrxPluginRuntime {
registerDecoder<TMessage>(plugin: DecoderPlugin<TMessage>): () => 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 }
@@ -4,6 +4,8 @@
export {}; export {};
import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage { interface VdesMessage {
rig_id?: string | null; rig_id?: string | null;
message_type?: number; message_type?: number;
@@ -42,12 +44,6 @@ interface VdesBridge {
vdesMapAddPoint?: (message: VdesMessage) => void; vdesMapAddPoint?: (message: VdesMessage) => void;
updateVdesBar?: () => void; updateVdesBar?: () => void;
clearVdesBar?: () => 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; const vdesWindow = window as unknown as VdesBridge;
@@ -267,15 +263,15 @@ function updateVdesBar() {
} }
vdesWindow.updateVdesBar = updateVdesBar; vdesWindow.updateVdesBar = updateVdesBar;
vdesWindow.clearVdesBar = function() { vdesWindow.clearVdesBar = function() {
vdesWindow.resetVdesHistoryView?.(); resetVdesHistoryView();
}; };
vdesWindow.resetVdesHistoryView = function() { function resetVdesHistoryView(): void {
if (vdesMessagesEl) vdesMessagesEl.innerHTML = ""; if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
vdesMessageHistory = []; vdesMessageHistory = [];
updateVdesBar(); updateVdesBar();
renderVdesHistory(); renderVdesHistory();
}; }
function renderVdesHistory() { function renderVdesHistory() {
pruneVdesMessageHistory(); 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 (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized: VdesMessage[] = []; const normalized: VdesMessage[] = [];
@@ -336,17 +332,13 @@ vdesWindow.onServerVdesBatch = function(messages: VdesMessage[]) {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
scheduleVdesBarUpdate(); scheduleVdesBarUpdate();
scheduleVdesHistoryRender(); scheduleVdesHistoryRender();
}; }
vdesWindow.restoreVdesHistory = function(messages: VdesMessage[]) {
vdesWindow.onServerVdesBatch?.(messages);
};
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { void (async () => { 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; if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await vdesWindow.postPath?.("/clear_vdes_decode"); await vdesWindow.postPath?.("/clear_vdes_decode");
vdesWindow.resetVdesHistoryView?.(); resetVdesHistoryView();
} catch (e) { } catch (e) {
console.error("VDES history clear failed", 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"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
addVdesMessage(next); addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next); vdesWindow.vdesMapAddPoint(next);
} }
}; }
vdesWindow.pruneVdesHistoryView = function() { function pruneVdesHistoryView(): void {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
updateVdesBar(); updateVdesBar();
renderVdesHistory(); renderVdesHistory();
}; }
updateVdesSummary(); updateVdesSummary();
vdesWindow._trxDrainPendingDecode?.("vdes"); (window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<VdesMessage>({
id: "vdes",
onMessage: onServerVdes,
onBatch: onServerVdesBatch,
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
});
@@ -23,10 +23,13 @@ test("AIS entry forwards positioned vessels with normalized metadata", async ()
Map, Map,
console, 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"); 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); 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.length, 1);
assert.equal(forwarded[0].mmsi, 261000001); assert.equal(forwarded[0].mmsi, 261000001);
assert.equal(forwarded[0].rig_id, null); assert.equal(forwarded[0].rig_id, null);
@@ -25,11 +25,14 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as
Reflect, Reflect,
console, 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"); const source = await readFile(new URL("../../assets/web/generated/aprs.js", import.meta.url), "utf8");
assert.equal(source.includes("raw.githubusercontent.com"), false); assert.equal(source.includes("raw.githubusercontent.com"), false);
new vm.Script(runtime).runInContext(context);
new vm.Script(source).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.length, 1);
assert.equal(forwarded[0][0], "SP0ABC"); assert.equal(forwarded[0][0], "SP0ABC");
assert.equal(forwarded[0][1], 54.5); assert.equal(forwarded[0][1], 54.5);
@@ -21,11 +21,13 @@ test("HF APRS entry uses shared typed normalization and local symbols", async ()
Reflect, Reflect,
console, 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"); const source = await readFile(new URL("../../assets/web/generated/hf-aprs.js", import.meta.url), "utf8");
assert.equal(source.includes("raw.githubusercontent.com"), false); assert.equal(source.includes("raw.githubusercontent.com"), false);
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);
window.onServerHfAprs({ src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, symbol_table: "/", symbol_code: ">" }); window.trxPluginRuntime.dispatch("hf_aprs", { src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, symbol_table: "/", symbol_code: ">" });
assert.equal(typeof window.restoreHfAprsHistory, "function"); assert.equal(window.onServerHfAprs, undefined);
assert.equal(typeof window.pruneHfAprsHistoryView, "function"); assert.equal(window.trxPluginRuntime.hasDecoder("hf_aprs"), true);
}); });
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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]]);
});
@@ -22,10 +22,13 @@ test("VDES entry normalizes and forwards positioned server messages", async () =
Array, Array,
console, 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"); 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); 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.length, 1);
assert.equal(forwarded[0].callsign, "SP0ABC"); assert.equal(forwarded[0].callsign, "SP0ABC");
assert.equal(forwarded[0].rig_id, null); assert.equal(forwarded[0].rig_id, null);
@@ -36,6 +36,11 @@ define_gz_cache!(
status::PLUGIN_LOADER_JS, status::PLUGIN_LOADER_JS,
"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_screenshot_js, status::SCREENSHOT_JS, "screenshot.js");
define_gz_cache!( define_gz_cache!(
gz_decode_history_worker_js, 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) 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")] #[get("/screenshot.js")]
pub(crate) async fn screenshot_js(req: HttpRequest) -> impl Responder { pub(crate) async fn screenshot_js(req: HttpRequest) -> impl Responder {
let c = gz_screenshot_js(); let c = gz_screenshot_js();
@@ -646,6 +646,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::ui_core_js) .service(assets::ui_core_js)
.service(assets::map_core_js) .service(assets::map_core_js)
.service(assets::plugin_loader_js) .service(assets::plugin_loader_js)
.service(assets::plugin_runtime_js)
.service(assets::screenshot_js) .service(assets::screenshot_js)
.service(assets::decode_history_worker_js) .service(assets::decode_history_worker_js)
.service(assets::webgl_renderer_js) .service(assets::webgl_renderer_js)
@@ -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 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 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_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 SCREENSHOT_JS: &str = include_str!("../assets/web/generated/screenshot.js");
pub const DECODE_HISTORY_WORKER_JS: &str = pub const DECODE_HISTORY_WORKER_JS: &str =
include_str!("../assets/web/generated/decode-history-worker.js"); include_str!("../assets/web/generated/decode-history-worker.js");
@@ -94,6 +95,14 @@ mod tests {
.find("/plugin-loader.js") .find("/plugin-loader.js")
.expect("typed plugin loader is loaded"); .expect("typed plugin loader is loaded");
assert!(plugin_loader < app, "plugin registry must load before app"); 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!(PLUGIN_LOADER_JS.contains("import(path)"));
assert!(html.contains("<summary>Scheduler controls</summary>")); assert!(html.contains("<summary>Scheduler controls</summary>"));
assert!(html.contains("<summary>Audio controls</summary>")); assert!(html.contains("<summary>Audio controls</summary>"));