[fix](trx-frontend-http): route feature bundles through the host contract
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 41s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / frontend (push) Failing after 36s
CI / reuse (push) Failing after 1s

The bookmark fix addressed one instance of a defect the TypeScript
migration left across the feature entries.  app.js stopped being a
classic script, so its top-level declarations are no longer shared
globals, but the converted entries kept reading them as window
properties that nothing publishes.

Restore the broken behavior:

- ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as
  undefined, so every positioned packet rendered an empty distance.
- ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined
  postPath, so clear-history and decoder toggles threw.
- scheduler read authRole as undefined, so the lazy-load path never
  self-initialized and the Settings tab opened an inert scheduler.
- background-decode read authEnabled as undefined, so control gating
  fell back to role-only.
- vchan read fifteen application values and services as undefined:
  mode and bandwidth sync, the out-of-band hint, RX audio restart, and
  the frequency field all silently no-opped on a virtual channel.
- vchan wrapped window.refreshFreqDisplay, capturing an undefined
  original exactly as it did for setRigFrequency, so leaving a channel
  never restored the application's own frequency display.
- _audioChannelOverride was a const that nothing could assign, so RX
  audio always subscribed to the primary channel.
- ftx-family read fmtTime, a helper legacy ft8.js owned locally, so
  decode bar timestamps rendered empty.

Declare the contract once in plugins/host.ts and import it from the
feature entries, rather than restoring globals that
docs/frontend-architecture.md excludes.  trx.state gains jogUnit,
rxActive and audioChannelOverride, and makes lastModeName writable;
trx.core gains the tuning, RDS, WFM, jog and RX audio services the
entries need.  vchan interception moves to an interceptFreqDisplay
service method that refreshFreqDisplay calls, matching the frequency,
mode and bandwidth interception it already registers.

Reading registry-built elements through a strict lookup is the same
defect as in bookmarks: renderTimelineNeedle guards its result, but
schedulerEl throws, so the now-initializing scheduler crashed on the
timeline needle group that its own SVG creates.

Feature tests move onto a shared host fixture, and entries that now
import a common module are bundled through bundleEntry like the other
shared-module entries.  Covers scheduler self-initialization and the
distance path that the bare window reads broke.

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 was merged in pull request #23.
This commit is contained in:
sjg
2026-08-02 11:20:17 +02:00
co-authored by Claude Opus 5
parent 23dbcac5b6
commit 0d4c657b97
50 changed files with 687 additions and 398 deletions
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
export {};
interface VirtualChannel {
@@ -21,40 +23,13 @@ interface SchedulerReleaseState {
all_released?: boolean;
current_session_released?: boolean;
}
interface SpectrumSnapshot { sample_rate: number; center_hz: number }
// Callbacks the application publishes as documented transitional window
// properties; all other application state and services arrive through the
// typed host contract in ./host.ts.
interface VirtualChannelBridge {
lastActiveRigId?: string | null;
lastFreqHz?: number;
lastModeName?: string;
lastSpectrumData?: SpectrumSnapshot | null;
currentBandwidthHz?: number;
jogUnit?: number;
rxActive?: boolean;
_audioChannelOverride?: string | null;
renderRdsOverlays?: () => void;
updateDocumentTitle?: (rds: unknown) => void;
activeChannelRds?: () => unknown;
stopRxAudio?: () => void;
startRxAudio?: () => void;
formatFreqForStep?: (frequencyHz: number, jogUnit: number) => string;
setJogDivisor?: (divisor: number) => void;
resetRdsDisplay?: () => void;
updateWfmControls?: () => void;
updateSdrSquelchControlVisibility?: () => void;
refreshRdsUi?: () => void;
positionRdsPsOverlay?: () => void;
mwDefaultsForMode?: (mode: string) => [number, ...unknown[]];
showHint?: (message: string, durationMs: number) => void;
refreshFreqDisplay?: () => void;
trx?: {
core?: VirtualChannelCoreServices;
modules?: { vchan?: VirtualChannelService };
};
}
interface VirtualChannelCoreServices {
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
armOptimisticFrequency(frequencyHz: number): void;
trx?: { modules?: { vchan?: VirtualChannelService } };
}
interface VirtualChannelService {
@@ -68,6 +43,7 @@ interface VirtualChannelService {
interceptMode(mode: string): Promise<boolean>;
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
interceptFrequency(frequencyHz: number): boolean;
interceptFreqDisplay(): boolean;
takeSchedulerControl(): Promise<void>;
releaseToScheduler(): Promise<void>;
}
@@ -151,7 +127,7 @@ function vchanStartSchedulerReleasePolling() {
async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return;
const rigId = vchanRigId || vchanWindow.lastActiveRigId || null;
const rigId = vchanRigId || hostState.lastActiveRigId || null;
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
@@ -266,9 +242,7 @@ function vchanRender() {
picker.appendChild(addBtn);
vchanSyncAccentUI();
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
}
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
vchanRenderSchedulerRelease();
}
@@ -276,8 +250,8 @@ async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return;
// Use the last known rig frequency and mode as the starting point.
const freqHz = (typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0)
? vchanWindow.lastFreqHz
const freqHz = (typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0)
? hostState.lastFreqHz
: 0;
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
const mode = modeEl ? (modeEl.value || "USB") : "USB";
@@ -379,14 +353,14 @@ function vchanReconnectAudio(): void {
// Always update the override so startRxAudio picks up the right URL,
// even when audio isn't currently running.
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
vchanWindow._audioChannelOverride = ch?.id ?? null;
if (!vchanWindow.rxActive) return;
vchanWindow.stopRxAudio?.();
hostState.audioChannelOverride = ch?.id ?? null;
if (!hostState.rxActive) return;
hostCore.stopRxAudio();
// Delay so the server has time to set up the per-channel encoder.
// The server-side audio_ws handler also polls for up to 2 s, so this
// just needs to be long enough for the WS upgrade to reach the server.
setTimeout(() => {
vchanWindow.startRxAudio?.();
hostCore.startRxAudio();
}, 300);
}
@@ -419,11 +393,7 @@ function vchanUpdateFreqDisplay() {
if (!ch) return;
const el = document.getElementById("freq") as HTMLInputElement | null;
if (!el) return;
if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
} else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
}
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
}
// Sync the mode picker to the active virtual channel's mode.
@@ -437,21 +407,21 @@ function vchanSyncModeDisplay() {
}
// When on primary channel, app.js rig-state updates handle the picker.
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof vchanWindow.lastModeName === "string") {
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
vchanWindow.setJogDivisor?.(10);
vchanWindow.resetRdsDisplay?.();
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
vchanWindow.resetRdsDisplay?.();
if (typeof hostState.lastModeName === "string") {
if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
hostCore.setJogDivisor(10);
hostCore.resetRdsDisplay();
} else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
hostCore.resetRdsDisplay();
}
vchanWindow.lastModeName = modeUpper;
hostState.lastModeName = modeUpper;
}
vchanWindow.updateWfmControls?.();
vchanWindow.updateSdrSquelchControlVisibility?.();
hostCore.updateWfmControls();
hostCore.updateSdrSquelchControlVisibility();
if (vchanWindow.refreshRdsUi) {
vchanWindow.refreshRdsUi();
} else {
vchanWindow.positionRdsPsOverlay?.();
hostCore.positionRdsPsOverlay();
}
}
@@ -464,12 +434,12 @@ function vchanSyncBwDisplay() {
if (!bwEl) return;
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && vchanWindow.mwDefaultsForMode) {
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
if (bwHz === 0) {
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
}
if (bwHz > 0) {
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
vchanWindow.currentBandwidthHz = bwHz;
hostState.currentBandwidthHz = bwHz;
}
}
@@ -485,29 +455,22 @@ function vchanSyncAccentUI() {
vchanSyncModeDisplay();
vchanSyncBwDisplay();
} else {
origRefreshFreqDisplay?.();
}
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
hostCore.refreshFreqDisplay();
}
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
}
// Saved reference to the original refreshFreqDisplay from app.js.
let origRefreshFreqDisplay: (() => void) | null = null;
function vchanSetChannelFreq(freqHz: number): void {
if (!vchanRigId || !vchanActiveId) return;
// Validate against current SDR capture window.
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
const center = vchanWindow.lastSpectrumData.center_hz;
if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
const center = hostState.lastSpectrumData.center_hz;
if (Math.abs(freqHz - center) > halfSpan) {
if (vchanWindow.showHint) {
vchanWindow.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3000
);
}
hostCore.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3000
);
return;
}
}
@@ -581,14 +544,21 @@ async function vchanInterceptBandwidth(bwHz: number): Promise<boolean> {
// 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);
hostCore.armOptimisticFrequency(targetHz);
hostCore.applyLocalTunedFrequency(targetHz);
vchanSetChannelFreq(freqHz);
return true;
}
// Called by the application's refreshFreqDisplay. While a virtual channel is
// active the field belongs to that channel, so report the render as handled.
function vchanInterceptFreqDisplay(): boolean {
if (!vchanIsOnVirtual()) return false;
vchanUpdateFreqDisplay();
return true;
}
vchanWindow.trx ??= {};
vchanWindow.trx.modules ??= {};
vchanWindow.trx.modules.vchan = {
@@ -602,6 +572,7 @@ vchanWindow.trx.modules.vchan = {
interceptMode: vchanInterceptMode,
interceptBandwidth: vchanInterceptBandwidth,
interceptFrequency: vchanInterceptFrequency,
interceptFreqDisplay: vchanInterceptFreqDisplay,
takeSchedulerControl: vchanTakeSchedulerControl,
releaseToScheduler: vchanToggleSchedulerRelease,
};
@@ -617,16 +588,3 @@ vchanWindow.trx.modules.vchan = {
vchanRenderSchedulerRelease();
})();
// Wrap refreshFreqDisplay so the main freq field stays in sync with the
// active virtual channel's frequency (SSE rig-state updates would otherwise
// constantly overwrite it with channel 0's freq).
(function() {
origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
vchanWindow.refreshFreqDisplay = function() {
if (vchanIsOnVirtual()) {
vchanUpdateFreqDisplay();
return;
}
origRefreshFreqDisplay?.();
};
})();