[fix](trx-frontend-http): restore bookmark host contract
The TypeScript migration turned app.js from a classic script into an ES module, so its top-level declarations stopped being shared globals. bookmarks.ts was converted verbatim and kept reading them as window properties, which app.ts no longer publishes. Every bookmark interaction read undefined: the Add Bookmark and Select All buttons stayed hidden because the auth check saw no authEnabled or authRole, per-rig scopes were missing from the scope picker and the move target, decoder checkboxes were never built, and Tune threw on bridge.postPath before issuing a single request. Extend the typed window.trx host contract instead of restoring globals, as docs/frontend-architecture.md closes the standalone window property list. trx.state publishes authEnabled; trx.core publishes setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency, syncBandwidthInput, scheduleSpectrumDraw, and onDecoderRegistryReady. Replace the vchan setRigFrequency wrapper with an interceptFrequency service method, matching interceptMode and interceptBandwidth. The wrapper captured an undefined original and silently dropped every tune; routing interception through setRigFrequency also restores virtual channel redirection for the application's own tuning. Read registry-built elements through bmOptionalEl, since bmEl throws and the decoder checkboxes and decode toggle buttons are legitimately absent until the registry arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -3625,12 +3625,19 @@ async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardB
|
|||||||
}
|
}
|
||||||
var _freqOptimisticHz = null;
|
var _freqOptimisticHz = null;
|
||||||
var _freqOptimisticSeq = 0;
|
var _freqOptimisticSeq = 0;
|
||||||
|
function armOptimisticFrequency(freqHz) {
|
||||||
|
if (!isFiniteNumber(freqHz)) return;
|
||||||
|
_freqOptimisticSeq += 1;
|
||||||
|
_freqOptimisticHz = Math.round(freqHz);
|
||||||
|
}
|
||||||
function setRigFrequency(freqHz) {
|
function setRigFrequency(freqHz) {
|
||||||
const targetHz = Math.round(freqHz);
|
const targetHz = Math.round(freqHz);
|
||||||
if (!freqAllowed(targetHz)) {
|
if (!freqAllowed(targetHz)) {
|
||||||
showUnsupportedFreqPopup(targetHz);
|
showUnsupportedFreqPopup(targetHz);
|
||||||
throw new Error(`Unsupported frequency: ${targetHz}`);
|
throw new Error(`Unsupported frequency: ${targetHz}`);
|
||||||
}
|
}
|
||||||
|
if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return;
|
||||||
|
void window.trx?.modules.vchan?.takeSchedulerControl();
|
||||||
const prevFreqHz = lastFreqHz;
|
const prevFreqHz = lastFreqHz;
|
||||||
const seq = ++_freqOptimisticSeq;
|
const seq = ++_freqOptimisticSeq;
|
||||||
_freqOptimisticHz = targetHz;
|
_freqOptimisticHz = targetHz;
|
||||||
@@ -5713,6 +5720,9 @@ Object.defineProperties(trxState, {
|
|||||||
decodeHistoryRetentionMin: { get() {
|
decodeHistoryRetentionMin: { get() {
|
||||||
return decodeHistoryRetentionMin;
|
return decodeHistoryRetentionMin;
|
||||||
} },
|
} },
|
||||||
|
authEnabled: { get() {
|
||||||
|
return authEnabled;
|
||||||
|
} },
|
||||||
authRole: { get() {
|
authRole: { get() {
|
||||||
return authRole;
|
return authRole;
|
||||||
} },
|
} },
|
||||||
@@ -5816,6 +5826,12 @@ var trxCore = Object.freeze({
|
|||||||
scheduleUiFrameJob,
|
scheduleUiFrameJob,
|
||||||
navigateToTab,
|
navigateToTab,
|
||||||
rigBadgeColor,
|
rigBadgeColor,
|
||||||
|
setRigFrequency,
|
||||||
|
applyLocalTunedFrequency,
|
||||||
|
armOptimisticFrequency,
|
||||||
|
syncBandwidthInput,
|
||||||
|
scheduleSpectrumDraw,
|
||||||
|
onDecoderRegistryReady,
|
||||||
latLonToMaidenhead,
|
latLonToMaidenhead,
|
||||||
locatorToLatLon,
|
locatorToLatLon,
|
||||||
haversineKm,
|
haversineKm,
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
// src/plugins/bookmarks.ts
|
// src/plugins/bookmarks.ts
|
||||||
var bridge = window;
|
var bridge = window;
|
||||||
|
var trxState = bridge.trx.state;
|
||||||
|
var trxCore = bridge.trx.core;
|
||||||
function bmEl(id) {
|
function bmEl(id) {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
function bmOptionalEl(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
function errorMessage(error) {
|
function errorMessage(error) {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
@@ -34,7 +39,7 @@ function bmEsc(str) {
|
|||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
function bmCanControl() {
|
function bmCanControl() {
|
||||||
return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control";
|
return !trxState.authEnabled || trxState.authRole === "control";
|
||||||
}
|
}
|
||||||
function bmSyncAccess() {
|
function bmSyncAccess() {
|
||||||
const canCtrl = bmCanControl();
|
const canCtrl = bmCanControl();
|
||||||
@@ -44,8 +49,7 @@ function bmSyncAccess() {
|
|||||||
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||||
}
|
}
|
||||||
function bmListScope() {
|
function bmListScope() {
|
||||||
const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null;
|
return trxState.lastActiveRigId || "general";
|
||||||
return rig || "general";
|
|
||||||
}
|
}
|
||||||
async function bmFetchOverlay() {
|
async function bmFetchOverlay() {
|
||||||
const overlayScope = bmListScope();
|
const overlayScope = bmListScope();
|
||||||
@@ -61,7 +65,7 @@ async function bmFetchOverlay() {
|
|||||||
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
||||||
bridge.syncBookmarkMapLocators(bmOverlayList);
|
bridge.syncBookmarkMapLocators(bmOverlayList);
|
||||||
}
|
}
|
||||||
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
|
trxCore.scheduleSpectrumDraw();
|
||||||
}
|
}
|
||||||
async function bmFetch(categoryFilter) {
|
async function bmFetch(categoryFilter) {
|
||||||
let url = "/bookmarks";
|
let url = "/bookmarks";
|
||||||
@@ -184,12 +188,12 @@ function bmChangePage(delta) {
|
|||||||
bmRender(bmFilteredList);
|
bmRender(bmFilteredList);
|
||||||
}
|
}
|
||||||
function bmReadDecoders() {
|
function bmReadDecoders() {
|
||||||
return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
return trxState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
||||||
}
|
}
|
||||||
function bmWriteDecoders(decoders) {
|
function bmWriteDecoders(decoders) {
|
||||||
const set = new Set(decoders || []);
|
const set = new Set(decoders || []);
|
||||||
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
|
trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||||
const el = bmEl("bm-dec-" + d.id);
|
const el = bmOptionalEl("bm-dec-" + d.id);
|
||||||
if (el) el.checked = set.has(d.id);
|
if (el) el.checked = set.has(d.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -197,7 +201,7 @@ function bmBuildDecoderCheckboxes() {
|
|||||||
const container = bmEl("bm-decoder-checkboxes");
|
const container = bmEl("bm-decoder-checkboxes");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
|
trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.className = "bm-decoder-check";
|
label.className = "bm-decoder-check";
|
||||||
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
||||||
@@ -227,19 +231,17 @@ function bmCloseForm() {
|
|||||||
if (wrap) wrap.style.display = "none";
|
if (wrap) wrap.style.display = "none";
|
||||||
}
|
}
|
||||||
function bmPrefillFromStatus() {
|
function bmPrefillFromStatus() {
|
||||||
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
|
const freqHz = trxState.lastFreqHz;
|
||||||
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
|
if (freqHz != null && Number.isFinite(freqHz)) {
|
||||||
|
bmEl("bm-freq").value = String(Math.round(freqHz));
|
||||||
}
|
}
|
||||||
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
|
if (trxState.lastModeName) {
|
||||||
bmEl("bm-mode").value = bridge.lastModeName;
|
bmEl("bm-mode").value = trxState.lastModeName;
|
||||||
}
|
}
|
||||||
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
|
if (trxState.currentBandwidthHz > 0) {
|
||||||
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
|
bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz));
|
||||||
}
|
}
|
||||||
const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => {
|
const activeDecoders = trxState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id);
|
||||||
const btn = bmEl(d.id + "-decode-toggle-btn");
|
|
||||||
return btn && btn.dataset.enabled === "true";
|
|
||||||
}).map((d) => d.id);
|
|
||||||
bmWriteDecoders(activeDecoders);
|
bmWriteDecoders(activeDecoders);
|
||||||
}
|
}
|
||||||
async function bmSave(e) {
|
async function bmSave(e) {
|
||||||
@@ -320,55 +322,43 @@ async function bmDelete(id) {
|
|||||||
}
|
}
|
||||||
function bmApply(bm) {
|
function bmApply(bm) {
|
||||||
try {
|
try {
|
||||||
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
|
const modeEl = document.getElementById("mode");
|
||||||
bridge.modeEl.value = (bm.mode || "").toUpperCase();
|
if (modeEl) {
|
||||||
|
modeEl.value = (bm.mode || "").toUpperCase();
|
||||||
}
|
}
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
if (typeof bridge.currentBandwidthHz !== "undefined") {
|
trxState.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
trxCore.syncBandwidthInput(bm.bandwidth_hz);
|
||||||
}
|
|
||||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
|
||||||
if (typeof bridge.syncBandwidthInput === "function") {
|
|
||||||
bridge.syncBandwidthInput(bm.bandwidth_hz);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (typeof bridge.applyLocalTunedFrequency === "function") {
|
trxCore.armOptimisticFrequency(bm.freq_hz);
|
||||||
if (typeof bridge._freqOptimisticSeq !== "undefined") {
|
trxCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||||
++bridge._freqOptimisticSeq;
|
if (trxState.lastSpectrumData) {
|
||||||
bridge._freqOptimisticHz = bm.freq_hz;
|
trxCore.scheduleSpectrumDraw();
|
||||||
}
|
|
||||||
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
|
|
||||||
}
|
|
||||||
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
|
|
||||||
bridge.scheduleSpectrumDraw();
|
|
||||||
}
|
}
|
||||||
const tunePromise = (async () => {
|
const tunePromise = (async () => {
|
||||||
await bridge.trx?.modules?.vchan?.takeSchedulerControl();
|
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
||||||
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false;
|
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||||
if (!onVirtual) {
|
if (!onVirtual) {
|
||||||
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
}
|
}
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
const bwHandledByVchan = await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||||
if (!bwHandledByVchan) {
|
if (!bwHandledByVchan) {
|
||||||
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof bridge.setRigFrequency === "function") {
|
trxCore.setRigFrequency(bm.freq_hz);
|
||||||
await bridge.setRigFrequency(bm.freq_hz);
|
|
||||||
} else {
|
|
||||||
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||||
const modeUp = (bm.mode || "").toUpperCase();
|
const modeUp = (bm.mode || "").toUpperCase();
|
||||||
const allToggleDecoders = (bridge.decoderRegistry || []).filter(
|
const allToggleDecoders = trxState.decoderRegistry.filter(
|
||||||
(d) => d.activation === "toggle"
|
(d) => d.activation === "toggle"
|
||||||
);
|
);
|
||||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||||
let statusUrl = "/status";
|
let statusUrl = "/status";
|
||||||
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
|
const rigId = trxState.lastActiveRigId;
|
||||||
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
|
if (rigId) {
|
||||||
|
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
||||||
}
|
}
|
||||||
const statusResp = await fetch(statusUrl);
|
const statusResp = await fetch(statusUrl);
|
||||||
if (!statusResp.ok) return;
|
if (!statusResp.ok) return;
|
||||||
@@ -387,7 +377,7 @@ function bmApply(bm) {
|
|||||||
wanted = currentlyOn;
|
wanted = currentlyOn;
|
||||||
}
|
}
|
||||||
if (wanted !== currentlyOn) {
|
if (wanted !== currentlyOn) {
|
||||||
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (toggles.length) await Promise.all(toggles);
|
if (toggles.length) await Promise.all(toggles);
|
||||||
@@ -399,8 +389,6 @@ function bmApply(bm) {
|
|||||||
console.error("Failed to apply bookmark:", err);
|
console.error("Failed to apply bookmark:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bridge.trx ??= {};
|
|
||||||
bridge.trx.modules ??= {};
|
|
||||||
bridge.trx.modules.bookmarks = {
|
bridge.trx.modules.bookmarks = {
|
||||||
get overlayList() {
|
get overlayList() {
|
||||||
return bmOverlayList;
|
return bmOverlayList;
|
||||||
@@ -439,8 +427,8 @@ function bmUpdateSelectionUi() {
|
|||||||
function bmPopulateMoveTarget() {
|
function bmPopulateMoveTarget() {
|
||||||
const sel = bmEl("bm-move-target");
|
const sel = bmEl("bm-move-target");
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
|
const rigIds = trxState.lastRigIds;
|
||||||
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
|
const displayNames = trxState.lastRigDisplayNames;
|
||||||
const prev = sel.value;
|
const prev = sel.value;
|
||||||
sel.innerHTML = "";
|
sel.innerHTML = "";
|
||||||
if (bmScope !== "general") {
|
if (bmScope !== "general") {
|
||||||
@@ -545,8 +533,8 @@ async function bmDeleteSelected() {
|
|||||||
function bmPopulateScopePicker() {
|
function bmPopulateScopePicker() {
|
||||||
const picker = bmEl("bm-scope-picker");
|
const picker = bmEl("bm-scope-picker");
|
||||||
if (!picker) return;
|
if (!picker) return;
|
||||||
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
|
const rigIds = trxState.lastRigIds;
|
||||||
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
|
const displayNames = trxState.lastRigDisplayNames;
|
||||||
const prev = picker.value;
|
const prev = picker.value;
|
||||||
while (picker.options.length > 1) picker.remove(1);
|
while (picker.options.length > 1) picker.remove(1);
|
||||||
rigIds.forEach((id) => {
|
rigIds.forEach((id) => {
|
||||||
@@ -565,9 +553,7 @@ function bmPopulateScopePicker() {
|
|||||||
(function initBookmarks() {
|
(function initBookmarks() {
|
||||||
bmSyncAccess();
|
bmSyncAccess();
|
||||||
bmBuildDecoderCheckboxes();
|
bmBuildDecoderCheckboxes();
|
||||||
if (typeof bridge.onDecoderRegistryReady === "function") {
|
trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||||
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
|
||||||
}
|
|
||||||
bmPopulateScopePicker();
|
bmPopulateScopePicker();
|
||||||
const scopePicker = bmEl("bm-scope-picker");
|
const scopePicker = bmEl("bm-scope-picker");
|
||||||
if (scopePicker) {
|
if (scopePicker) {
|
||||||
|
|||||||
@@ -411,6 +411,15 @@ async function vchanInterceptBandwidth(bwHz) {
|
|||||||
await vchanSetChannelBandwidth(bwHz);
|
await vchanSetChannelBandwidth(bwHz);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
function vchanInterceptFrequency(freqHz) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
const core = vchanWindow.trx?.core;
|
||||||
|
const targetHz = Math.round(freqHz);
|
||||||
|
core?.armOptimisticFrequency(targetHz);
|
||||||
|
core?.applyLocalTunedFrequency(targetHz);
|
||||||
|
vchanSetChannelFreq(freqHz);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
vchanWindow.trx ??= {};
|
vchanWindow.trx ??= {};
|
||||||
vchanWindow.trx.modules ??= {};
|
vchanWindow.trx.modules ??= {};
|
||||||
vchanWindow.trx.modules.vchan = {
|
vchanWindow.trx.modules.vchan = {
|
||||||
@@ -427,27 +436,10 @@ vchanWindow.trx.modules.vchan = {
|
|||||||
isOnVirtual: vchanIsOnVirtual,
|
isOnVirtual: vchanIsOnVirtual,
|
||||||
interceptMode: vchanInterceptMode,
|
interceptMode: vchanInterceptMode,
|
||||||
interceptBandwidth: vchanInterceptBandwidth,
|
interceptBandwidth: vchanInterceptBandwidth,
|
||||||
|
interceptFrequency: vchanInterceptFrequency,
|
||||||
takeSchedulerControl: vchanTakeSchedulerControl,
|
takeSchedulerControl: vchanTakeSchedulerControl,
|
||||||
releaseToScheduler: vchanToggleSchedulerRelease
|
releaseToScheduler: vchanToggleSchedulerRelease
|
||||||
};
|
};
|
||||||
(function() {
|
|
||||||
const original = vchanWindow.setRigFrequency;
|
|
||||||
vchanWindow.setRigFrequency = function(freqHz) {
|
|
||||||
if (vchanIsOnVirtual()) {
|
|
||||||
if (vchanWindow.applyLocalTunedFrequency) {
|
|
||||||
if (typeof vchanWindow._freqOptimisticSeq === "number") {
|
|
||||||
vchanWindow._freqOptimisticSeq += 1;
|
|
||||||
vchanWindow._freqOptimisticHz = Math.round(freqHz);
|
|
||||||
}
|
|
||||||
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
|
|
||||||
}
|
|
||||||
vchanSetChannelFreq(freqHz);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void vchanTakeSchedulerControl();
|
|
||||||
original?.(freqHz);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
(function initSchedulerReleaseControl() {
|
(function initSchedulerReleaseControl() {
|
||||||
const btn = document.getElementById("scheduler-release-btn");
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
applyDecoderRegistryVisibility,
|
applyDecoderRegistryVisibility,
|
||||||
decoderRegistry,
|
decoderRegistry,
|
||||||
loadDecoderRegistry,
|
loadDecoderRegistry,
|
||||||
|
onDecoderRegistryReady,
|
||||||
} from "./core/decoder-registry.js";
|
} from "./core/decoder-registry.js";
|
||||||
import {
|
import {
|
||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
@@ -201,6 +202,7 @@ interface TrxModules {
|
|||||||
handleChannels(data: string): void;
|
handleChannels(data: string): void;
|
||||||
handleSession(data: string): void;
|
handleSession(data: string): void;
|
||||||
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
||||||
|
interceptFrequency(frequencyHz: number): boolean;
|
||||||
interceptMode(mode: string): Promise<boolean>;
|
interceptMode(mode: string): Promise<boolean>;
|
||||||
isOnVirtual(): boolean;
|
isOnVirtual(): boolean;
|
||||||
takeSchedulerControl(): Promise<void>;
|
takeSchedulerControl(): Promise<void>;
|
||||||
@@ -217,6 +219,7 @@ interface TrxState {
|
|||||||
readonly lastRigDisplayNames: Record<string, string>;
|
readonly lastRigDisplayNames: Record<string, string>;
|
||||||
readonly initialMapZoom: number;
|
readonly initialMapZoom: number;
|
||||||
readonly decodeHistoryRetentionMin: number;
|
readonly decodeHistoryRetentionMin: number;
|
||||||
|
readonly authEnabled: boolean;
|
||||||
readonly authRole: AuthRole | null;
|
readonly authRole: AuthRole | null;
|
||||||
readonly decoderRegistry: typeof decoderRegistry;
|
readonly decoderRegistry: typeof decoderRegistry;
|
||||||
readonly sseSessionId: string | null;
|
readonly sseSessionId: string | null;
|
||||||
@@ -2476,12 +2479,26 @@ async function ensureTunedBandwidthCoverage(freqHz: number, bandwidthHz = covera
|
|||||||
let _freqOptimisticHz: number | null = null;
|
let _freqOptimisticHz: number | null = null;
|
||||||
let _freqOptimisticSeq = 0;
|
let _freqOptimisticSeq = 0;
|
||||||
|
|
||||||
|
// Lazy features paint their own optimistic frequency before starting the tune
|
||||||
|
// round-trip (a bookmark updates the marker before mode and bandwidth settle).
|
||||||
|
// Arming the guard from there keeps SSE from snapping back to the stale value.
|
||||||
|
function armOptimisticFrequency(freqHz: number) {
|
||||||
|
if (!isFiniteNumber(freqHz)) return;
|
||||||
|
_freqOptimisticSeq += 1;
|
||||||
|
_freqOptimisticHz = Math.round(freqHz);
|
||||||
|
}
|
||||||
|
|
||||||
function setRigFrequency(freqHz: number) {
|
function setRigFrequency(freqHz: number) {
|
||||||
const targetHz = Math.round(freqHz);
|
const targetHz = Math.round(freqHz);
|
||||||
if (!freqAllowed(targetHz)) {
|
if (!freqAllowed(targetHz)) {
|
||||||
showUnsupportedFreqPopup(targetHz);
|
showUnsupportedFreqPopup(targetHz);
|
||||||
throw new Error(`Unsupported frequency: ${targetHz}`);
|
throw new Error(`Unsupported frequency: ${targetHz}`);
|
||||||
}
|
}
|
||||||
|
// A virtual channel owns its own tuning path: the plugin applies the
|
||||||
|
// optimistic update and posts to the channel API instead of the rig.
|
||||||
|
if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return;
|
||||||
|
// Scheduler control is fire-and-forget — don't block the freq change.
|
||||||
|
void window.trx?.modules.vchan?.takeSchedulerControl();
|
||||||
// Optimistic local update — visual is instant via CSS overlay + guard.
|
// Optimistic local update — visual is instant via CSS overlay + guard.
|
||||||
const prevFreqHz = lastFreqHz;
|
const prevFreqHz = lastFreqHz;
|
||||||
const seq = ++_freqOptimisticSeq;
|
const seq = ++_freqOptimisticSeq;
|
||||||
@@ -4822,6 +4839,7 @@ Object.defineProperties(trxState, {
|
|||||||
lastRigDisplayNames: { get() { return lastRigDisplayNames; } },
|
lastRigDisplayNames: { get() { return lastRigDisplayNames; } },
|
||||||
initialMapZoom: { get() { return initialMapZoom; } },
|
initialMapZoom: { get() { return initialMapZoom; } },
|
||||||
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
|
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
|
||||||
|
authEnabled: { get() { return authEnabled; } },
|
||||||
authRole: { get() { return authRole; } },
|
authRole: { get() { return authRole; } },
|
||||||
decoderRegistry: { get() { return decoderRegistry; } },
|
decoderRegistry: { get() { return decoderRegistry; } },
|
||||||
sseSessionId: { get() { return sseSessionId; } },
|
sseSessionId: { get() { return sseSessionId; } },
|
||||||
@@ -4855,6 +4873,8 @@ const trxCore = Object.freeze({
|
|||||||
saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
|
saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
|
||||||
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
|
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
|
||||||
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
|
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
|
||||||
|
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
|
||||||
|
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady,
|
||||||
latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm,
|
latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm,
|
||||||
formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText,
|
formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText,
|
||||||
nearestBookmarkForHz, currentDecodeHistoryRetentionMs,
|
nearestBookmarkForHz, currentDecodeHistoryRetentionMs,
|
||||||
|
|||||||
@@ -43,42 +43,60 @@ interface VirtualChannelService {
|
|||||||
takeSchedulerControl(): Promise<void>;
|
takeSchedulerControl(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Application state read through the `window.trx` host contract. This plugin
|
||||||
|
* is a separate bundle, so it cannot import the application module directly. */
|
||||||
|
interface BookmarkHostState {
|
||||||
|
readonly authEnabled: boolean;
|
||||||
|
readonly authRole: string | null;
|
||||||
|
readonly lastActiveRigId: string | null;
|
||||||
|
readonly lastRigIds: string[];
|
||||||
|
readonly lastRigDisplayNames: Record<string, string>;
|
||||||
|
readonly lastFreqHz: number | null;
|
||||||
|
readonly lastModeName: string;
|
||||||
|
readonly lastSpectrumData: unknown;
|
||||||
|
currentBandwidthHz: number;
|
||||||
|
readonly decoderRegistry: readonly DecoderDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookmarkHostCore {
|
||||||
|
postPath(path: string): Promise<unknown>;
|
||||||
|
setRigFrequency(frequencyHz: number): void;
|
||||||
|
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
|
||||||
|
armOptimisticFrequency(frequencyHz: number): void;
|
||||||
|
syncBandwidthInput(bandwidthHz: number): void;
|
||||||
|
scheduleSpectrumDraw(): void;
|
||||||
|
onDecoderRegistryReady(callback: () => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
interface BookmarkBridge {
|
interface BookmarkBridge {
|
||||||
authEnabled?: boolean;
|
trx: {
|
||||||
authRole?: string | null;
|
state: BookmarkHostState;
|
||||||
lastActiveRigId?: string | null;
|
core: BookmarkHostCore;
|
||||||
lastRigIds?: string[];
|
modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService };
|
||||||
lastRigDisplayNames?: Record<string, string>;
|
};
|
||||||
lastFreqHz?: number;
|
|
||||||
lastModeName?: string;
|
|
||||||
lastSpectrumData?: unknown;
|
|
||||||
currentBandwidthHz?: number;
|
|
||||||
modeEl?: HTMLSelectElement | null;
|
|
||||||
decoderRegistry?: DecoderDescriptor[];
|
|
||||||
trx?: { modules?: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
|
|
||||||
trxUi: {
|
trxUi: {
|
||||||
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
|
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
|
||||||
notify?(message: string, options: { kind: "error" }): void;
|
notify?(message: string, options: { kind: "error" }): void;
|
||||||
};
|
};
|
||||||
syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
|
syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
|
||||||
scheduleSpectrumDraw?(): void;
|
|
||||||
syncBandwidthInput?(bandwidthHz: number): void;
|
|
||||||
applyLocalTunedFrequency?(frequencyHz: number, force?: boolean): void;
|
|
||||||
setRigFrequency?(frequencyHz: number): Promise<unknown>;
|
|
||||||
postPath(path: string): Promise<unknown>;
|
|
||||||
onDecoderRegistryReady?(callback: () => void): void;
|
|
||||||
_freqOptimisticSeq?: number;
|
|
||||||
_freqOptimisticHz?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
|
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
|
||||||
const bridge = window as unknown as BookmarkBridge;
|
const bridge = window as unknown as BookmarkBridge;
|
||||||
|
const trxState = bridge.trx.state;
|
||||||
|
const trxCore = bridge.trx.core;
|
||||||
function bmEl(id: string): BookmarkElement {
|
function bmEl(id: string): BookmarkElement {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
||||||
return element as BookmarkElement;
|
return element as BookmarkElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Decoder checkboxes and decoder toggle buttons are built from the runtime
|
||||||
|
* registry, so their elements are legitimately absent for unbuilt decoders. */
|
||||||
|
function bmOptionalEl(id: string): BookmarkElement | null {
|
||||||
|
return document.getElementById(id) as BookmarkElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
@@ -119,10 +137,7 @@ function bmEsc(str: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bmCanControl() {
|
function bmCanControl() {
|
||||||
return (
|
return !trxState.authEnabled || trxState.authRole === "control";
|
||||||
(typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) ||
|
|
||||||
(typeof bridge.authRole !== "undefined" && bridge.authRole === "control")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||||
@@ -136,8 +151,7 @@ function bmSyncAccess() {
|
|||||||
|
|
||||||
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
||||||
function bmListScope() {
|
function bmListScope() {
|
||||||
const rig = (typeof bridge.lastActiveRigId !== "undefined") ? bridge.lastActiveRigId : null;
|
return trxState.lastActiveRigId || "general";
|
||||||
return rig || "general";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmFetchOverlay() {
|
async function bmFetchOverlay() {
|
||||||
@@ -154,7 +168,7 @@ async function bmFetchOverlay() {
|
|||||||
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
||||||
bridge.syncBookmarkMapLocators(bmOverlayList);
|
bridge.syncBookmarkMapLocators(bmOverlayList);
|
||||||
}
|
}
|
||||||
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
|
trxCore.scheduleSpectrumDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmFetch(categoryFilter: string): Promise<void> {
|
async function bmFetch(categoryFilter: string): Promise<void> {
|
||||||
@@ -310,19 +324,19 @@ function bmChangePage(delta: number): void {
|
|||||||
|
|
||||||
// Read decoder checkboxes and return an array of selected decoder names.
|
// Read decoder checkboxes and return an array of selected decoder names.
|
||||||
function bmReadDecoders(): string[] {
|
function bmReadDecoders(): string[] {
|
||||||
return (bridge.decoderRegistry || [])
|
return trxState.decoderRegistry
|
||||||
.filter(d => d.bookmark_selectable)
|
.filter(d => d.bookmark_selectable)
|
||||||
.filter(d => bmEl("bm-dec-" + d.id)?.checked)
|
.filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
|
||||||
.map(d => d.id);
|
.map(d => d.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set decoder checkboxes to match the given array.
|
// Set decoder checkboxes to match the given array.
|
||||||
function bmWriteDecoders(decoders: readonly string[]): void {
|
function bmWriteDecoders(decoders: readonly string[]): void {
|
||||||
const set = new Set(decoders || []);
|
const set = new Set(decoders || []);
|
||||||
(bridge.decoderRegistry || [])
|
trxState.decoderRegistry
|
||||||
.filter(d => d.bookmark_selectable)
|
.filter(d => d.bookmark_selectable)
|
||||||
.forEach(d => {
|
.forEach(d => {
|
||||||
const el = bmEl("bm-dec-" + d.id);
|
const el = bmOptionalEl("bm-dec-" + d.id);
|
||||||
if (el) el.checked = set.has(d.id);
|
if (el) el.checked = set.has(d.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -332,7 +346,7 @@ function bmBuildDecoderCheckboxes() {
|
|||||||
const container = bmEl("bm-decoder-checkboxes");
|
const container = bmEl("bm-decoder-checkboxes");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
(bridge.decoderRegistry || [])
|
trxState.decoderRegistry
|
||||||
.filter(d => d.bookmark_selectable)
|
.filter(d => d.bookmark_selectable)
|
||||||
.forEach(d => {
|
.forEach(d => {
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
@@ -372,23 +386,21 @@ function bmCloseForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bmPrefillFromStatus() {
|
function bmPrefillFromStatus() {
|
||||||
// Use globals maintained by app.js (updated by SSE stream)
|
// Read live rig state from the host contract (updated by the SSE stream).
|
||||||
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
|
const freqHz = trxState.lastFreqHz;
|
||||||
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
|
if (freqHz != null && Number.isFinite(freqHz)) {
|
||||||
|
bmEl("bm-freq").value = String(Math.round(freqHz));
|
||||||
}
|
}
|
||||||
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
|
if (trxState.lastModeName) {
|
||||||
bmEl("bm-mode").value = bridge.lastModeName;
|
bmEl("bm-mode").value = trxState.lastModeName;
|
||||||
}
|
}
|
||||||
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
|
if (trxState.currentBandwidthHz > 0) {
|
||||||
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
|
bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz));
|
||||||
}
|
}
|
||||||
// Prefill decoder checkboxes from current toggle button state.
|
// Prefill decoder checkboxes from current toggle button state.
|
||||||
const activeDecoders = (bridge.decoderRegistry || [])
|
const activeDecoders = trxState.decoderRegistry
|
||||||
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
||||||
.filter(d => {
|
.filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
|
||||||
const btn = bmEl(d.id + "-decode-toggle-btn");
|
|
||||||
return btn && btn.dataset.enabled === "true";
|
|
||||||
})
|
|
||||||
.map(d => d.id);
|
.map(d => d.id);
|
||||||
bmWriteDecoders(activeDecoders);
|
bmWriteDecoders(activeDecoders);
|
||||||
}
|
}
|
||||||
@@ -478,58 +490,44 @@ async function bmDelete(id: string): Promise<void> {
|
|||||||
function bmApply(bm: Bookmark): void {
|
function bmApply(bm: Bookmark): void {
|
||||||
try {
|
try {
|
||||||
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
||||||
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
|
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
||||||
bridge.modeEl.value = (bm.mode || "").toUpperCase();
|
if (modeEl) {
|
||||||
|
modeEl.value = (bm.mode || "").toUpperCase();
|
||||||
}
|
}
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
if (typeof bridge.currentBandwidthHz !== "undefined") {
|
trxState.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
trxCore.syncBandwidthInput(bm.bandwidth_hz);
|
||||||
}
|
|
||||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
|
||||||
if (typeof bridge.syncBandwidthInput === "function") {
|
|
||||||
bridge.syncBandwidthInput(bm.bandwidth_hz);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (typeof bridge.applyLocalTunedFrequency === "function") {
|
// Set optimistic guard before applying so SSE cannot snap back.
|
||||||
// Set optimistic guard before applying so SSE cannot snap back.
|
trxCore.armOptimisticFrequency(bm.freq_hz);
|
||||||
if (typeof bridge._freqOptimisticSeq !== "undefined") {
|
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||||
++bridge._freqOptimisticSeq;
|
trxCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||||
bridge._freqOptimisticHz = bm.freq_hz;
|
if (trxState.lastSpectrumData) {
|
||||||
}
|
trxCore.scheduleSpectrumDraw();
|
||||||
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
|
||||||
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
|
|
||||||
}
|
|
||||||
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
|
|
||||||
bridge.scheduleSpectrumDraw();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Take scheduler control up front, then apply mode before bandwidth so a
|
// Take scheduler control up front, then apply mode before bandwidth so a
|
||||||
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
||||||
const tunePromise = (async () => {
|
const tunePromise = (async () => {
|
||||||
await bridge.trx?.modules?.vchan?.takeSchedulerControl();
|
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
||||||
|
|
||||||
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false;
|
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||||
if (!onVirtual) {
|
if (!onVirtual) {
|
||||||
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
const bwHandledByVchan =
|
const bwHandledByVchan =
|
||||||
await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||||
if (!bwHandledByVchan) {
|
if (!bwHandledByVchan) {
|
||||||
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bridge.setRigFrequency is wrapped by vchan.js to redirect to the channel API
|
// setRigFrequency redirects to the channel API when a virtual channel is
|
||||||
// when on a virtual channel, so this call works correctly in both cases.
|
// active. It repeats the optimistic update applied above, which is a
|
||||||
// It also does its own optimistic update (bridge.applyLocalTunedFrequency) but
|
// no-op because the value is unchanged.
|
||||||
// that's a no-op since we already set the same value above.
|
trxCore.setRigFrequency(bm.freq_hz);
|
||||||
if (typeof bridge.setRigFrequency === "function") {
|
|
||||||
await bridge.setRigFrequency(bm.freq_hz);
|
|
||||||
} else {
|
|
||||||
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
// Decoder toggles — fire-and-forget.
|
// Decoder toggles — fire-and-forget.
|
||||||
// - Decoders incompatible with the new mode are always turned off
|
// - Decoders incompatible with the new mode are always turned off
|
||||||
@@ -539,13 +537,14 @@ function bmApply(bm: Bookmark): void {
|
|||||||
// alone.
|
// alone.
|
||||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||||
const modeUp = (bm.mode || "").toUpperCase();
|
const modeUp = (bm.mode || "").toUpperCase();
|
||||||
const allToggleDecoders = (bridge.decoderRegistry || []).filter(d =>
|
const allToggleDecoders = trxState.decoderRegistry.filter(d =>
|
||||||
d.activation === "toggle"
|
d.activation === "toggle"
|
||||||
);
|
);
|
||||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||||
let statusUrl = "/status";
|
let statusUrl = "/status";
|
||||||
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
|
const rigId = trxState.lastActiveRigId;
|
||||||
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
|
if (rigId) {
|
||||||
|
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
||||||
}
|
}
|
||||||
const statusResp = await fetch(statusUrl);
|
const statusResp = await fetch(statusUrl);
|
||||||
if (!statusResp.ok) return;
|
if (!statusResp.ok) return;
|
||||||
@@ -567,7 +566,7 @@ function bmApply(bm: Bookmark): void {
|
|||||||
wanted = currentlyOn;
|
wanted = currentlyOn;
|
||||||
}
|
}
|
||||||
if (wanted !== currentlyOn) {
|
if (wanted !== currentlyOn) {
|
||||||
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (toggles.length) await Promise.all(toggles);
|
if (toggles.length) await Promise.all(toggles);
|
||||||
@@ -582,8 +581,6 @@ function bmApply(bm: Bookmark): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bridge.trx ??= {};
|
|
||||||
bridge.trx.modules ??= {};
|
|
||||||
bridge.trx.modules.bookmarks = {
|
bridge.trx.modules.bookmarks = {
|
||||||
get overlayList() { return bmOverlayList; },
|
get overlayList() { return bmOverlayList; },
|
||||||
get overlayRevision() { return bmOverlayRevision; },
|
get overlayRevision() { return bmOverlayRevision; },
|
||||||
@@ -619,8 +616,8 @@ function bmUpdateSelectionUi() {
|
|||||||
function bmPopulateMoveTarget() {
|
function bmPopulateMoveTarget() {
|
||||||
const sel = bmEl("bm-move-target");
|
const sel = bmEl("bm-move-target");
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
|
const rigIds = trxState.lastRigIds;
|
||||||
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
|
const displayNames = trxState.lastRigDisplayNames;
|
||||||
const prev = sel.value;
|
const prev = sel.value;
|
||||||
sel.innerHTML = "";
|
sel.innerHTML = "";
|
||||||
if (bmScope !== "general") {
|
if (bmScope !== "general") {
|
||||||
@@ -728,8 +725,8 @@ async function bmDeleteSelected() {
|
|||||||
function bmPopulateScopePicker() {
|
function bmPopulateScopePicker() {
|
||||||
const picker = bmEl("bm-scope-picker");
|
const picker = bmEl("bm-scope-picker");
|
||||||
if (!picker) return;
|
if (!picker) return;
|
||||||
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
|
const rigIds = trxState.lastRigIds;
|
||||||
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
|
const displayNames = trxState.lastRigDisplayNames;
|
||||||
// Preserve current selection if still valid.
|
// Preserve current selection if still valid.
|
||||||
const prev = picker.value;
|
const prev = picker.value;
|
||||||
while (picker.options.length > 1) picker.remove(1);
|
while (picker.options.length > 1) picker.remove(1);
|
||||||
@@ -756,9 +753,7 @@ function bmPopulateScopePicker() {
|
|||||||
// Build decoder checkboxes from registry. The registry is fetched async
|
// Build decoder checkboxes from registry. The registry is fetched async
|
||||||
// so we rebuild once it arrives to ensure checkboxes are present.
|
// so we rebuild once it arrives to ensure checkboxes are present.
|
||||||
bmBuildDecoderCheckboxes();
|
bmBuildDecoderCheckboxes();
|
||||||
if (typeof bridge.onDecoderRegistryReady === "function") {
|
trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||||
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scope picker
|
// Scope picker
|
||||||
bmPopulateScopePicker();
|
bmPopulateScopePicker();
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ interface VirtualChannelBridge {
|
|||||||
jogUnit?: number;
|
jogUnit?: number;
|
||||||
rxActive?: boolean;
|
rxActive?: boolean;
|
||||||
_audioChannelOverride?: string | null;
|
_audioChannelOverride?: string | null;
|
||||||
_freqOptimisticSeq?: number;
|
|
||||||
_freqOptimisticHz?: number;
|
|
||||||
renderRdsOverlays?: () => void;
|
renderRdsOverlays?: () => void;
|
||||||
updateDocumentTitle?: (rds: unknown) => void;
|
updateDocumentTitle?: (rds: unknown) => void;
|
||||||
activeChannelRds?: () => unknown;
|
activeChannelRds?: () => unknown;
|
||||||
@@ -47,10 +45,16 @@ interface VirtualChannelBridge {
|
|||||||
positionRdsPsOverlay?: () => void;
|
positionRdsPsOverlay?: () => void;
|
||||||
mwDefaultsForMode?: (mode: string) => [number, ...unknown[]];
|
mwDefaultsForMode?: (mode: string) => [number, ...unknown[]];
|
||||||
showHint?: (message: string, durationMs: number) => void;
|
showHint?: (message: string, durationMs: number) => void;
|
||||||
applyLocalTunedFrequency?: (frequencyHz: number) => void;
|
|
||||||
setRigFrequency?: (frequencyHz: number) => void;
|
|
||||||
refreshFreqDisplay?: () => void;
|
refreshFreqDisplay?: () => void;
|
||||||
trx?: { modules?: { vchan?: VirtualChannelService } };
|
trx?: {
|
||||||
|
core?: VirtualChannelCoreServices;
|
||||||
|
modules?: { vchan?: VirtualChannelService };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualChannelCoreServices {
|
||||||
|
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
|
||||||
|
armOptimisticFrequency(frequencyHz: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtualChannelService {
|
interface VirtualChannelService {
|
||||||
@@ -63,6 +67,7 @@ interface VirtualChannelService {
|
|||||||
isOnVirtual(): boolean;
|
isOnVirtual(): boolean;
|
||||||
interceptMode(mode: string): Promise<boolean>;
|
interceptMode(mode: string): Promise<boolean>;
|
||||||
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
||||||
|
interceptFrequency(frequencyHz: number): boolean;
|
||||||
takeSchedulerControl(): Promise<void>;
|
takeSchedulerControl(): Promise<void>;
|
||||||
releaseToScheduler(): Promise<void>;
|
releaseToScheduler(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -571,6 +576,19 @@ async function vchanInterceptBandwidth(bwHz: number): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called by setRigFrequency before it posts /set_freq. When a non-primary
|
||||||
|
// channel is active the change belongs to the channel API instead of the rig,
|
||||||
|
// so this applies the optimistic local update and reports the tune as handled.
|
||||||
|
function vchanInterceptFrequency(freqHz: number): boolean {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
const core = vchanWindow.trx?.core;
|
||||||
|
const targetHz = Math.round(freqHz);
|
||||||
|
core?.armOptimisticFrequency(targetHz);
|
||||||
|
core?.applyLocalTunedFrequency(targetHz);
|
||||||
|
vchanSetChannelFreq(freqHz);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
vchanWindow.trx ??= {};
|
vchanWindow.trx ??= {};
|
||||||
vchanWindow.trx.modules ??= {};
|
vchanWindow.trx.modules ??= {};
|
||||||
vchanWindow.trx.modules.vchan = {
|
vchanWindow.trx.modules.vchan = {
|
||||||
@@ -583,34 +601,11 @@ vchanWindow.trx.modules.vchan = {
|
|||||||
isOnVirtual: vchanIsOnVirtual,
|
isOnVirtual: vchanIsOnVirtual,
|
||||||
interceptMode: vchanInterceptMode,
|
interceptMode: vchanInterceptMode,
|
||||||
interceptBandwidth: vchanInterceptBandwidth,
|
interceptBandwidth: vchanInterceptBandwidth,
|
||||||
|
interceptFrequency: vchanInterceptFrequency,
|
||||||
takeSchedulerControl: vchanTakeSchedulerControl,
|
takeSchedulerControl: vchanTakeSchedulerControl,
|
||||||
releaseToScheduler: vchanToggleSchedulerRelease,
|
releaseToScheduler: vchanToggleSchedulerRelease,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
|
|
||||||
// frequency changes are redirected to the active virtual channel instead of
|
|
||||||
// the server when on a non-primary channel.
|
|
||||||
(function() {
|
|
||||||
const original = vchanWindow.setRigFrequency;
|
|
||||||
vchanWindow.setRigFrequency = function(freqHz: number) {
|
|
||||||
if (vchanIsOnVirtual()) {
|
|
||||||
// Optimistic local update first, then fire-and-forget channel API.
|
|
||||||
if (vchanWindow.applyLocalTunedFrequency) {
|
|
||||||
if (typeof vchanWindow._freqOptimisticSeq === "number") {
|
|
||||||
vchanWindow._freqOptimisticSeq += 1;
|
|
||||||
vchanWindow._freqOptimisticHz = Math.round(freqHz);
|
|
||||||
}
|
|
||||||
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
|
|
||||||
}
|
|
||||||
vchanSetChannelFreq(freqHz);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Scheduler control is fire-and-forget — don't block the freq change.
|
|
||||||
void vchanTakeSchedulerControl();
|
|
||||||
original?.(freqHz);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
(function initSchedulerReleaseControl() {
|
(function initSchedulerReleaseControl() {
|
||||||
const btn = document.getElementById("scheduler-release-btn");
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
|||||||
@@ -26,6 +26,48 @@ class ElementFixture {
|
|||||||
querySelector() { return null; }
|
querySelector() { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors the `window.trx` host contract published by app.ts. The plugin is a
|
||||||
|
// separate bundle, so every application service it uses arrives this way.
|
||||||
|
function hostFixture(overrides = {}) {
|
||||||
|
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0 };
|
||||||
|
const state = {
|
||||||
|
authEnabled: false,
|
||||||
|
authRole: "control",
|
||||||
|
lastActiveRigId: null,
|
||||||
|
lastRigIds: [],
|
||||||
|
lastRigDisplayNames: {},
|
||||||
|
lastFreqHz: 14_074_000,
|
||||||
|
lastModeName: "USB",
|
||||||
|
lastSpectrumData: null,
|
||||||
|
currentBandwidthHz: 2400,
|
||||||
|
decoderRegistry: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const core = {
|
||||||
|
postPath: async (path) => { calls.postPath.push(path); },
|
||||||
|
setRigFrequency: (hz) => { calls.setRigFrequency.push(hz); },
|
||||||
|
applyLocalTunedFrequency: (hz, force) => { calls.applyLocalTunedFrequency.push([hz, force]); },
|
||||||
|
armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); },
|
||||||
|
syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); },
|
||||||
|
scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; },
|
||||||
|
onDecoderRegistryReady: () => {},
|
||||||
|
};
|
||||||
|
return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentFixture(element) {
|
||||||
|
return {
|
||||||
|
getElementById: element,
|
||||||
|
querySelector: () => new ElementFixture(),
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => new ElementFixture(),
|
||||||
|
createTextNode: (text) => ({ textContent: text }),
|
||||||
|
addEventListener() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8");
|
||||||
|
|
||||||
test("bookmarks register an explicit typed service for application consumers", async () => {
|
test("bookmarks register an explicit typed service for application consumers", async () => {
|
||||||
const elements = new Map();
|
const elements = new Map();
|
||||||
const element = (id) => {
|
const element = (id) => {
|
||||||
@@ -36,33 +78,15 @@ test("bookmarks register an explicit typed service for application consumers", a
|
|||||||
element("bm-category-filter").options.push({ value: "" });
|
element("bm-category-filter").options.push({ value: "" });
|
||||||
element("bm-mode-filter").options.push({ value: "" });
|
element("bm-mode-filter").options.push({ value: "" });
|
||||||
const bookmarks = [{ id: "one", name: "Local", freq_hz: 145_500_000, mode: "FM", scope: "general" }];
|
const bookmarks = [{ id: "one", name: "Local", freq_hz: 145_500_000, mode: "FM", scope: "general" }];
|
||||||
const window = {
|
const { window } = hostFixture();
|
||||||
trx: { modules: {} },
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
decoderRegistry: [],
|
|
||||||
};
|
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
window,
|
window,
|
||||||
document: {
|
document: documentFixture(element),
|
||||||
getElementById: element,
|
|
||||||
querySelector: () => new ElementFixture(),
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
createElement: () => new ElementFixture(),
|
|
||||||
createTextNode: (text) => ({ textContent: text }),
|
|
||||||
addEventListener() {},
|
|
||||||
},
|
|
||||||
fetch: async () => ({ ok: true, json: async () => bookmarks }),
|
fetch: async () => ({ ok: true, json: async () => bookmarks }),
|
||||||
CSS: { escape: (value) => value },
|
CSS: { escape: (value) => value },
|
||||||
Element: ElementFixture,
|
Element: ElementFixture,
|
||||||
Set,
|
|
||||||
Map,
|
|
||||||
Array,
|
|
||||||
Number,
|
|
||||||
String,
|
|
||||||
Promise,
|
|
||||||
console,
|
console,
|
||||||
});
|
});
|
||||||
const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8");
|
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
@@ -73,3 +97,80 @@ test("bookmarks register an explicit typed service for application consumers", a
|
|||||||
assert.equal(typeof window.trx.modules.bookmarks.populateScopePicker, "function");
|
assert.equal(typeof window.trx.modules.bookmarks.populateScopePicker, "function");
|
||||||
assert.equal(globalThis.bmOverlayList, undefined);
|
assert.equal(globalThis.bmOverlayList, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("applying a bookmark drives tuning through the host services", async () => {
|
||||||
|
const elements = new Map();
|
||||||
|
const element = (id) => {
|
||||||
|
if (!elements.has(id)) elements.set(id, new ElementFixture());
|
||||||
|
return elements.get(id);
|
||||||
|
};
|
||||||
|
const { window, calls } = hostFixture({
|
||||||
|
lastActiveRigId: "sdr",
|
||||||
|
decoderRegistry: [
|
||||||
|
{ id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"], bookmark_selectable: true },
|
||||||
|
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW"], bookmark_selectable: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const context = vm.createContext({
|
||||||
|
window,
|
||||||
|
document: documentFixture(element),
|
||||||
|
fetch: async (url) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => (url.startsWith("/status") ? { ft8_decode_enabled: false, cw_decode_enabled: true } : []),
|
||||||
|
}),
|
||||||
|
CSS: { escape: (value) => value },
|
||||||
|
Element: ElementFixture,
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
new vm.Script(source).runInContext(context);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
calls.postPath.length = 0;
|
||||||
|
|
||||||
|
window.trx.modules.bookmarks.apply({
|
||||||
|
id: "ft8-20m", name: "FT8 20m", freq_hz: 14_074_000, mode: "USB", bandwidth_hz: 3000, decoders: ["ft8"],
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
// Optimistic UI: the guard is armed before the display update so a stale SSE
|
||||||
|
// frame cannot snap the marker back while the tune is in flight.
|
||||||
|
assert.deepEqual(calls.armOptimisticFrequency, [14_074_000]);
|
||||||
|
assert.deepEqual(calls.applyLocalTunedFrequency, [[14_074_000, true]]);
|
||||||
|
assert.deepEqual(calls.syncBandwidthInput, [3000]);
|
||||||
|
assert.equal(window.trx.state.currentBandwidthHz, 3000);
|
||||||
|
assert.equal(element("mode").value, "USB");
|
||||||
|
|
||||||
|
// Rig commands: mode and bandwidth over postPath, frequency via the core
|
||||||
|
// tuning service so virtual-channel redirection still applies.
|
||||||
|
assert.deepEqual(calls.setRigFrequency, [14_074_000]);
|
||||||
|
assert.ok(calls.postPath.includes("/set_mode?mode=USB"));
|
||||||
|
assert.ok(calls.postPath.includes("/set_bandwidth?hz=3000"));
|
||||||
|
// ft8 is selected and off; cw is on but incompatible with USB.
|
||||||
|
assert.ok(calls.postPath.includes("/toggle_ft8_decode"));
|
||||||
|
assert.ok(calls.postPath.includes("/toggle_cw_decode"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bookmark controls follow the host authentication state", async () => {
|
||||||
|
const elements = new Map();
|
||||||
|
const element = (id) => {
|
||||||
|
if (!elements.has(id)) elements.set(id, new ElementFixture());
|
||||||
|
return elements.get(id);
|
||||||
|
};
|
||||||
|
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
|
||||||
|
const context = vm.createContext({
|
||||||
|
window,
|
||||||
|
document: documentFixture(element),
|
||||||
|
fetch: async () => ({ ok: true, json: async () => [] }),
|
||||||
|
CSS: { escape: (value) => value },
|
||||||
|
Element: ElementFixture,
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
new vm.Script(source).runInContext(context);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.equal(element("bm-add-btn").style.display, "none");
|
||||||
|
|
||||||
|
window.trx.state.authRole = "control";
|
||||||
|
await window.trx.modules.bookmarks.fetch("");
|
||||||
|
assert.equal(element("bm-add-btn").style.display, "");
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ test("virtual channels expose typed SSE and interception boundaries", async () =
|
|||||||
assert.equal(typeof service.applyCapabilities, "function");
|
assert.equal(typeof service.applyCapabilities, "function");
|
||||||
assert.equal(await service.interceptMode("USB"), false);
|
assert.equal(await service.interceptMode("USB"), false);
|
||||||
assert.equal(await service.interceptBandwidth(2400), false);
|
assert.equal(await service.interceptBandwidth(2400), false);
|
||||||
|
// No virtual channel is active, so tuning stays with the physical rig.
|
||||||
|
assert.equal(service.interceptFrequency(14_074_000), false);
|
||||||
assert.equal(window.vchanHandleSession, undefined);
|
assert.equal(window.vchanHandleSession, undefined);
|
||||||
assert.equal(window.vchanInterceptBandwidth, undefined);
|
assert.equal(window.vchanInterceptBandwidth, undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user