From 5848cf6ca704e867380ae687614df014c94a87fe Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 9 Aug 2026 07:17:54 +0000 Subject: [PATCH] feat(ui,desktop): explain a red connection dot + follow the project in use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artist UI's dot was a bare boolean: no reason, no retry. It now diagnoses a drop against /api/config + /api/me (server unreachable / session expired / socket rejected / restarting) and reconnects with backoff. Startup failures were swallowed. The server handle exposes a ready promise so a port clash surfaces as an actionable message instead of a show that reports running with nothing listening, the brain records the failure in its status, and the Show route prints it — plus a separate receiver-failed-but-brain-up warning. Switching the project in use now takes every panel with it: the pinned config project is dropped and a live brain restarts onto the new project, reloading the embedded artist UI (same origin, so it kept the old layout). OSC hosts normalize loopback aliases to 127.0.0.1 — localhost can resolve to IPv6 while BEYOND listens on IPv4, and UDP drops the packets silently. The Output route gains a This machine button and a host help toggle. --- packages/cli/src/commands/osc.ts | 20 ++- packages/cli/src/commands/receiver.ts | 6 +- packages/cli/src/commands/runtime.ts | 17 +++ packages/cli/src/commands/server.ts | 3 +- packages/cli/src/commands/start.ts | 6 +- packages/desktop/src/main/brain.ts | 57 +++++++- packages/desktop/src/main/ipc.ts | 11 +- packages/desktop/src/main/laser-view.ts | 15 +++ packages/desktop/src/main/osc-target.ts | 12 +- packages/desktop/src/main/runtime.ts | 4 +- packages/desktop/src/renderer/App.tsx | 26 +++- .../desktop/src/renderer/lib/use-wavegrid.ts | 4 +- .../src/renderer/routes/output-route.tsx | 53 ++++++-- .../src/renderer/routes/show-route.tsx | 22 ++- packages/desktop/src/types/ipc.ts | 6 + packages/layout/__tests__/osc-host.test.ts | 24 ++++ packages/layout/src/index.ts | 3 + packages/layout/src/osc-host.ts | 25 ++++ packages/server/__tests__/bind.test.ts | 49 +++++++ packages/server/src/server.ts | 21 ++- packages/ui/__tests__/connection.test.ts | 90 +++++++++++++ packages/ui/jest.config.cjs | 21 +++ packages/ui/src/app.tsx | 13 +- packages/ui/src/components/status-dot.tsx | 45 +++++++ packages/ui/src/lib/connection.ts | 102 ++++++++++++++ packages/ui/src/lib/use-socket.ts | 126 ++++++++++++------ 26 files changed, 687 insertions(+), 94 deletions(-) create mode 100644 packages/layout/__tests__/osc-host.test.ts create mode 100644 packages/layout/src/osc-host.ts create mode 100644 packages/server/__tests__/bind.test.ts create mode 100644 packages/ui/__tests__/connection.test.ts create mode 100644 packages/ui/jest.config.cjs create mode 100644 packages/ui/src/components/status-dot.tsx create mode 100644 packages/ui/src/lib/connection.ts diff --git a/packages/cli/src/commands/osc.ts b/packages/cli/src/commands/osc.ts index 79592e3..490ec43 100644 --- a/packages/cli/src/commands/osc.ts +++ b/packages/cli/src/commands/osc.ts @@ -1,4 +1,4 @@ -import type { OscConfig, WavegridConfig } from '@wavegrid/layout'; +import { LOOPBACK_HOST, normalizeOscHost, type OscConfig, type WavegridConfig } from '@wavegrid/layout'; import type { Inquirerer, Question } from 'inquirerer'; import c from 'yanse'; @@ -53,7 +53,7 @@ function num(flags: Flags, key: string): number | undefined { /** Non-interactive setters (no TTY / scripted). Return true on success. */ function applyFromFlags(flags: Flags, kind: string): boolean { if (kind === 'beyond') { - const host = str(flags, 'host'); + const host = normalizeOscHost(str(flags, 'host') ?? ''); if (!host) return false; const port = num(flags, 'port') ?? 7001; const gridOrder = str(flags, 'grid-order') === 'column' ? 'column' : 'row'; @@ -64,7 +64,7 @@ function applyFromFlags(flags: Flags, kind: string): boolean { return true; } if (kind === 'fb4') { - const host = str(flags, 'host'); + const host = normalizeOscHost(str(flags, 'host') ?? ''); if (!host) return false; const port = num(flags, 'port') ?? 8000; const project = save(flags, (config) => { @@ -90,8 +90,8 @@ async function wizardBeyond(prompter: Inquirerer, current?: OscConfig): Promise< { type: 'text', name: 'host', - message: 'BEYOND host (the machine running BEYOND, e.g. 192.168.1.50)', - default: current?.beyond?.host, + message: `BEYOND host \u2014 ${LOOPBACK_HOST} for this machine, or the LAN IP of the PC running BEYOND`, + default: current?.beyond?.host ?? LOOPBACK_HOST, required: true } as Question, { @@ -110,7 +110,13 @@ async function wizardBeyond(prompter: Inquirerer, current?: OscConfig): Promise< required: true } as Question ])) as unknown as { host: string; port: number; gridOrder: 'row' | 'column' }; - return { beyond: { host: answers.host.trim(), port: Number(answers.port), gridOrder: answers.gridOrder } }; + return { + beyond: { + host: normalizeOscHost(answers.host), + port: Number(answers.port), + gridOrder: answers.gridOrder + } + }; } async function wizardFb4(prompter: Inquirerer, current?: OscConfig): Promise { @@ -118,7 +124,7 @@ async function wizardFb4(prompter: Inquirerer, current?: OscConfig): Promise { diff --git a/packages/cli/src/commands/receiver.ts b/packages/cli/src/commands/receiver.ts index f628d50..0168aec 100644 --- a/packages/cli/src/commands/receiver.ts +++ b/packages/cli/src/commands/receiver.ts @@ -16,7 +16,7 @@ import c from 'yanse'; import { type Flags, getStore, resolveProjectName } from '../project'; import { coordinate } from './coordinate'; -import { applyReceiverEnv, applyServerEnv, applyShardFlag } from './runtime'; +import { applyReceiverEnv, applyServerEnv, applyShardFlag, awaitBind } from './runtime'; /** Turn a discovered brain into the ws:// URL the receiver dials. */ export function brainToWsUrl(brain: DiscoveredBrain): string { @@ -179,8 +179,8 @@ async function promoteToBrain(ctx: { const serverHandle = startServer(resolved, { advertise: { project, deviceId: device.id, deviceName: device.name, transient: true } }); - // Let the server bind before the local receiver dials in. - await new Promise((r) => setTimeout(r, 250)); + // The receiver dials in only once the port is actually bound. + await awaitBind(serverHandle); const receiverHandle = startReceiver(resolved); let stopped = false; diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index 9587a6a..6c67642 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -105,6 +105,23 @@ export function applyGeneratedRouting( } } +/** + * Wait for the brain to actually bind its port, and fail with the bind error + * rather than a stack trace from an unhandled `error` event. Without this a + * clashing port left the CLI printing "brain up" while nothing listened. + */ +export async function awaitBind(handle: { ready: Promise; stop: () => void }): Promise { + try { + await handle.ready; + } catch (e) { + handle.stop(); + console.log(''); + console.log(c.red(` ✗ ${e instanceof Error ? e.message : String(e)}`)); + console.log(''); + throw e; + } +} + /** IPv4 LAN addresses of this machine — the URLs operators point iPads/receivers at. */ export function lanAddresses(): string[] { const out: string[] = []; diff --git a/packages/cli/src/commands/server.ts b/packages/cli/src/commands/server.ts index 0e722e7..1b9850d 100644 --- a/packages/cli/src/commands/server.ts +++ b/packages/cli/src/commands/server.ts @@ -11,7 +11,7 @@ import type { Inquirerer, Question } from 'inquirerer'; import c from 'yanse'; import { type Flags, getStore, resolveProjectName } from '../project'; -import { applyServerEnv, printLanUrls } from './runtime'; +import { applyServerEnv, awaitBind, printLanUrls } from './runtime'; export interface ServerOptions { cwd?: string; @@ -73,6 +73,7 @@ export async function runServer(opts: ServerOptions = {}): Promise const serverHandle = startServer(resolved, { advertise: { project, deviceId: device.id, deviceName: device.name } }); + await awaitBind(serverHandle); let stopped = false; const stop = () => { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index a5350b8..9fb1a55 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -4,7 +4,7 @@ import c from 'yanse'; import { findConfigFile } from '../config-file'; import { type Flags, getStore, resolveProjectName } from '../project'; -import { applyReceiverEnv, applyServerEnv } from './runtime'; +import { applyReceiverEnv, applyServerEnv, awaitBind } from './runtime'; export interface StartOptions { cwd?: string; @@ -107,8 +107,8 @@ export async function runStart(opts: StartOptions = {}): Promise { const { startReceiver } = await import('@wavegrid/receiver'); const serverHandle = startServer(resolved); - // Let the server bind before the receiver dials in. - await new Promise((r) => setTimeout(r, 250)); + // The receiver dials in only once the port is actually bound. + await awaitBind(serverHandle); const receiverHandle = startReceiver(resolved); let stopped = false; diff --git a/packages/desktop/src/main/brain.ts b/packages/desktop/src/main/brain.ts index 00ca51f..03f9d1b 100644 --- a/packages/desktop/src/main/brain.ts +++ b/packages/desktop/src/main/brain.ts @@ -25,10 +25,15 @@ interface RunningBrain { runMode: BrainStatus['runMode']; server: ServerHandle; receiver: ReceiverHandle | null; + /** Why the output stage isn't running, when the brain came up without it. */ + receiverError: string | null; } let current: RunningBrain | null = null; +/** Why the last start attempt failed — kept so the UI can explain a red brain. */ +let lastError: string | null = null; + /** IPv4 LAN addresses — the URLs operators point iPads / receivers at. */ function lanAddresses(): string[] { const out: string[] = []; @@ -82,9 +87,20 @@ export function status(): BrainStatus { project: current.project, runMode: current.runMode, receiverRunning: current.receiver != null, - lanUrls: lanAddresses().map((ip) => `http://${ip}:${new URL(current!.url).port}`) + lanUrls: lanAddresses().map((ip) => `http://${ip}:${new URL(current!.url).port}`), + receiverError: current.receiverError, + lastError: null } - : { running: false, url: null, project: null, runMode: null, receiverRunning: false, lanUrls: [] }; + : { + running: false, + url: null, + project: null, + runMode: null, + receiverRunning: false, + lanUrls: [], + receiverError: null, + lastError + }; runtime.lastStatus = s; return s; } @@ -97,7 +113,17 @@ function broadcast(): BrainStatus { export async function startBrain(project: string): Promise { if (current) await stopBrain(); + lastError = null; + try { + return await start(project); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + broadcast(); + throw err; + } +} +async function start(project: string): Promise { const store = openStore(); if (!store.hasProject(project)) throw new Error(`Unknown project: ${project}`); if (store.getActiveProject() !== project) store.setActiveProject(project); @@ -108,16 +134,25 @@ export async function startBrain(project: string): Promise { const { startServer } = await import('@wavegrid/server'); const server = startServer(resolved); - // Let the server bind before the receiver dials in. - await new Promise((r) => setTimeout(r, 250)); + // Wait for the actual bind: a port clash surfaces here rather than leaving + // the UI reporting a running show with nothing listening. + try { + await server.ready; + } catch (err) { + server.stop(); + throw err; + } let receiver: ReceiverHandle | null = null; + let receiverError: string | null = null; try { const { startReceiver } = await import('@wavegrid/receiver'); receiver = startReceiver(resolved); } catch (err) { // A receiver failure (no OSC target, network) must not take down the show: - // the brain + laser UI still run console-only. + // the brain + laser UI still run console-only. Reported, not just logged — + // otherwise the show looks healthy while nothing reaches the lasers. + receiverError = err instanceof Error ? err.message : String(err); console.error('[brain] receiver failed to start:', err); } @@ -127,7 +162,8 @@ export async function startBrain(project: string): Promise { url: `http://127.0.0.1:${port}`, runMode: resolved.runMode, server, - receiver + receiver, + receiverError }; return broadcast(); } @@ -150,7 +186,14 @@ export async function startLocalReceiver(): Promise { const store = openStore(); applyReceiverEnv(store, current.project); const { startReceiver } = await import('@wavegrid/receiver'); - current.receiver = startReceiver(loadWavegridConfig()); + try { + current.receiver = startReceiver(loadWavegridConfig()); + current.receiverError = null; + } catch (err) { + current.receiverError = err instanceof Error ? err.message : String(err); + broadcast(); + throw err; + } return broadcast(); } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 5daba01..d39e5fd 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -12,7 +12,7 @@ import { stopLocalReceiver } from '@/main/brain'; import { buildDoctorReport } from '@/main/doctor'; -import { type LaserSyncState, syncLaser } from '@/main/laser-view'; +import { invalidateLaserView, type LaserSyncState, syncLaser } from '@/main/laser-view'; import { buildLightMapView } from '@/main/light-map'; import { applyOscTarget, toOscTarget } from '@/main/osc-target'; import { @@ -99,7 +99,14 @@ function devices(project: string): DeviceInfo[] { * store / brain — the renderer never touches the store or `fs` directly. */ export function registerAllIpc(): void { ipcMain.handle('brain:status', () => status()); - ipcMain.handle('brain:start', (_e, project: string) => startBrain(project)); + // The embedded artist UI is served on the same origin whichever project runs, + // so it has to be reloaded explicitly or it keeps the previous project's + // layout and light map. + ipcMain.handle('brain:start', async (_e, project: string) => { + const s = await startBrain(project); + invalidateLaserView(); + return s; + }); ipcMain.handle('brain:stop', () => stopBrain()); // Receiver-only controls: the output stage reads its OSC target, shard, and // light map at startup, so restarting just the receiver applies a config diff --git a/packages/desktop/src/main/laser-view.ts b/packages/desktop/src/main/laser-view.ts index 90b5202..f6656fa 100644 --- a/packages/desktop/src/main/laser-view.ts +++ b/packages/desktop/src/main/laser-view.ts @@ -21,6 +21,16 @@ export function resetLaserView(): void { loadedUrl = null; } +/** + * Drop the loaded-URL memo so the next sync reloads the page. The brain serves + * a different project on the same origin after a project switch, so without + * this the embedded UI keeps rendering the previous project's layout. + */ +export function invalidateLaserView(): void { + loadedUrl = null; + if (view && !view.webContents.isDestroyed()) void view.webContents.reload(); +} + function ensureView(): WebContentsView | null { const win = runtime.mainWindow; if (!win || win.isDestroyed()) return null; @@ -34,6 +44,11 @@ function ensureView(): WebContentsView | null { void shell.openExternal(url); return { action: 'deny' }; }); + // A load that failed (brain not listening yet) must not count as loaded, or + // the URL gate below would never retry and the panel would stay blank. + created.webContents.on('did-fail-load', () => { + loadedUrl = null; + }); win.contentView.addChildView(created); view = created; return created; diff --git a/packages/desktop/src/main/osc-target.ts b/packages/desktop/src/main/osc-target.ts index 304cc74..0bc5aaa 100644 --- a/packages/desktop/src/main/osc-target.ts +++ b/packages/desktop/src/main/osc-target.ts @@ -1,7 +1,7 @@ // Pure helpers translating between the stored OscConfig and the flat // OscTarget the renderer binds to. Same four choices as the CLI's // `wavegrid projects osc` wizard: BEYOND, FB4, a routing file, or none. -import type { OscConfig } from '@wavegrid/layout'; +import { normalizeOscHost, type OscConfig } from '@wavegrid/layout'; import type { ProjectConfig } from '@wavegrid/settings'; import type { OscTarget } from '@/types/ipc'; @@ -67,8 +67,10 @@ export function applyOscTarget(existing: ProjectConfig | null, target: OscTarget const keep = routing ? { routing } : {}; if (target.kind === 'beyond') { - const host = target.host.trim(); - if (!host) throw new Error('BEYOND needs the host running BEYOND (e.g. 192.168.1.50).'); + const host = normalizeOscHost(target.host); + if (!host) { + throw new Error('BEYOND needs the host running BEYOND — 127.0.0.1 for this laptop, or its LAN IP.'); + } return { ...prev, osc: { @@ -82,8 +84,8 @@ export function applyOscTarget(existing: ProjectConfig | null, target: OscTarget }; } if (target.kind === 'fb4') { - const host = target.host.trim(); - if (!host) throw new Error('FB4 needs a host address.'); + const host = normalizeOscHost(target.host); + if (!host) throw new Error('FB4 needs a host address — the FB4 device’s IP.'); return { ...prev, osc: { ...keep, fb4: { host, port: validPort(target.port, DEFAULT_FB4_PORT) } } diff --git a/packages/desktop/src/main/runtime.ts b/packages/desktop/src/main/runtime.ts index ab1992e..87e8aa2 100644 --- a/packages/desktop/src/main/runtime.ts +++ b/packages/desktop/src/main/runtime.ts @@ -16,7 +16,9 @@ export const runtime: Runtime = { project: null, runMode: null, receiverRunning: false, - lanUrls: [] + lanUrls: [], + receiverError: null, + lastError: null } }; diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 5dac044..ee177b8 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -142,11 +142,13 @@ export function App() { const discovery = useDiscovery(); const { exportProject, importProject } = useTransfer(refresh); + // A failed start is reported through `status.lastError` (pushed by the main + // process), so the rejection here is expected and not re-thrown. const onStart = React.useCallback(async () => { if (!activeProject) return; setBusy(true); try { - await window.wavegrid.brain.start(activeProject); + await window.wavegrid.brain.start(activeProject).catch(() => undefined); } finally { setBusy(false); } @@ -161,16 +163,28 @@ export function App() { } }, []); + /** + * Switching the active project has to move every project-scoped panel with it + * — and a brain still serving the previous project would keep the artist UI + * (and its light map) on the old layout, so it is restarted onto the new one. + */ const onUse = React.useCallback( async (name: string) => { setBusy(true); try { await use(name); + // Drop any project pinned by "Config" on a row, so every project-scoped + // panel (config, lights, devices, access) follows the project in use + // instead of the one last inspected. + setConfigProject(null); + if (status.running && status.project !== name) { + await window.wavegrid.brain.start(name).catch(() => undefined); + } } finally { setBusy(false); } }, - [use] + [use, status.running, status.project] ); const onCreate = React.useCallback( @@ -212,16 +226,22 @@ export function App() { } }, []); + // A live brain resolved its config at startup, so a layout/port change only + // reaches the artist UI (and the light map derived from it) on a restart. const onSaveConfig = React.useCallback( async (next: Parameters[0]) => { setBusy(true); try { await saveConfig(next); + if (editingProject && status.running && status.project === editingProject) { + await window.wavegrid.brain.start(editingProject).catch(() => undefined); + } + await refreshLightMap(); } finally { setBusy(false); } }, - [saveConfig] + [saveConfig, refreshLightMap, editingProject, status.running, status.project] ); // Hash links drive an in-app route switch (no real navigation — the window diff --git a/packages/desktop/src/renderer/lib/use-wavegrid.ts b/packages/desktop/src/renderer/lib/use-wavegrid.ts index cf1ebc3..673e2b2 100644 --- a/packages/desktop/src/renderer/lib/use-wavegrid.ts +++ b/packages/desktop/src/renderer/lib/use-wavegrid.ts @@ -29,7 +29,9 @@ const EMPTY_STATUS: BrainStatus = { project: null, runMode: null, receiverRunning: false, - lanUrls: [] + lanUrls: [], + receiverError: null, + lastError: null }; /** Live brain status: seeded from the main process, then kept fresh via the diff --git a/packages/desktop/src/renderer/routes/output-route.tsx b/packages/desktop/src/renderer/routes/output-route.tsx index f8d80ae..0c0df70 100644 --- a/packages/desktop/src/renderer/routes/output-route.tsx +++ b/packages/desktop/src/renderer/routes/output-route.tsx @@ -1,4 +1,4 @@ -import { Radio } from 'lucide-react'; +import { HelpCircle, Radio } from 'lucide-react'; import * as React from 'react'; import { Badge } from '@/components/ui/badge'; @@ -24,6 +24,14 @@ interface OutputRouteProps { type Kind = OscTarget['kind']; +/** OSC is UDP: `localhost` can resolve to IPv6 ::1 while BEYOND/FB4 listens on + * IPv4, and packets then vanish with no error — so the loopback default is the + * literal IPv4 address. */ +const THIS_MACHINE = '127.0.0.1'; + +const HOST_HELP = + 'Where the laser software is listening. Same laptop as Wavegrid → 127.0.0.1. Another machine → its LAN IP (e.g. 192.168.1.50). Avoid “localhost”: OSC is UDP and localhost can resolve to IPv6 while the target listens on IPv4, so the packets are silently dropped.'; + const KINDS: { id: Kind; label: string; blurb: string }[] = [ { id: 'beyond', label: 'BEYOND', blurb: 'Pangolin BEYOND over OSC — the usual choice' }, { id: 'fb4', label: 'FB4', blurb: 'Pangolin FB4 over OSC' }, @@ -40,6 +48,7 @@ export function OutputRoute({ activeProject, target, onSave, busy }: OutputRoute const [draft, setDraft] = React.useState(target); const [error, setError] = React.useState(null); const [saved, setSaved] = React.useState(false); + const [hostHelp, setHostHelp] = React.useState(false); // Re-seed whenever the stored target changes (project switch, or a save). React.useEffect(() => { @@ -118,17 +127,37 @@ export function OutputRoute({ activeProject, target, onSave, busy }: OutputRoute {(draft.kind === 'beyond' || draft.kind === 'fb4') && (
-
)} + {(draft.kind === 'beyond' || draft.kind === 'fb4') && hostHelp && ( +

{HOST_HELP}

+ )} + {draft.kind === 'routing' && (
+ {/* Why the show isn't up (or is up without output) — a red dot with no + reason is the thing operators can't act on. */} + {status.lastError && !running && ( +
+ + + The show didn’t start. {status.lastError} + +
+ )} + {running && status.receiverError && ( +
+ + + No laser output. The brain and UI are running, but + this machine’s receiver failed to start: {status.receiverError} + +
+ )} +
{running ? ( // The native laser WebContentsView is positioned over this slot. diff --git a/packages/desktop/src/types/ipc.ts b/packages/desktop/src/types/ipc.ts index ebe42d6..633e72d 100644 --- a/packages/desktop/src/types/ipc.ts +++ b/packages/desktop/src/types/ipc.ts @@ -14,6 +14,12 @@ export interface BrainStatus { receiverRunning: boolean; /** LAN URLs receivers / iPads can point at while the brain is running. */ lanUrls: string[]; + /** Why the output stage isn't running while the brain is up (OSC target, + * network) — the show plays on screen but nothing reaches the lasers. */ + receiverError: string | null; + /** Why the last start attempt failed, while the brain is down. Cleared by a + * successful start. */ + lastError: string | null; } export interface ProjectSummary { diff --git a/packages/layout/__tests__/osc-host.test.ts b/packages/layout/__tests__/osc-host.test.ts new file mode 100644 index 0000000..578fd5c --- /dev/null +++ b/packages/layout/__tests__/osc-host.test.ts @@ -0,0 +1,24 @@ +import { isLoopbackHost, LOOPBACK_HOST, normalizeOscHost } from '../src/osc-host'; + +describe('normalizeOscHost', () => { + it('rewrites every loopback alias to IPv4 loopback', () => { + for (const alias of ['localhost', 'LocalHost', ' localhost ', '::1', '[::1]', 'ip6-localhost']) { + expect(normalizeOscHost(alias)).toBe(LOOPBACK_HOST); + } + }); + + it('leaves real hosts alone, trimmed', () => { + expect(normalizeOscHost(' 192.168.1.50 ')).toBe('192.168.1.50'); + expect(normalizeOscHost('beyond-pc.local')).toBe('beyond-pc.local'); + expect(normalizeOscHost(LOOPBACK_HOST)).toBe(LOOPBACK_HOST); + }); + + it('keeps an empty host empty so callers can still require one', () => { + expect(normalizeOscHost(' ')).toBe(''); + }); + + it('recognizes this machine', () => { + expect(isLoopbackHost('localhost')).toBe(true); + expect(isLoopbackHost('192.168.1.50')).toBe(false); + }); +}); diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index 8dc61a7..ab2cbd5 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -50,6 +50,9 @@ export { normalizeLightMap } from './light-map'; +// OSC target hosts (loopback normalization — UDP fails silently otherwise) +export { isLoopbackHost, LOOPBACK_HOST, normalizeOscHost } from './osc-host'; + // Unified → per-device routing generation (shard + zone re-basing, validation) export { type DeviceCannon, diff --git a/packages/layout/src/osc-host.ts b/packages/layout/src/osc-host.ts new file mode 100644 index 0000000..255a64f --- /dev/null +++ b/packages/layout/src/osc-host.ts @@ -0,0 +1,25 @@ +/** + * OSC target hosts. + * + * OSC output is UDP, so a wrong host fails silently — nothing errors, the lasers + * just stay dark. The classic version of that is `localhost`: it can resolve to + * IPv6 `::1` while BEYOND/FB4 listens on IPv4, so every packet is dropped with + * no indication. Loopback names are therefore normalized to the literal IPv4 + * address wherever a host is stored. + */ + +/** Loopback in the form OSC targets actually listen on. */ +export const LOOPBACK_HOST = '127.0.0.1'; + +const LOOPBACK_NAMES = new Set(['localhost', 'localhost.localdomain', '::1', '[::1]', 'ip6-localhost']); + +/** Trim a host and rewrite any loopback alias to IPv4 loopback. */ +export function normalizeOscHost(host: string): string { + const trimmed = host.trim(); + return LOOPBACK_NAMES.has(trimmed.toLowerCase()) ? LOOPBACK_HOST : trimmed; +} + +/** True when a host points at this machine. */ +export function isLoopbackHost(host: string): boolean { + return normalizeOscHost(host) === LOOPBACK_HOST; +} diff --git a/packages/server/__tests__/bind.test.ts b/packages/server/__tests__/bind.test.ts new file mode 100644 index 0000000..3e24369 --- /dev/null +++ b/packages/server/__tests__/bind.test.ts @@ -0,0 +1,49 @@ +import { createServer } from 'node:http'; + +import { DEFAULT_CONFIG, type ResolvedConfig, resolveLayout } from '@wavegrid/layout'; + +import { startServer } from '../src/server'; + +/** + * `listen` is asynchronous, so a caller that doesn't await the bind reports a + * running show while nothing is listening — the "why is the dot red?" case. + * `ready` is the signal that makes the failure visible. + */ +function resolved(port: number): ResolvedConfig { + return { + config: { ...DEFAULT_CONFIG, server: { host: '127.0.0.1', port } }, + layout: resolveLayout({ preset: 'grid-7x7' }), + runMode: 'simple' + }; +} + +async function freePort(): Promise { + const probe = createServer(); + await new Promise((r) => probe.listen(0, '127.0.0.1', r)); + const addr = probe.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + await new Promise((r) => probe.close(() => r())); + return port; +} + +describe('startServer bind readiness', () => { + it('resolves ready once the port is actually bound', async () => { + const port = await freePort(); + const handle = startServer(resolved(port), { advertise: false }); + await expect(handle.ready).resolves.toBeUndefined(); + handle.stop(); + }); + + it('rejects ready with an actionable message when the port is taken', async () => { + const port = await freePort(); + const squatter = createServer(); + await new Promise((r) => squatter.listen(port, '127.0.0.1', r)); + try { + const handle = startServer(resolved(port), { advertise: false }); + await expect(handle.ready).rejects.toThrow(/already in use/i); + handle.stop(); + } finally { + await new Promise((r) => squatter.close(() => r())); + } + }); +}); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 6cd666b..1385036 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -30,6 +30,10 @@ import { applyScene, scenes } from './scenes'; export interface ServerHandle { server: http.Server; grid: ReturnType; + /** Resolves once the port is bound, rejects with the bind error (e.g. a port + * already in use). `listen` is async, so without awaiting this a caller + * would report a healthy show while nothing is listening. */ + ready: Promise; stop: () => void; /** * Inject a command in-process, exactly as if an authenticated client had sent @@ -934,6 +938,21 @@ const tickTimer = setInterval(() => { let advertiseHandle: AdvertiseHandle | null = null; +const ready = new Promise((resolveReady, rejectReady) => { + server.once('listening', () => resolveReady()); + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + rejectReady( + new Error( + `Port ${PORT} is already in use — another Wavegrid (or app) is on it. Stop it, or change the project's server port.` + ) + ); + return; + } + rejectReady(err); + }); +}); + server.listen(PORT, resolved.config.server.host, () => { console.log(''); console.log('╔══════════════════════════════════════════╗'); @@ -965,7 +984,7 @@ const send = (cmd: Record) => { handleMessage(cmd); }; -return { server, grid, stop, send }; +return { server, grid, ready, stop, send }; } // Run directly (dev script / node bin). The CLI imports startServer instead. diff --git a/packages/ui/__tests__/connection.test.ts b/packages/ui/__tests__/connection.test.ts new file mode 100644 index 0000000..3d47a9d --- /dev/null +++ b/packages/ui/__tests__/connection.test.ts @@ -0,0 +1,90 @@ +import { + connectionLabel, + diagnoseConnection, + OPEN_CONNECTION, + type Probe, + retryDelay +} from '../src/lib/connection'; + +/** A probe returning a fixed status per path, or throwing for "unreachable". */ +function probeWith(map: Record): Probe { + return async (path) => { + const v = map[path]; + if (v === undefined || v === 'throw') throw new Error('fetch failed'); + return { ok: v >= 200 && v < 300, status: v }; + }; +} + +describe('diagnoseConnection', () => { + it('blames the server when the origin cannot be reached at all', async () => { + const { cause, detail } = await diagnoseConnection(probeWith({}), 1006, 'tok'); + expect(cause).toBe('serverUnreachable'); + expect(detail).toMatch(/reach the server/i); + }); + + it('blames the server when /api/config answers with an error', async () => { + const { cause, detail } = await diagnoseConnection(probeWith({ '/api/config': 503 }), 1006, 'tok'); + expect(cause).toBe('serverUnreachable'); + expect(detail).toContain('503'); + }); + + it('reports an expired or revoked session when the server is up but /api/me 401s', async () => { + const { cause, detail } = await diagnoseConnection( + probeWith({ '/api/config': 200, '/api/me': 401 }), + 1006, + 'tok' + ); + expect(cause).toBe('sessionExpired'); + expect(detail).toMatch(/sign in again/i); + }); + + it('reports not-signed-in when there is no token at all', async () => { + const { cause } = await diagnoseConnection(probeWith({ '/api/config': 200 }), 1006, null); + expect(cause).toBe('sessionExpired'); + }); + + it('reports a rejected socket when the server is up and the token is good', async () => { + const { cause, detail } = await diagnoseConnection( + probeWith({ '/api/config': 200, '/api/me': 200 }), + 1006, + 'tok' + ); + expect(cause).toBe('rejected'); + expect(detail).toMatch(/JWT secret/i); + }); + + it('treats a clean close as a restart rather than a rejection', async () => { + const { cause } = await diagnoseConnection( + probeWith({ '/api/config': 200, '/api/me': 200 }), + 1000, + 'tok' + ); + expect(cause).toBe('closed'); + }); +}); + +describe('retryDelay', () => { + it('backs off from half a second and caps at ten', () => { + expect(retryDelay(1)).toBe(500); + expect(retryDelay(2)).toBe(1000); + expect(retryDelay(3)).toBe(2000); + expect(retryDelay(20)).toBe(10_000); + }); +}); + +describe('connectionLabel', () => { + it('distinguishes a first connect from a reconnect', () => { + expect(connectionLabel({ ...OPEN_CONNECTION, state: 'connecting' })).toBe('Connecting…'); + expect(connectionLabel({ ...OPEN_CONNECTION, state: 'connecting', attempts: 2 })).toBe('Reconnecting…'); + expect(connectionLabel(OPEN_CONNECTION)).toBe('Connected'); + }); + + it('shows the diagnosed reason once there is one', () => { + expect( + connectionLabel({ state: 'down', cause: 'serverUnreachable', detail: 'Show is stopped.', code: 1006, attempts: 1 }) + ).toBe('Show is stopped.'); + expect(connectionLabel({ state: 'down', cause: 'unknown', detail: '', code: 1006, attempts: 1 })).toBe( + 'Disconnected' + ); + }); +}); diff --git a/packages/ui/jest.config.cjs b/packages/ui/jest.config.cjs new file mode 100644 index 0000000..457ec8a --- /dev/null +++ b/packages/ui/jest.config.cjs @@ -0,0 +1,21 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json' + } + ] + }, + transformIgnorePatterns: ['/node_modules/*'], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1' + }, + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/packages/ui/src/app.tsx b/packages/ui/src/app.tsx index b8a032d..b2656fe 100644 --- a/packages/ui/src/app.tsx +++ b/packages/ui/src/app.tsx @@ -20,6 +20,7 @@ import { PrideTab } from '@/components/pride-tab'; import { SequencesTab } from '@/components/sequences-tab'; import { SettingsTab } from '@/components/settings-tab'; import { ShiftDial } from '@/components/shift-dial'; +import { StatusDot } from '@/components/status-dot'; import { UsaTab } from '@/components/usa-tab'; import { VideoTab } from '@/components/video-tab'; import { useAudio } from '@/lib/use-audio'; @@ -444,7 +445,7 @@ export default function Home() { const [configRev, setConfigRev] = useState(0); const config = useConfig(configRev); const { user, token, checked, login } = useAuth(); - const { connected, grid, orientation, playlistState, settings, send } = useSocket( + const { connection, grid, orientation, playlistState, settings, send } = useSocket( config?.simulatorUrl ?? null, token, useCallback(() => setConfigRev((n) => n + 1), []) @@ -786,10 +787,7 @@ export default function Home() { >
Wavegrid - + {(audio.state.playing || audio.state.micActive) && ( {audio.state.micActive ? '🎤' : '♪'} )} @@ -1020,10 +1018,7 @@ export default function Home() { > Wavegrid - + {(audio.state.playing || audio.state.micActive) && ( {audio.state.micActive ? '🎤' : '♪'} )} diff --git a/packages/ui/src/components/status-dot.tsx b/packages/ui/src/components/status-dot.tsx new file mode 100644 index 0000000..ca05a97 --- /dev/null +++ b/packages/ui/src/components/status-dot.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; + +import { type ConnectionInfo,connectionLabel } from '@/lib/connection'; + +const COLOR: Record = { + open: '#4a4', + connecting: '#d9a441', + down: '#d44' +}; + +/** + * The connection dot in the header. Green/amber/red as before, except it now + * says why: hover (or tap) for the reason, and on a wide header the reason sits + * next to it while the socket is down, so a red dot is never a dead end. + */ +export function StatusDot({ + info, + compact = false +}: { + info: ConnectionInfo; + compact?: boolean; +}) { + const [open, setOpen] = useState(false); + const label = connectionLabel(info); + const down = info.state !== 'open'; + const showText = down && (open || !compact); + + return ( + +