From 7b7188ec9895808301b3422c4a4a85b7958750cc Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 19 Aug 2026 18:25:48 +0000 Subject: [PATCH] feat(desktop): OSC debugging moves into Output, and looks stay in the show UI Nova and Traffic leave the outer shell: looks belong to the artist UI the show serves, and packet capture is terminal work (tools/traffic stays). OSC debugging becomes Output's Advanced tab, next to the target it debugs. Also default the artist UI's parameters panel to the right of the grid rather than under it, remembered across reloads. --- README.md | 2 +- docs/event-deployment.md | 2 +- packages/desktop/__tests__/navigation.test.ts | 18 +- packages/desktop/__tests__/nova.test.ts | 66 --- packages/desktop/__tests__/traffic.test.ts | 69 --- packages/desktop/src/main/ipc.ts | 52 +-- packages/desktop/src/main/nova.ts | 38 -- packages/desktop/src/main/osc-debug.ts | 2 +- packages/desktop/src/main/traffic.ts | 188 -------- packages/desktop/src/preload.ts | 30 +- packages/desktop/src/renderer/App.tsx | 33 +- .../desktop/src/renderer/lib/navigation.ts | 25 +- .../src/renderer/routes/nova-route.tsx | 338 --------------- .../routes/{osc-route.tsx => osc-panel.tsx} | 7 +- .../src/renderer/routes/output-route.tsx | 333 ++++++++------ .../src/renderer/routes/show-route.tsx | 2 +- .../src/renderer/routes/traffic-route.tsx | 406 ------------------ packages/desktop/src/types/ipc.ts | 131 +----- packages/ui/src/app.tsx | 15 +- tools/traffic/README.md | 9 +- tools/traffic/lib/common.sh | 2 +- 21 files changed, 254 insertions(+), 1514 deletions(-) delete mode 100644 packages/desktop/__tests__/nova.test.ts delete mode 100644 packages/desktop/__tests__/traffic.test.ts delete mode 100644 packages/desktop/src/main/nova.ts delete mode 100644 packages/desktop/src/main/traffic.ts delete mode 100644 packages/desktop/src/renderer/routes/nova-route.tsx rename packages/desktop/src/renderer/routes/{osc-route.tsx => osc-panel.tsx} (98%) delete mode 100644 packages/desktop/src/renderer/routes/traffic-route.tsx diff --git a/README.md b/README.md index 207abda..9be4396 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ pnpm build | Tool | Description | |------|-------------| -| `tools/traffic` | Passive capture and byte-level analysis of Pangolin BEYOND ⇄ FB4 traffic, on Wireshark's CLI. Driven from Advanced → Traffic in the desktop app, or straight from a terminal — see [tools/traffic/README.md](tools/traffic/README.md). Observation, plus one hand-run experiment (`bin/replay`) that sends BEYOND's own plaintext live-control lines. | +| `tools/traffic` | Passive capture and byte-level analysis of Pangolin BEYOND ⇄ FB4 traffic, on Wireshark's CLI. Run from a terminal — see [tools/traffic/README.md](tools/traffic/README.md). Observation, plus one hand-run experiment (`bin/replay`) that sends BEYOND's own plaintext live-control lines. | ## Architecture diff --git a/docs/event-deployment.md b/docs/event-deployment.md index 2205303..b02c078 100644 --- a/docs/event-deployment.md +++ b/docs/event-deployment.md @@ -134,7 +134,7 @@ graph TD ## Troubleshooting -Advanced → OSC in the desktop app is the fastest first look: it shows the +Output → Advanced in the desktop app is the fastest first look: it shows the resolved target and port, probes it, reads BEYOND.ini where BEYOND is installed locally, sends blackout / full white / full amber (one zone or all) and tails every message in and out. diff --git a/packages/desktop/__tests__/navigation.test.ts b/packages/desktop/__tests__/navigation.test.ts index bb13589..d525eca 100644 --- a/packages/desktop/__tests__/navigation.test.ts +++ b/packages/desktop/__tests__/navigation.test.ts @@ -19,8 +19,8 @@ describe('sidebar navigation', () => { expect(grouped.length).toBe(new Set(grouped).size); }); - it('keeps running a show to the show, its looks, and its health, ahead of everything else', () => { - expect(NAV_GROUPS[0]).toEqual({ id: 'run', label: 'Run', routes: ['show', 'nova', 'status'] }); + it('keeps running a show to the show and its health, ahead of everything else', () => { + expect(NAV_GROUPS[0]).toEqual({ id: 'run', label: 'Run', routes: ['show', 'status'] }); }); it('hides the admin vocabulary under Advanced', () => { @@ -28,8 +28,18 @@ describe('sidebar navigation', () => { expect(advanced).toContain('access'); expect(advanced).toContain('settings'); expect(advanced).toContain('devices'); - // Packet capture is protocol archaeology, not something an operator needs. - expect(advanced).toContain('traffic'); + }); + + // Looks belong to the artist UI the show serves, and OSC debugging belongs to + // the target it debugs — neither is a destination in the shell. + it('leaves looks and OSC debugging out of the sidebar entirely', () => { + const everything = [...NAV_GROUPS.flatMap((g) => g.routes), ...UNLISTED_ROUTES]; + expect(everything).not.toContain('nova'); + expect(everything).not.toContain('osc'); + expect(everything).not.toContain('traffic'); + expect(isRoute('nova')).toBe(false); + expect(isRoute('osc')).toBe(false); + expect(isRoute('traffic')).toBe(false); }); it('labels every route', () => { diff --git a/packages/desktop/__tests__/nova.test.ts b/packages/desktop/__tests__/nova.test.ts deleted file mode 100644 index 6462102..0000000 --- a/packages/desktop/__tests__/nova.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * The Nova panel is an operator surface pointed at live hardware, so the main - * process treats what the renderer sends as untrusted: only a known amber look - * reaches the brain, and speed is clamped to something a rig can follow. - */ -const send = jest.fn]>(); - -jest.mock('@/main/brain', () => ({ - sendToBrain: (project: string, cmd: Record) => send(project, cmd) -})); - -import { AMBER_LOOKS } from '@wavegrid/animations'; - -import { applyNovaLook, novaBlackout, setNovaSpeed } from '@/main/nova'; - -beforeEach(() => { - send.mockReset(); - send.mockReturnValue(true); -}); - -describe('applyNovaLook', () => { - it('sends each catalog look under its own command type', () => { - for (const look of AMBER_LOOKS) { - expect(applyNovaLook('show', look.id)).toBe(true); - expect(send).toHaveBeenLastCalledWith('show', { type: look.kind, name: look.id }); - } - }); - - it('refuses anything not in the catalog, without touching the brain', () => { - for (const bad of ['', 'rainbow', 'amber-nope', '../clear']) { - expect(applyNovaLook('show', bad)).toBe(false); - } - expect(send).not.toHaveBeenCalled(); - }); - - it('reports refusal from the brain when the project is not the live one', () => { - send.mockReturnValue(false); - expect(applyNovaLook('other', 'amber')).toBe(false); - }); -}); - -describe('setNovaSpeed', () => { - it('passes a usable speed through', () => { - setNovaSpeed('show', 1.5); - expect(send).toHaveBeenCalledWith('show', { type: 'anim_speed', value: 1.5 }); - }); - - it('clamps instead of rejecting, so a dragged slider never stalls the ring', () => { - setNovaSpeed('show', 0); - expect(send).toHaveBeenLastCalledWith('show', { type: 'anim_speed', value: 0.1 }); - setNovaSpeed('show', 99); - expect(send).toHaveBeenLastCalledWith('show', { type: 'anim_speed', value: 3 }); - }); - - it('drops a non-number outright', () => { - expect(setNovaSpeed('show', Number.NaN)).toBe(false); - expect(send).not.toHaveBeenCalled(); - }); -}); - -describe('novaBlackout', () => { - it('clears the running look', () => { - expect(novaBlackout('show')).toBe(true); - expect(send).toHaveBeenCalledWith('show', { type: 'clear' }); - }); -}); diff --git a/packages/desktop/__tests__/traffic.test.ts b/packages/desktop/__tests__/traffic.test.ts deleted file mode 100644 index e3c058f..0000000 --- a/packages/desktop/__tests__/traffic.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; - -import { captureArgs, toolkitDir } from '../src/main/traffic'; - -/** The toolkit as shipped in the repo, which is what the panel drives. */ -const TOOLKIT = join(__dirname, '../../../tools/traffic'); - -describe('capture arguments', () => { - it('always captures in the background so the panel can stop it', () => { - expect(captureArgs({})).toEqual(['--background']); - }); - - it('passes the operator’s choices through, and only the ones they made', () => { - expect(captureArgs({ iface: 'en7', host: '10.0.0.42', label: 'idle', seconds: 20 })).toEqual([ - '--background', - '--iface', - 'en7', - '--host', - '10.0.0.42', - '--label', - 'idle', - '--seconds', - '20' - ]); - }); - - it('drops a zero duration rather than asking dumpcap to stop immediately', () => { - expect(captureArgs({ seconds: 0 })).toEqual(['--background']); - }); -}); - -describe('toolkit location', () => { - it('finds the toolkit in the repo', () => { - expect(existsSync(join(toolkitDir(), 'bin', 'doctor'))).toBe(true); - }); -}); - -describe('doctor', () => { - // Deliberately runs the real script: it is the one command that must work on a - // machine *without* Wireshark, because its whole job is to say what's missing. - const report = JSON.parse( - execFileSync(join(TOOLKIT, 'bin', 'doctor'), ['--json'], { encoding: 'utf8' }) - ) as { - os: string; - arch: string; - tools: { name: string; found: boolean }[]; - capturePermission: { ok: boolean; detail: string; fix: string }; - }; - - it('reports this machine without needing the tools it looks for', () => { - expect(report.os).toBeTruthy(); - expect(report.arch).toBeTruthy(); - expect(report.tools.map((t) => t.name)).toEqual([ - 'tshark', - 'dumpcap', - 'capinfos', - 'editcap', - 'mergecap' - ]); - }); - - it('either grants capture permission or says how to get it', () => { - expect( - report.capturePermission.ok || report.capturePermission.fix.length > 0 - ).toBe(true); - }); -}); diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 64fdfe2..dba0e76 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -1,7 +1,7 @@ import { browse } from '@wavegrid/discovery'; import { autoMap, resolveLayout } from '@wavegrid/layout'; import { openStore } from '@wavegrid/settings'; -import { dialog, ipcMain } from 'electron'; +import { ipcMain } from 'electron'; import { sendToBrain, @@ -15,7 +15,6 @@ import { buildDoctorReport } from '@/main/doctor'; import { invalidateLaserView, type LaserSyncState, syncLaser } from '@/main/laser-view'; import { buildLightMapView } from '@/main/light-map'; import { buildNetworkReport } from '@/main/network'; -import { applyNovaLook, novaBlackout, setNovaSpeed } from '@/main/nova'; import { clearOscLog, oscDebugState, @@ -32,19 +31,6 @@ import { knownPresets, toEditable } from '@/main/project-config'; -import { - analyzeCapture, - captureState, - compareCaptures, - listCaptures, - readSettings, - startCapture, - stopCapture, - trafficDiscover, - trafficDoctor, - trafficInterfaces, - writeSettings -} from '@/main/traffic'; import { exportProjectToFile, importProjectFromFile } from '@/main/transfer'; import type { DeviceInfo, @@ -60,7 +46,6 @@ import type { ShardRange, StoreClearResult, StoreInfo, - TrafficCaptureRequest, UserRole } from '@/types/ipc'; @@ -327,12 +312,6 @@ export function registerAllIpc(): void { sendToBrain(project, { type: 'calibration_mode', enabled: false }); }); - // Nova panel: run an amber look on the live rig. The look id is validated - // against the shared catalog in `nova.ts`; nothing else can be injected. - ipcMain.handle('nova:apply', (_e, project: string, look: string) => applyNovaLook(project, look)); - ipcMain.handle('nova:speed', (_e, project: string, value: number) => setNovaSpeed(project, value)); - ipcMain.handle('nova:blackout', (_e, project: string) => novaBlackout(project)); - ipcMain.handle('projects:exportToFile', (_e, project: string, includeSecrets: boolean) => exportProjectToFile(project, includeSecrets) ); @@ -376,34 +355,7 @@ export function registerAllIpc(): void { return { ...summary, info: storeInfo() }; }); - // Traffic panel (Advanced → Traffic): passive observation only. Wireshark is - // looked for when the panel asks, never at startup, so a machine without it - // runs the rest of the app untouched. - ipcMain.handle('traffic:doctor', () => trafficDoctor()); - ipcMain.handle('traffic:interfaces', (_e, host?: string) => trafficInterfaces(host)); - ipcMain.handle('traffic:discover', (_e, iface?: string, seconds?: number) => - trafficDiscover(iface, seconds) - ); - ipcMain.handle('traffic:start', (_e, req: TrafficCaptureRequest) => startCapture(req)); - ipcMain.handle('traffic:stop', () => stopCapture()); - ipcMain.handle('traffic:status', () => captureState()); - ipcMain.handle('traffic:captures', () => listCaptures()); - ipcMain.handle('traffic:analyze', (_e, path: string, host?: string) => analyzeCapture(path, host)); - ipcMain.handle('traffic:compare', (_e, a: string, b: string, host?: string) => - compareCaptures(a, b, host) - ); - ipcMain.handle('traffic:settings', () => readSettings()); - ipcMain.handle('traffic:setCaptureDir', (_e, dir: string) => writeSettings(dir)); - ipcMain.handle('traffic:chooseCaptureDir', async () => { - const { canceled, filePaths } = await dialog.showOpenDialog({ - title: 'Where should captures be saved?', - properties: ['openDirectory', 'createDirectory'] - }); - if (canceled || !filePaths[0]) return null; - return writeSettings(filePaths[0]); - }); - - // OSC debugger (Advanced → OSC). Local UDP only, single messages, at the + // OSC debugger (Output → Advanced). Local UDP only, single messages, at the // project's own configured target — never a running show. ipcMain.handle('oscDebug:state', (_e, project: string) => oscDebugState(project)); ipcMain.handle('oscDebug:probe', (_e, project: string) => probeOscTarget(project)); diff --git a/packages/desktop/src/main/nova.ts b/packages/desktop/src/main/nova.ts deleted file mode 100644 index bd64906..0000000 --- a/packages/desktop/src/main/nova.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { AMBER_LOOKS, type AmberLook } from '@wavegrid/animations'; - -import { sendToBrain } from '@/main/brain'; - -/** - * The Nova panel's command path. - * - * The renderer names a *look*, never a command: only ids from the shared amber - * catalog reach the brain, and the numeric controls are clamped here rather - * than trusted from the panel. Everything routes through `sendToBrain`, which - * refuses unless the named project's brain is the one currently running. - */ - -const LOOKS = new Map(AMBER_LOOKS.map((look) => [look.id, look])); - -/** Speed multiplier bounds — the server clamps to 0.001..5, this is the range - * the panel's slider is useful over. */ -const MIN_SPEED = 0.1; -const MAX_SPEED = 3; - -/** Run one amber look. False when the id is unknown or that brain isn't live. */ -export function applyNovaLook(project: string, id: string): boolean { - const look = LOOKS.get(id); - if (!look) return false; - return sendToBrain(project, { type: look.kind, name: look.id }); -} - -/** How fast a moving look travels. Only animations read it. */ -export function setNovaSpeed(project: string, value: number): boolean { - if (!Number.isFinite(value)) return false; - const speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, value)); - return sendToBrain(project, { type: 'anim_speed', value: speed }); -} - -/** Stop whatever is running and go dark. */ -export function novaBlackout(project: string): boolean { - return sendToBrain(project, { type: 'clear' }); -} diff --git a/packages/desktop/src/main/osc-debug.ts b/packages/desktop/src/main/osc-debug.ts index 2c9eb85..5dc7669 100644 --- a/packages/desktop/src/main/osc-debug.ts +++ b/packages/desktop/src/main/osc-debug.ts @@ -1,5 +1,5 @@ /** - * The OSC debugger's back end (Advanced → OSC). + * The OSC debugger's back end (Output → Advanced). * * Debugging "the lasers aren't responding" needs three things a show cannot * give you: is anything listening where we send, what exactly goes on the wire, diff --git a/packages/desktop/src/main/traffic.ts b/packages/desktop/src/main/traffic.ts deleted file mode 100644 index 76cba83..0000000 --- a/packages/desktop/src/main/traffic.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * The Traffic panel's back end: a thin driver for the `tools/traffic` CLI. - * - * All the protocol archaeology lives in those shell scripts, because that is - * where an operator (or an agent on the show laptop) actually runs it; this only - * shells out with `--json` and hands the result to the renderer. Two rules: - * - * - Nothing is checked until the panel asks. Wireshark is not a dependency of - * Wavegrid, so a machine without it must open every other screen normally and - * only see a "not installed" message here. - * - Passive only. The commands exposed here list, listen, capture and analyse. - * Nothing transmits toward the laser hardware. - */ -import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; - -import type { - TrafficCaptureFile, - TrafficCaptureRequest, - TrafficCaptureState, - TrafficDiscovery, - TrafficDoctorReport, - TrafficInterfaceInfo, - TrafficResult, - TrafficSettings -} from '@/types/ipc'; - -/** Config file shared with the CLI, so the tab and a terminal agree. */ -const CONFIG_PATH = join(homedir(), '.wavegrid', 'traffic.json'); - -/** A run is bounded: `discover` listens, and a wedged tshark must not hang the panel. */ -const MAX_RUN_MS = 120_000; - -/** - * Where the toolkit is. In development it is in the repo; in a packaged app it - * is copied next to the app resources. An explicit env var wins, which is how - * you point the panel at a checkout. - */ -export function toolkitDir(): string { - const fromEnv = process.env.WAVEGRID_TRAFFIC_TOOLKIT; - if (fromEnv) return resolve(fromEnv); - const candidates = [ - process.resourcesPath ? join(process.resourcesPath, 'traffic') : '', - // packages/desktop/.vite/build → repo root - resolve(__dirname, '../../../../tools/traffic'), - resolve(process.cwd(), 'tools/traffic'), - resolve(process.cwd(), '../../tools/traffic') - ]; - return candidates.find((dir) => dir && existsSync(join(dir, 'bin', 'doctor'))) ?? candidates[1]; -} - -export function readSettings(): TrafficSettings { - let captureDir = ''; - try { - const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) as { captureDir?: unknown }; - if (typeof raw.captureDir === 'string') captureDir = raw.captureDir; - } catch { - // No config yet (or unreadable) — the toolkit's own default applies. - } - const toolkit = toolkitDir(); - return { - captureDir: captureDir || join(toolkit, 'captures'), - configPath: CONFIG_PATH, - toolkitDir: toolkit, - toolkitFound: existsSync(join(toolkit, 'bin', 'doctor')) - }; -} - -export function writeSettings(captureDir: string): TrafficSettings { - const dir = captureDir.trim(); - if (dir) { - mkdirSync(dirname(CONFIG_PATH), { recursive: true }); - writeFileSync(CONFIG_PATH, `${JSON.stringify({ captureDir: dir }, null, 2)}\n`); - } - return readSettings(); -} - -/** Run one toolkit command. Never throws: the panel shows failures as text. */ -export function run(command: string, args: string[] = []): Promise { - const toolkit = toolkitDir(); - const script = join(toolkit, 'bin', command); - if (!existsSync(script)) { - return Promise.resolve({ - ok: false, - stdout: '', - stderr: `traffic toolkit not found at ${toolkit} — set WAVEGRID_TRAFFIC_TOOLKIT to its path` - }); - } - - return new Promise((done) => { - const child = spawn(script, args, { - cwd: toolkit, - env: { ...process.env, TRAFFIC_CAPTURE_DIR: readSettings().captureDir } - }); - let stdout = ''; - let stderr = ''; - const timer = setTimeout(() => child.kill('SIGTERM'), MAX_RUN_MS); - child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString())); - child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString())); - child.on('error', (err: Error) => { - clearTimeout(timer); - done({ ok: false, stdout, stderr: err.message }); - }); - child.on('close', (code) => { - clearTimeout(timer); - done({ ok: code === 0, stdout, stderr }); - }); - }); -} - -/** Run a command that speaks `--json`. Null when it failed or printed nothing parseable. */ -export async function runJson(command: string, args: string[] = []): Promise { - const result = await run(command, ['--json', ...args]); - if (!result.stdout.trim()) return null; - try { - return JSON.parse(result.stdout) as T; - } catch { - return null; - } -} - -/** Build `capture` arguments from the panel's form. Exported for testing. */ -export function captureArgs(req: TrafficCaptureRequest): string[] { - const args = ['--background']; - if (req.iface) args.push('--iface', req.iface); - if (req.host) args.push('--host', req.host); - if (req.label) args.push('--label', req.label); - if (req.seconds && req.seconds > 0) args.push('--seconds', String(Math.round(req.seconds))); - return args; -} - -export function trafficDoctor(): Promise { - return runJson('doctor'); -} - -export async function trafficInterfaces(host?: string): Promise { - const payload = await runJson<{ interfaces: TrafficInterfaceInfo[] }>( - 'interfaces', - host ? ['--host', host] : [] - ); - return payload?.interfaces ?? []; -} - -export function trafficDiscover(iface?: string, seconds = 10): Promise { - const args = ['--seconds', String(Math.round(seconds))]; - if (iface) args.push('--iface', iface); - return runJson('discover', args); -} - -export function startCapture(req: TrafficCaptureRequest): Promise { - return runJson('capture', captureArgs(req)); -} - -export function stopCapture(): Promise { - return runJson('capture', ['--stop']); -} - -export function captureState(): Promise { - return runJson('capture', ['--status']); -} - -/** Captures on disk, newest first, with the label the operator gave each one. */ -export function listCaptures(): TrafficCaptureFile[] { - const dir = readSettings().captureDir; - if (!existsSync(dir)) return []; - return readdirSync(dir) - .filter((name) => name.endsWith('.pcapng')) - .map((name) => { - const path = join(dir, name); - const info = statSync(path); - // -