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); - // -