From ea220a0f66ed0f5fcb75489614b5219a677980d8 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 21 Sep 2026 03:54:02 +0000 Subject: [PATCH 1/4] feat(desktop): Join a brain receiver-only mode --- .../skills/wavegrid-distributed-show/SKILL.md | 11 +++++ packages/cli/__tests__/config-set.test.ts | 16 ++++++ .../cli/__tests__/runtime-commands.test.ts | 22 +++++++++ packages/cli/src/cli.ts | 10 ++-- packages/cli/src/commands/config-set.ts | 13 +++-- packages/cli/src/commands/receiver.ts | 6 ++- packages/cli/src/commands/secrets.ts | 30 ++++++++++++ packages/cli/src/index.ts | 2 +- .../desktop/__tests__/project-config.test.ts | 14 ++++++ .../desktop/__tests__/receiver-env.test.ts | 10 +++- .../desktop/__tests__/show-output.test.ts | 2 + packages/desktop/src/main/brain.ts | 41 +++++++++++++--- packages/desktop/src/main/project-config.ts | 13 ++++- packages/desktop/src/main/runtime.ts | 2 + packages/desktop/src/renderer/App.tsx | 20 ++++++++ .../desktop/src/renderer/lib/use-wavegrid.ts | 2 + .../src/renderer/routes/brain-discovery.tsx | 12 +++-- .../src/renderer/routes/devices-route.tsx | 19 ++++++- .../src/renderer/routes/join-brain.tsx | 49 +++++++++++++++++++ .../src/renderer/routes/show-route.tsx | 5 ++ .../src/renderer/routes/status-route.tsx | 9 +++- packages/desktop/src/types/ipc.ts | 3 ++ packages/doctor/src/collect.ts | 4 +- packages/layout/__tests__/config-env.test.ts | 22 ++++++++- packages/layout/__tests__/config.test.ts | 5 ++ packages/layout/src/config-env.ts | 27 +++++++++- packages/layout/src/config.ts | 1 + packages/layout/src/index.ts | 9 +++- packages/layout/src/types.ts | 2 + packages/receiver/__tests__/upstream.test.ts | 17 +++++++ packages/receiver/src/index.ts | 1 + packages/receiver/src/main.ts | 5 +- packages/receiver/src/upstream.ts | 6 +++ packages/settings/src/index.ts | 3 +- packages/settings/src/secrets.ts | 6 +++ packages/settings/src/store.ts | 5 +- 36 files changed, 386 insertions(+), 38 deletions(-) create mode 100644 packages/desktop/src/renderer/routes/join-brain.tsx create mode 100644 packages/receiver/__tests__/upstream.test.ts create mode 100644 packages/receiver/src/upstream.ts diff --git a/.agents/skills/wavegrid-distributed-show/SKILL.md b/.agents/skills/wavegrid-distributed-show/SKILL.md index bb53387..2376f25 100644 --- a/.agents/skills/wavegrid-distributed-show/SKILL.md +++ b/.agents/skills/wavegrid-distributed-show/SKILL.md @@ -40,6 +40,17 @@ wavegrid receiver # discovers the server via mDNS, connects ``` A bare `wavegrid receiver` also picks up the shard the operator assigned this laptop (`wavegrid devices assign`, below) — no `--shard` needed. Explicit override for multicast-blocked networks: `wavegrid receiver --server ws://192.168.1.42:3333 --shard 0-24` (an explicit `--shard` wins over the assigned one). +### Desktop app as a receiver (Join a brain) + +On a receiver laptop, open Devices → Join a brain, paste the brain's `ws://` +or `wss://` URL (or use one found by scanning), choose Save, and then Start. +The CLI equivalent is `wavegrid projects config set receiver.server +wss://grace.hipzap.com` followed by `wavegrid receiver`; the `--server` flag +still overrides it. The receiver key must match the brain's project: +`wavegrid projects export --with-secrets` on the brain and import the bundle +(or use Desktop Projects → Export/Import), or run `wavegrid projects secrets set +receiverKey`. Clearing `receiver.server` returns to local-brain mode. + **At showtime:** operator paints → UI → server `broadcastCommand()` → every receiver filters to its shard → OSC to its hardware. ## Devices: identity, naming, management diff --git a/packages/cli/__tests__/config-set.test.ts b/packages/cli/__tests__/config-set.test.ts index 9d2b991..082d167 100644 --- a/packages/cli/__tests__/config-set.test.ts +++ b/packages/cli/__tests__/config-set.test.ts @@ -131,4 +131,20 @@ describe('runConfigSet', () => { getStore().createProject('p', { layout: { preset: 'ring-6' } }); await expect(runConfigSet('sync', 'maybe', {})).rejects.toThrow(/true or false/); }); + + it('sets and clears a remote receiver brain', async () => { + isolate(); + const store = getStore(); + store.createProject('p', { layout: { preset: 'ring-6' } }); + await runConfigSet('receiver.server', 'wss://grace.hipzap.com/path', {}); + expect(store.getProjectConfig('p')?.receiver?.server).toBe('wss://grace.hipzap.com'); + await runConfigSet('receiver.server', '', {}); + expect(store.getProjectConfig('p')?.receiver).not.toHaveProperty('server'); + }); + + it('rejects a non-ws receiver brain URL', async () => { + isolate(); + getStore().createProject('p', { layout: { preset: 'ring-6' } }); + await expect(runConfigSet('receiver.server', 'http://x', {})).rejects.toThrow(/ws:\/\//); + }); }); diff --git a/packages/cli/__tests__/runtime-commands.test.ts b/packages/cli/__tests__/runtime-commands.test.ts index d24cda3..6bb7ef5 100644 --- a/packages/cli/__tests__/runtime-commands.test.ts +++ b/packages/cli/__tests__/runtime-commands.test.ts @@ -148,4 +148,26 @@ describe('runReceiver (dry-run)', () => { await runReceiver({ cwd, dryRun: true, flags: { shard: '99-1' } }); expect(process.exitCode).toBe(1); }); + + it('uses the configured brain when no flag is supplied', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'wg-rt-')); + const cfg = buildConfig({ shape: 'preset', preset: 'ring-6', mode: 'auto' }); + cfg.receiver = { alpha: 0.06, fallbackDelay: 3000, server: 'wss://grace.hipzap.com' }; + writeFileSync(join(cwd, CONFIG_FILENAME), serializeConfig(cfg)); + const result = await runReceiver({ cwd, dryRun: true, flags: { discover: false } }); + expect(result.server).toBe('wss://grace.hipzap.com'); + }); + + it('lets an explicit flag override the configured brain', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'wg-rt-')); + const cfg = buildConfig({ shape: 'preset', preset: 'ring-6', mode: 'auto' }); + cfg.receiver = { alpha: 0.06, fallbackDelay: 3000, server: 'wss://grace.hipzap.com' }; + writeFileSync(join(cwd, CONFIG_FILENAME), serializeConfig(cfg)); + const result = await runReceiver({ + cwd, + dryRun: true, + flags: { discover: false, server: 'ws://127.0.0.1:3000' } + }); + expect(result.server).toBe('ws://127.0.0.1:3000'); + }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1a7c51d..c279b65 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -27,7 +27,7 @@ import { runRoutingImport, runRoutingShow } from './commands/routing'; -import { runSecretsInit, runSecretsList } from './commands/secrets'; +import { runSecretsInit, runSecretsList, runSecretsSet } from './commands/secrets'; import { runServer } from './commands/server'; import { runSettingsEnvironment, runSettingsInitialize } from './commands/settings'; import { runStart } from './commands/start'; @@ -48,7 +48,7 @@ ${c.bold('Projects')} — manage and edit projects projects use Set the active project projects config Print the resolved config + provenance projects config set Set a field (layout, mode, port, host, ui-port) - projects secrets list|init List / generate the project's secrets + projects secrets list|init|set List / generate / set the project's secrets projects users list|add|rm Manage UI login users projects keys ls|new|rm Named access keys (per-person or shared passphrases) projects devices list|assign List / name / shard-assign devices that joined @@ -129,12 +129,13 @@ const SETTINGS_SUBS: SubCommand[] = [ const CONFIG_SUBS: SubCommand[] = [ { value: 'show', description: 'Print the resolved config + provenance (secrets masked)' }, - { value: 'set', description: 'Set a field: layout, mode, port, host, ui-port' } + { value: 'set', description: 'Set a field: layout, mode, port, host, ui-port, receiver.server' } ]; const SECRETS_SUBS: SubCommand[] = [ { value: 'list', description: 'List required secrets and whether each is set' }, - { value: 'init', description: 'Generate any missing secrets (--force to rotate)' } + { value: 'init', description: 'Generate any missing secrets (--force to rotate)' }, + { value: 'set', description: 'Set a secret value (e.g. receiverKey from the brain’s project)' } ]; const USERS_SUBS: SubCommand[] = [ @@ -283,6 +284,7 @@ async function dispatchSecrets( if (sub == null) return; if (sub === 'init') runSecretsInit(flags); else if (sub === 'list') runSecretsList(flags); + else if (sub === 'set') await runSecretsSet(args.slice(1), flags, nonInteractive ? undefined : prompter); else unknownSub('secrets', sub); } diff --git a/packages/cli/src/commands/config-set.ts b/packages/cli/src/commands/config-set.ts index ea6f143..6d93b6f 100644 --- a/packages/cli/src/commands/config-set.ts +++ b/packages/cli/src/commands/config-set.ts @@ -1,4 +1,4 @@ -import { LAYOUT_SPEC_FORMS, parseLayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout'; +import { DEFAULT_CONFIG, LAYOUT_SPEC_FORMS, parseBrainUrl, parseLayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout'; import type { Inquirerer, Question } from 'inquirerer'; import c from 'yanse'; @@ -32,6 +32,12 @@ const SETTERS: Record, value: string) = sync: (config, value) => { const on = boolOrThrow('sync', value); config.sync = { secrets: config.sync?.secrets ?? false, ...config.sync, enabled: on }; + }, + 'receiver.server': (config, value) => { + const receiver = { ...DEFAULT_CONFIG.receiver, ...config.receiver }; + if (value.trim() === '') delete receiver.server; + else receiver.server = parseBrainUrl(value); + config.receiver = receiver; } }; @@ -45,7 +51,8 @@ const KEY_CHOICES = [ { value: 'port', description: 'Server port' }, { value: 'host', description: 'Server host/bind address' }, { value: 'ui-port', description: 'UI port' }, - { value: 'sync', description: 'Config sync across devices: true | false' } + { value: 'sync', description: 'Config sync across devices: true | false' }, + { value: 'receiver.server', description: 'Remote brain this laptop’s receiver dials (ws:// or wss://); empty = local' } ]; function boolOrThrow(key: string, value: string): boolean { @@ -130,7 +137,7 @@ export async function runConfigSet( } let resolvedValue = value; - if (resolvedValue == null || resolvedValue === '') { + if (resolvedValue == null || (resolvedValue === '' && resolvedKey !== 'receiver.server')) { if (!prompter) { console.log(c.red(` Missing value for "${resolvedKey}".`)); process.exitCode = 1; diff --git a/packages/cli/src/commands/receiver.ts b/packages/cli/src/commands/receiver.ts index 0168aec..850d0b7 100644 --- a/packages/cli/src/commands/receiver.ts +++ b/packages/cli/src/commands/receiver.ts @@ -6,8 +6,8 @@ * * wavegrid receiver --server ws://192.168.1.42:3333 --shard 0-24 * - * `--server` is the explicit upstream (required when the brain isn't this - * machine); `--shard start-end` restricts which cannons this laptop drives. + * `--server` is the explicit upstream; a configured receiver.server is used + * when no flag or discovered brain is available. */ import { browse, type DiscoveredBrain } from '@wavegrid/discovery'; import { loadWavegridConfig, type ResolvedConfig } from '@wavegrid/layout'; @@ -89,6 +89,7 @@ export async function runReceiver(opts: ReceiverOptions = {}): Promise {} }; } @@ -97,6 +98,7 @@ export async function runReceiver(opts: ReceiverOptions = {}): Promise { + const name = args[0]; + if (!name || !SECRET_NAMES.includes(name as SecretName)) { + console.log(c.red(` Unknown secret "${name ?? ''}". Valid names: ${SECRET_NAMES.join(', ')}`)); + process.exitCode = 1; + return; + } + let value = args[1]; + if (value == null && prompter) { + const answer = (await prompter.prompt({}, [{ + type: 'password', + name: 'value', + message: `Value for ${name}`, + required: true + }])) as unknown as { value: unknown }; + value = String(answer.value ?? ''); + } + if (value == null || value.trim() === '') { + console.log(c.red(' Missing secret value.')); + process.exitCode = 1; + return; + } + const store = getStore(); + const project = resolveProjectName(store, flags); + store.setSecret(project, name as SecretName, value); + console.log(` ${c.green('✓')} ${name} set · ${project}`); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f38689c..caf1ecc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,7 +7,7 @@ export { runPrintConfig } from './commands/print-config'; export { runProjects, runUse } from './commands/projects'; export { type ReceiverOptions, type ReceiverResult, runReceiver } from './commands/receiver'; export { applyReceiverEnv, applyServerEnv, applyShardFlag, lanAddresses, resolveUiDir } from './commands/runtime'; -export { runSecretsInit, runSecretsList } from './commands/secrets'; +export { runSecretsInit, runSecretsList, runSecretsSet } from './commands/secrets'; export { runServer, type ServerOptions, type ServerResult } from './commands/server'; export { runStart, servicesForMode, type ServiceSpec, type StartOptions, type StartResult } from './commands/start'; export { runUsersAdd, runUsersList, runUsersRemove } from './commands/users'; diff --git a/packages/desktop/__tests__/project-config.test.ts b/packages/desktop/__tests__/project-config.test.ts index 12a0d11..6a224ba 100644 --- a/packages/desktop/__tests__/project-config.test.ts +++ b/packages/desktop/__tests__/project-config.test.ts @@ -21,6 +21,20 @@ describe('buildLayoutSpec', () => { }); describe('editable round-trip', () => { + it('sets and clears a remote receiver brain', () => { + const base = toEditable(null); + const stored = applyEditable(null, { ...base, receiverServer: 'wss://grace.hipzap.com/path' }); + expect(stored.receiver?.server).toBe('wss://grace.hipzap.com'); + expect(toEditable(stored).receiverServer).toBe('wss://grace.hipzap.com'); + const cleared = applyEditable(stored, { ...toEditable(stored), receiverServer: '' }); + expect(cleared.receiver).not.toHaveProperty('server'); + }); + + it('rejects a non-ws receiver brain URL', () => { + expect(() => applyEditable(null, { ...toEditable(null), receiverServer: 'http://grace.hipzap.com' })) + .toThrow(/ws:\/\//); + }); + it('keeps an annulus intact through the editor', () => { const stored = applyEditable(null, { ...toEditable({ layout: { kind: 'annulus', count: 25, innerRadius: 0.5 } }), diff --git a/packages/desktop/__tests__/receiver-env.test.ts b/packages/desktop/__tests__/receiver-env.test.ts index 39490e2..306a74e 100644 --- a/packages/desktop/__tests__/receiver-env.test.ts +++ b/packages/desktop/__tests__/receiver-env.test.ts @@ -62,7 +62,8 @@ const OWNED = [ 'WG_STATE_DIR', 'WG_DEVICE_ID', 'WG_DEVICE_NAME', - 'RECEIVER_LOG' + 'RECEIVER_LOG', + 'SIMULATOR_URL' ]; beforeEach(() => { @@ -84,6 +85,13 @@ describe('applyReceiverEnv', () => { expect(process.env.FB4_PORT).toBe('8000'); }); + it('projects the configured brain URL, or the local default', () => { + applyReceiverEnv(store, 'remote', resolve({ receiver: { ...DEFAULT_CONFIG.receiver, server: 'wss://grace.hipzap.com' } })); + expect(process.env.SIMULATOR_URL).toBe('wss://grace.hipzap.com'); + applyReceiverEnv(store, 'local', consoleOnly); + expect(process.env.SIMULATOR_URL).toBe('ws://localhost:3000'); + }); + // The desktop app is long-lived: one process starts many projects, so a stale // target here would keep firing at the previous project's lasers. it('drops the previous project’s target on a switch', () => { diff --git a/packages/desktop/__tests__/show-output.test.ts b/packages/desktop/__tests__/show-output.test.ts index 1d5c084..fd6c7e1 100644 --- a/packages/desktop/__tests__/show-output.test.ts +++ b/packages/desktop/__tests__/show-output.test.ts @@ -3,6 +3,8 @@ import type { BrainStatus } from '@/types/ipc'; const status = (receiverOutputs: string[]): BrainStatus => ({ running: true, + role: 'brain', + remoteUrl: null, url: 'http://127.0.0.1:3000', project: 'grace', runMode: 'simple', diff --git a/packages/desktop/src/main/brain.ts b/packages/desktop/src/main/brain.ts index 1189318..1cef2f0 100644 --- a/packages/desktop/src/main/brain.ts +++ b/packages/desktop/src/main/brain.ts @@ -9,7 +9,7 @@ import { createRequire } from 'node:module'; import { networkInterfaces } from 'node:os'; import { join } from 'node:path'; -import type { ResolvedConfig } from '@wavegrid/layout'; +import { brainHttpOrigin, type ResolvedConfig } from '@wavegrid/layout'; import type { ReceiverHandle } from '@wavegrid/receiver'; import type { ServerHandle } from '@wavegrid/server'; import { openStore, type SettingsStore } from '@wavegrid/settings'; @@ -24,7 +24,9 @@ interface RunningBrain { project: string; url: string; runMode: BrainStatus['runMode']; - server: ServerHandle; + server: ServerHandle | null; + role: 'brain' | 'receiver'; + remoteUrl: string | null; receiver: ReceiverHandle | null; /** Why the output stage isn't running, when the brain came up without it. */ receiverError: string | null; @@ -75,17 +77,23 @@ export function status(): BrainStatus { const s: BrainStatus = current ? { running: true, + role: current.role, + remoteUrl: current.remoteUrl, url: current.url, project: current.project, runMode: current.runMode, receiverRunning: current.receiver != null, - lanUrls: lanAddresses().map((ip) => `http://${ip}:${new URL(current!.url).port}`), + lanUrls: current.role === 'brain' + ? lanAddresses().map((ip) => `http://${ip}:${new URL(current!.url).port}`) + : [], receiverError: current.receiverError, receiverOutputs: current.receiver?.outputs ?? [], lastError: null } : { running: false, + role: null, + remoteUrl: null, url: null, project: null, runMode: null, @@ -123,6 +131,23 @@ async function start(project: string): Promise { if (store.getActiveProject() !== project) store.setActiveProject(project); const resolved: ResolvedConfig = resolveProjectConfig(); + const remote = resolved.config.receiver.server; + if (remote) { + applyReceiverEnv(store, project, resolved); + const { startReceiver } = await import('@wavegrid/receiver'); + const receiver = startReceiver(resolved); + current = { + project, + url: brainHttpOrigin(remote), + runMode: resolved.runMode, + server: null, + role: 'receiver', + remoteUrl: remote, + receiver, + receiverError: null + }; + return broadcast(); + } applyServerEnv(store, project); const { startServer } = await import('@wavegrid/server'); @@ -155,6 +180,8 @@ async function start(project: string): Promise { project, url: `http://127.0.0.1:${port}`, runMode: resolved.runMode, + role: 'brain', + remoteUrl: null, server, receiver, receiverError @@ -162,11 +189,11 @@ async function start(project: string): Promise { return broadcast(); } -/** What the running server bound to, or null when the brain is down. Network +/** What the running brain bound to, or null when it is down. Network * diagnostics need the bind host, which the status object deliberately hides * (it reports the loopback URL the embedded UI loads). */ export function runningBind(): { host: string; port: number } | null { - if (!current) return null; + if (!current || !current.server) return null; return { host: resolveProjectConfig().config.server.host, port: Number(new URL(current.url).port) @@ -218,7 +245,7 @@ export function stopLocalReceiver(): BrainStatus { * show — so a light-map identify can never light the wrong project's rig. */ export function sendToBrain(project: string, cmd: Record): boolean { - if (!current || current.project !== project) return false; + if (!current?.server || current.project !== project) return false; current.server.send(cmd); return true; } @@ -231,7 +258,7 @@ export async function stopBrain(): Promise { console.error('[brain] receiver stop failed:', err); } try { - current.server.stop(); + current.server?.stop(); } catch (err) { console.error('[brain] server stop failed:', err); } diff --git a/packages/desktop/src/main/project-config.ts b/packages/desktop/src/main/project-config.ts index 301c811..18c28bc 100644 --- a/packages/desktop/src/main/project-config.ts +++ b/packages/desktop/src/main/project-config.ts @@ -5,6 +5,7 @@ import { DEFAULT_CONFIG, getPresetNames, type LayoutSpec, + parseBrainUrl, parseLayoutSpec, resolveLayout, type WavegridConfig @@ -96,6 +97,7 @@ export function toEditable(stored: ProjectConfig | null): EditableConfig { uiPort: stored?.ui?.port ?? DEFAULT_CONFIG.ui.port, alpha: stored?.receiver?.alpha ?? DEFAULT_CONFIG.receiver.alpha, fallbackDelay: stored?.receiver?.fallbackDelay ?? DEFAULT_CONFIG.receiver.fallbackDelay, + receiverServer: stored?.receiver?.server ?? '', layoutLabel: resolved.name, cannonCount: resolved.count }; @@ -106,7 +108,14 @@ export function toEditable(stored: ProjectConfig | null): EditableConfig { * debug). Returns a new ProjectConfig ready for saveProjectConfig. */ export function applyEditable(existing: ProjectConfig | null, edit: EditableConfig): ProjectConfig { const prev: ProjectConfig = existing ?? {}; - const prevReceiver: Partial = prev.receiver ?? {}; + const prevReceiver = prev.receiver ?? {}; + const receiver: WavegridConfig['receiver'] = { + ...prevReceiver, + alpha: edit.alpha, + fallbackDelay: edit.fallbackDelay + }; + if (edit.receiverServer.trim()) receiver.server = parseBrainUrl(edit.receiverServer); + else delete receiver.server; return { ...prev, layout: buildLayoutSpec(edit.layout), @@ -114,6 +123,6 @@ export function applyEditable(existing: ProjectConfig | null, edit: EditableConf simpleModeMax: edit.simpleModeMax, server: { host: edit.serverHost, port: edit.serverPort }, ui: { port: edit.uiPort }, - receiver: { ...prevReceiver, alpha: edit.alpha, fallbackDelay: edit.fallbackDelay } + receiver }; } diff --git a/packages/desktop/src/main/runtime.ts b/packages/desktop/src/main/runtime.ts index 7830203..dab2196 100644 --- a/packages/desktop/src/main/runtime.ts +++ b/packages/desktop/src/main/runtime.ts @@ -12,6 +12,8 @@ export const runtime: Runtime = { mainWindow: null, lastStatus: { running: false, + role: null, + remoteUrl: null, url: null, project: null, runMode: null, diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index f88d28c..916f187 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -297,6 +297,16 @@ export function App() { }, [saveConfig, refreshLightMap, editingProject, status.running, status.project] ); + const [joinUrl, setJoinUrl] = React.useState(''); + React.useEffect(() => setJoinUrl(config?.receiverServer ?? ''), [config?.receiverServer]); + const saveJoin = React.useCallback(async () => { + if (!config) return; + await onSaveConfig({ ...config, receiverServer: joinUrl }); + }, [config, joinUrl, onSaveConfig]); + const clearJoin = React.useCallback(async () => { + setJoinUrl(''); + if (config) await onSaveConfig({ ...config, receiverServer: '' }); + }, [config, onSaveConfig]); // Hash links drive an in-app route switch (no real navigation — the window // never leaves the renderer bundle). @@ -394,6 +404,8 @@ export function App() { {route === 'status' && ( void renameDevice(id, name)} onAssignShard={(id, shard) => void assignShard(id, shard)} busy={busy} + join={{ + value: joinUrl, + saved: config?.receiverServer ?? '', + onChange: setJoinUrl, + onSave: () => void saveJoin(), + onClear: () => void clearJoin(), + busy + }} discovery={{ brains: discovery.brains, scanning: discovery.scanning, diff --git a/packages/desktop/src/renderer/lib/use-wavegrid.ts b/packages/desktop/src/renderer/lib/use-wavegrid.ts index 1c95b4d..5454a12 100644 --- a/packages/desktop/src/renderer/lib/use-wavegrid.ts +++ b/packages/desktop/src/renderer/lib/use-wavegrid.ts @@ -26,6 +26,8 @@ import type { const EMPTY_STATUS: BrainStatus = { running: false, + role: null, + remoteUrl: null, url: null, project: null, runMode: null, diff --git a/packages/desktop/src/renderer/routes/brain-discovery.tsx b/packages/desktop/src/renderer/routes/brain-discovery.tsx index f125215..f5849a5 100644 --- a/packages/desktop/src/renderer/routes/brain-discovery.tsx +++ b/packages/desktop/src/renderer/routes/brain-discovery.tsx @@ -11,6 +11,7 @@ interface BrainDiscoveryProps { scanning: boolean; scanned: boolean; onScan: () => void; + onUse?: (url: string) => void; } /** @@ -19,7 +20,7 @@ interface BrainDiscoveryProps { * explicit because multicast is frequently blocked; an empty result is a real * answer ("nothing found"), not a failure, and typing the URL always works. */ -export function BrainDiscovery({ brains, scanning, scanned, onScan }: BrainDiscoveryProps) { +export function BrainDiscovery({ brains, scanning, scanned, onScan, onUse }: BrainDiscoveryProps) { const [copied, setCopied] = React.useState(null); const copy = (url: string) => { @@ -80,9 +81,12 @@ export function BrainDiscovery({ brains, scanning, scanned, onScan }: BrainDisco {b.deviceName ? ` · ${b.deviceName}` : ''} - +
+ + {onUse && } +
))} diff --git a/packages/desktop/src/renderer/routes/devices-route.tsx b/packages/desktop/src/renderer/routes/devices-route.tsx index 459c36d..b0fef94 100644 --- a/packages/desktop/src/renderer/routes/devices-route.tsx +++ b/packages/desktop/src/renderer/routes/devices-route.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/empty'; import { Input } from '@/components/ui/input'; import { BrainDiscovery } from '@/renderer/routes/brain-discovery'; +import { JoinBrain } from '@/renderer/routes/join-brain'; import type { DeviceInfo, DiscoveredBrainInfo, ShardRange } from '@/types/ipc'; interface DevicesRouteProps { @@ -26,6 +27,14 @@ interface DevicesRouteProps { scanned: boolean; onScan: () => void; }; + join: { + value: string; + saved: string; + onChange: (value: string) => void; + onSave: () => void; + onClear: () => void; + busy: boolean; + }; } function relativeTime(ms?: number): string | null { @@ -179,12 +188,18 @@ export function DevicesRoute({ onRename, onAssignShard, busy, - discovery + discovery, + join }: DevicesRouteProps) { // Discovery is project-independent (it browses the LAN, not the store), so it // stays visible above every state — including "no devices yet", where it is // the most useful: it tells you the brain a laptop should point at. - const scanner = ; + const scanner = ( + <> + + + + ); if (!activeProject) { return ( diff --git a/packages/desktop/src/renderer/routes/join-brain.tsx b/packages/desktop/src/renderer/routes/join-brain.tsx new file mode 100644 index 0000000..e81e74e --- /dev/null +++ b/packages/desktop/src/renderer/routes/join-brain.tsx @@ -0,0 +1,49 @@ +import { Link } from 'lucide-react'; +import * as React from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +interface JoinBrainProps { + value: string; + saved: string; + onChange: (value: string) => void; + onSave: () => void; + onClear: () => void; + busy: boolean; + disabled: boolean; +} + +export function JoinBrain({ value, saved, onChange, onSave, onClear, busy, disabled }: JoinBrainProps) { + const valid = value.trim() === '' || /^wss?:\/\//i.test(value.trim()); + return ( +
+
+ + Join a brain + {saved && receiver-only · {saved}} +
+
+ onChange(event.target.value)} + placeholder='wss://grace.hipzap.com or ws://192.168.1.42:3000' + className='min-w-72 flex-1' + /> + + {saved && ( + + )} +
+

+ This laptop runs only a receiver and dials that brain instead of starting its own server. Its receiver key must match the brain’s project: import the project from the brain’s export with secrets (wavegrid projects export --with-secrets, or Projects → Export here), or set it with wavegrid projects secrets set receiverKey. Takes effect on the next Start. +

+
+ ); +} diff --git a/packages/desktop/src/renderer/routes/show-route.tsx b/packages/desktop/src/renderer/routes/show-route.tsx index 6cb0a5e..5462b9d 100644 --- a/packages/desktop/src/renderer/routes/show-route.tsx +++ b/packages/desktop/src/renderer/routes/show-route.tsx @@ -123,6 +123,11 @@ export function ShowRoute({ status, activeProject, onStart, onStop, busy }: Show {status.url && ( {status.url} )} + {running && status.role === 'receiver' && status.remoteUrl && ( + + Receiver-only — driving lasers for the brain at {status.remoteUrl} + + )} {running && status.lanUrls.length > 0 && ( )} diff --git a/packages/desktop/src/renderer/routes/status-route.tsx b/packages/desktop/src/renderer/routes/status-route.tsx index 28ab059..745814d 100644 --- a/packages/desktop/src/renderer/routes/status-route.tsx +++ b/packages/desktop/src/renderer/routes/status-route.tsx @@ -32,10 +32,12 @@ import { tally } from '@/renderer/lib/doctor-format'; import { NetworkPanel } from '@/renderer/routes/network-panel'; -import type { DoctorCheck, DoctorReport, NetworkReport } from '@/types/ipc'; +import type { BrainStatus, DoctorCheck, DoctorReport, NetworkReport } from '@/types/ipc'; interface StatusRouteProps { project: string | null; + role: BrainStatus['role']; + remoteUrl: string | null; report: DoctorReport | null; loading: boolean; error: string | null; @@ -86,6 +88,8 @@ function Card({ title, children, action }: { */ export function StatusRoute({ project, + role, + remoteUrl, report, loading, error, @@ -146,6 +150,7 @@ export function StatusRoute({ {server ? `brain up · ${formatUptime(server.uptimeMs)}` : 'brain down'} + {role === 'receiver' && receiver-only → {remoteUrl}} {server && ( {server.receivers.length} receiver{server.receivers.length === 1 ? '' : 's'} · {server.uiClients} UI @@ -169,7 +174,7 @@ export function StatusRoute({ {/* ── The show ─────────────────────────────────────────────────── */}
{report?.serverUrl} } diff --git a/packages/desktop/src/types/ipc.ts b/packages/desktop/src/types/ipc.ts index 13dfd40..7d30b1c 100644 --- a/packages/desktop/src/types/ipc.ts +++ b/packages/desktop/src/types/ipc.ts @@ -5,6 +5,8 @@ export type RunMode = 'simple' | 'distributed' | 'auto'; export interface BrainStatus { running: boolean; + role: 'brain' | 'receiver' | null; + remoteUrl: string | null; /** Origin the embedded laser UI + API are served on, e.g. http://127.0.0.1:3000. */ url: string | null; project: string | null; @@ -70,6 +72,7 @@ export interface EditableConfig { uiPort: number; alpha: number; fallbackDelay: number; + receiverServer: string; /** Resolved layout summary for display (name + cannon count). */ layoutLabel: string; cannonCount: number; diff --git a/packages/doctor/src/collect.ts b/packages/doctor/src/collect.ts index ceec462..ce737b6 100644 --- a/packages/doctor/src/collect.ts +++ b/packages/doctor/src/collect.ts @@ -190,9 +190,9 @@ export async function collectDiagnostics(input: CollectInput): Promise { expect(env.BEYOND_HOST).toBeUndefined(); }); + it('projects the configured brain or the local default', () => { + expect(configEnvMap(DEFAULT_CONFIG).SIMULATOR_URL).toBe('ws://localhost:3000'); + expect(configEnvMap({ ...DEFAULT_CONFIG, receiver: { ...DEFAULT_CONFIG.receiver, server: 'wss://grace.hipzap.com' } }).SIMULATOR_URL) + .toBe('wss://grace.hipzap.com'); + }); + it('names no OSC key for a project that sends nowhere', () => { const env = configEnvMap(consoleOnlyProject); for (const key of ['BEYOND_HOST', 'FB4_HOST', 'ROUTING_CONFIG']) { @@ -52,6 +58,20 @@ describe('configEnvMap', () => { }); }); +describe('brain URLs', () => { + it('maps ws origins to their HTTP UI origin', () => { + expect(brainHttpOrigin('ws://209.38.133.17:3000')).toBe('http://209.38.133.17:3000'); + expect(brainHttpOrigin('wss://grace.hipzap.com')).toBe('https://grace.hipzap.com'); + }); + + it('normalises and validates brain URLs', () => { + expect(parseBrainUrl(' wss://grace.hipzap.com/path?q=1 ')).toBe('wss://grace.hipzap.com'); + expect(() => parseBrainUrl('http://x')).toThrow(/ws:\/\//); + expect(() => parseBrainUrl('')).toThrow(); + expect(() => parseBrainUrl('garbage')).toThrow(); + }); +}); + describe('applyConfigToEnv', () => { it('fills config values without touching what the operator set', () => { const env: NodeJS.ProcessEnv = { BEYOND_HOST: '127.0.0.1' }; diff --git a/packages/layout/__tests__/config.test.ts b/packages/layout/__tests__/config.test.ts index 3bdfc20..8a27654 100644 --- a/packages/layout/__tests__/config.test.ts +++ b/packages/layout/__tests__/config.test.ts @@ -141,4 +141,9 @@ describe('loadWavegridConfig', () => { const resolved = loadWavegridConfig({ cwd: '/', env: { WG_SYNC_SECRETS: 'true' } }); expect(resolved.config.sync).toEqual({ enabled: true, secrets: true }); }); + + it('picks up SIMULATOR_URL as the receiver brain', () => { + expect(loadWavegridConfig({ cwd: '/', env: { SIMULATOR_URL: 'wss://grace.hipzap.com' } }).config.receiver.server) + .toBe('wss://grace.hipzap.com'); + }); }); diff --git a/packages/layout/src/config-env.ts b/packages/layout/src/config-env.ts index f9f1098..0a0f6d7 100644 --- a/packages/layout/src/config-env.ts +++ b/packages/layout/src/config-env.ts @@ -10,6 +10,31 @@ */ import type { WavegridConfig } from './types'; +/** The http(s) origin a brain's ws(s):// URL serves its UI on. */ +export function brainHttpOrigin(wsUrl: string): string { + const u = new URL(wsUrl); + if (u.protocol !== 'ws:' && u.protocol !== 'wss:') { + throw new Error('Brain URL must use ws:// or wss://.'); + } + return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}`; +} + +/** Accept and normalise an absolute ws:// or wss:// brain URL. */ +export function parseBrainUrl(input: string): string { + const value = input.trim(); + let u: URL; + try { + u = new URL(value); + } catch { + throw new Error('Brain URL must be an absolute ws:// or wss:// URL.'); + } + if (u.protocol !== 'ws:' && u.protocol !== 'wss:') { + throw new Error('Brain URL must use ws:// or wss://.'); + } + if (!u.hostname) throw new Error('Brain URL must include a hostname.'); + return `${u.protocol}//${u.host}`; +} + export function configEnvMap(config: WavegridConfig): Record { const env: Record = {}; const set = (k: string, v: string | number | undefined) => { @@ -21,7 +46,7 @@ export function configEnvMap(config: WavegridConfig): Record { set('WAVEGRID_HOST', config.server.host); set('WAVEGRID_PORT', config.server.port); set('WAVEGRID_UI_PORT', config.ui.port); - set('SIMULATOR_URL', `ws://localhost:${config.server.port}`); + set('SIMULATOR_URL', config.receiver.server || `ws://localhost:${config.server.port}`); set('RECEIVER_ALPHA', config.receiver.alpha); set('FALLBACK_DELAY', config.receiver.fallbackDelay); diff --git a/packages/layout/src/config.ts b/packages/layout/src/config.ts index 3638d07..7514d83 100644 --- a/packages/layout/src/config.ts +++ b/packages/layout/src/config.ts @@ -79,6 +79,7 @@ function envLayer(env: NodeJS.ProcessEnv): Partial { if (alpha != null) receiver.alpha = alpha; const fallback = toInt(env.FALLBACK_DELAY); if (fallback != null) receiver.fallbackDelay = fallback; + if (env.SIMULATOR_URL) receiver.server = env.SIMULATOR_URL; const shardStart = toInt(env.SHARD_START); const shardEnd = toInt(env.SHARD_END); if (shardStart != null && shardEnd != null) receiver.shard = { start: shardStart, end: shardEnd }; diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index 4f53942..6adbddf 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -74,7 +74,14 @@ export { } from './routing'; // Config → env projection (the receiver reads its OSC target from env only) -export { applyConfigToEnv, CONFIG_ENV_KEYS, configEnvMap, resetConfigEnv } from './config-env'; +export { + applyConfigToEnv, + brainHttpOrigin, + CONFIG_ENV_KEYS, + configEnvMap, + parseBrainUrl, + resetConfigEnv +} from './config-env'; // Config loading (confstash) + run-mode derivation export { diff --git a/packages/layout/src/types.ts b/packages/layout/src/types.ts index e94ebd5..4c4abe5 100644 --- a/packages/layout/src/types.ts +++ b/packages/layout/src/types.ts @@ -154,6 +154,8 @@ export interface ReceiverConfig { alpha: number; /** Milliseconds of silence before falling back to idle. */ fallbackDelay: number; + /** Remote brain to dial (ws:// or wss://). Unset = the local server. */ + server?: string; /** Distributed mode only: the cannon range this laptop drives. */ shard?: ShardConfig; /** Absolute path to a fixture→light map JSON, when required by the outputs. */ diff --git a/packages/receiver/__tests__/upstream.test.ts b/packages/receiver/__tests__/upstream.test.ts new file mode 100644 index 0000000..f549cfd --- /dev/null +++ b/packages/receiver/__tests__/upstream.test.ts @@ -0,0 +1,17 @@ +import { upstreamUrl } from '../src/upstream'; + +describe('upstreamUrl', () => { + it('adds the receiver key while preserving the upstream', () => { + const parsed = new URL(upstreamUrl('wss://grace.hipzap.com', 'abc')); + expect(parsed.protocol).toBe('wss:'); + expect(parsed.searchParams.get('key')).toBe('abc'); + }); + + it('preserves a ws port', () => { + expect(new URL(upstreamUrl('ws://127.0.0.1:3000', 'abc')).port).toBe('3000'); + }); + + it('leaves an empty key unchanged', () => { + expect(upstreamUrl('wss://grace.hipzap.com', '')).toBe('wss://grace.hipzap.com'); + }); +}); diff --git a/packages/receiver/src/index.ts b/packages/receiver/src/index.ts index deee1c3..f1ebd62 100644 --- a/packages/receiver/src/index.ts +++ b/packages/receiver/src/index.ts @@ -27,3 +27,4 @@ export { Receiver } from './receiver'; // Entry point export type { ReceiverHandle } from './main'; export { startReceiver } from './main'; +export { upstreamUrl } from './upstream'; diff --git a/packages/receiver/src/main.ts b/packages/receiver/src/main.ts index f9d47f7..1a80ed0 100644 --- a/packages/receiver/src/main.ts +++ b/packages/receiver/src/main.ts @@ -24,6 +24,7 @@ import { resolve } from 'path'; import { ConsoleOutput, MultiOutput, OutputAdapter, WebSocketInput, WebSocketOutput } from './adapters'; import { startDebugUI } from './debug-ui'; import { Receiver, ShardConfig } from './receiver'; +import { upstreamUrl } from './upstream'; export interface ReceiverHandle { receiver: Receiver; @@ -76,9 +77,7 @@ function logToFile(level: string, msg: string) { export function startReceiver(resolved: ResolvedConfig = loadWavegridConfig()): ReceiverHandle { const RAW_SIMULATOR_URL = process.env.SIMULATOR_URL || 'ws://localhost:3000'; const RECEIVER_KEY = process.env.WG_RECEIVER_KEY || ''; - const SIMULATOR_URL = RECEIVER_KEY - ? (() => { const u = new URL(RAW_SIMULATOR_URL); u.searchParams.set('key', RECEIVER_KEY); return u.toString(); })() - : RAW_SIMULATOR_URL; + const SIMULATOR_URL = upstreamUrl(RAW_SIMULATOR_URL, RECEIVER_KEY); const ALPHA = parseFloat(process.env.RECEIVER_ALPHA || '0.06'); const FALLBACK_DELAY = parseInt(process.env.FALLBACK_DELAY || '3000', 10); const WS_OUTPUT_PORT = process.env.WS_OUTPUT_PORT ? parseInt(process.env.WS_OUTPUT_PORT, 10) : undefined; diff --git a/packages/receiver/src/upstream.ts b/packages/receiver/src/upstream.ts new file mode 100644 index 0000000..fde5b21 --- /dev/null +++ b/packages/receiver/src/upstream.ts @@ -0,0 +1,6 @@ +export function upstreamUrl(raw: string, key: string): string { + if (!key) return raw; + const u = new URL(raw); + u.searchParams.set('key', key); + return u.toString(); +} diff --git a/packages/settings/src/index.ts b/packages/settings/src/index.ts index e9f184b..78df267 100644 --- a/packages/settings/src/index.ts +++ b/packages/settings/src/index.ts @@ -50,7 +50,8 @@ export { type GenerateResult, type ProjectSecrets, SECRET_NAMES, - type SecretName + type SecretName, + setSecret } from './secrets'; // Light-map library (named correction maps + active selection) diff --git a/packages/settings/src/secrets.ts b/packages/settings/src/secrets.ts index edd763d..eb365de 100644 --- a/packages/settings/src/secrets.ts +++ b/packages/settings/src/secrets.ts @@ -69,6 +69,12 @@ export function hasSecret(paths: StorePaths, project: string, name: SecretName): return Boolean(readSecrets(paths, project)[name]); } +export function setSecret(paths: StorePaths, project: string, name: SecretName, value: string): void { + const trimmed = value.trim(); + if (!trimmed) throw new Error(`Secret "${name}" cannot be empty.`); + writeSecrets(paths, project, { ...readSecrets(paths, project), [name]: trimmed }); +} + /** * Read a secret, throwing an explicit, actionable error when it is missing. * No implicit generation, no null return — callers get a value or an error. diff --git a/packages/settings/src/store.ts b/packages/settings/src/store.ts index b752ff6..45d4117 100644 --- a/packages/settings/src/store.ts +++ b/packages/settings/src/store.ts @@ -67,7 +67,8 @@ import { type ProjectSecrets, readSecrets, requireSecret, - type SecretName + type SecretName, + setSecret } from './secrets'; import { createSession, @@ -129,6 +130,7 @@ export interface SettingsStore { // Secrets (generated once; runtime reads must be explicit) generateSecrets(project: string, opts?: { force?: boolean }): GenerateResult; hasSecret(project: string, name: SecretName): boolean; + setSecret(project: string, name: SecretName, value: string): void; requireSecret(project: string, name: SecretName): string; readSecrets(project: string): Partial; requiredSecrets(project: string): RequiredSecret[]; @@ -230,6 +232,7 @@ export function openStore(opts: StoreOptions = {}): SettingsStore { generateSecrets: (project, o) => generateSecrets(paths, project, o), hasSecret: (project, name) => hasSecret(paths, project, name), + setSecret: (project, name, value) => setSecret(paths, project, name, value), requireSecret: (project, name) => requireSecret(paths, project, name), readSecrets: (project) => readSecrets(paths, project), requiredSecrets: (project) => requiredSecrets(paths, project), From c2dd532196c5edb27232f1adfb8e12993efa21a0 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 21 Sep 2026 03:59:33 +0000 Subject: [PATCH 2/4] docs(skills): receiver-only GUI test notes --- .agents/skills/testing-wavegrid-desktop-gui/SKILL.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/skills/testing-wavegrid-desktop-gui/SKILL.md b/.agents/skills/testing-wavegrid-desktop-gui/SKILL.md index 9e475f8..9e28adb 100644 --- a/.agents/skills/testing-wavegrid-desktop-gui/SKILL.md +++ b/.agents/skills/testing-wavegrid-desktop-gui/SKILL.md @@ -18,6 +18,18 @@ ELECTRON_ENABLE_LOGGING=1 DISPLAY=:0 \ - The CLI runs from built output, not a global binary: `node packages/cli/dist/bin.js …` (`projects config`, `signals send|probe|listen`, `doctor`). +- If Forge reports "Electron failed to install correctly", pnpm may have skipped + Electron's install script. From `packages/desktop`, run + `node node_modules/electron/install.js`, then retry startup. +- For receiver-only GUI tests, run a separate real brain with + `WAVEGRID_PORT=3555 node packages/cli/dist/bin.js server`, using the same active + project as the desktop so receiver and embedded-UI secrets match. Join + `ws://127.0.0.1:3555` in Devices. Check that port 3000 is not listening in + receiver-only mode, then returns when Use local brain restarts the show. +- CLI subcommand `--help` may execute the command instead of displaying help; + avoid probing `server --help` while preparing port-sensitive tests. +- Receiver startup logs may include a `?key=` secret. Redact query key values + before sharing log artifacts. - Store lives in `~/.wavegrid`; logs in `~/.wavegrid/logs//`. - Set the layout explicitly or you may land in `distributed` run mode (49 cannons): `node packages/cli/dist/bin.js projects config set layout nova` (6-cannon ring, simple mode). From a3832f4b1c3908a6d7f53e8c0ba5fe7e4931a0b2 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 21 Sep 2026 04:04:49 +0000 Subject: [PATCH 3/4] fix(receiver): prefer configured brain over discovery --- .../skills/wavegrid-distributed-show/SKILL.md | 6 +- .../cli/__tests__/runtime-commands.test.ts | 28 +++++++- packages/cli/src/commands/receiver.ts | 64 +++++++++++-------- packages/cli/src/index.ts | 2 +- .../src/renderer/routes/join-brain.tsx | 2 +- 5 files changed, 71 insertions(+), 31 deletions(-) diff --git a/.agents/skills/wavegrid-distributed-show/SKILL.md b/.agents/skills/wavegrid-distributed-show/SKILL.md index 2376f25..f183696 100644 --- a/.agents/skills/wavegrid-distributed-show/SKILL.md +++ b/.agents/skills/wavegrid-distributed-show/SKILL.md @@ -47,9 +47,11 @@ or `wss://` URL (or use one found by scanning), choose Save, and then Start. The CLI equivalent is `wavegrid projects config set receiver.server wss://grace.hipzap.com` followed by `wavegrid receiver`; the `--server` flag still overrides it. The receiver key must match the brain's project: -`wavegrid projects export --with-secrets` on the brain and import the bundle +`wavegrid projects export --include-secrets` on the brain and import the bundle (or use Desktop Projects → Export/Import), or run `wavegrid projects secrets set -receiverKey`. Clearing `receiver.server` returns to local-brain mode. +receiverKey`. Clearing `receiver.server` returns to local-brain mode. Importing +without secrets means the embedded artist UI shows the brain's login screen; +import with `--include-secrets` (or set `receiverKey`) to avoid it. **At showtime:** operator paints → UI → server `broadcastCommand()` → every receiver filters to its shard → OSC to its hardware. diff --git a/packages/cli/__tests__/runtime-commands.test.ts b/packages/cli/__tests__/runtime-commands.test.ts index 6bb7ef5..12bfa30 100644 --- a/packages/cli/__tests__/runtime-commands.test.ts +++ b/packages/cli/__tests__/runtime-commands.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { applyAssignedShard, applyShardFlag, parseShardRange } from '../src/commands/runtime'; -import { runReceiver } from '../src/commands/receiver'; +import { resolveUpstream, runReceiver } from '../src/commands/receiver'; import { runServer } from '../src/commands/server'; import { buildConfig, CONFIG_FILENAME, serializeConfig } from '../src/config-file'; @@ -171,3 +171,29 @@ describe('runReceiver (dry-run)', () => { expect(result.server).toBe('ws://127.0.0.1:3000'); }); }); + +describe('resolveUpstream', () => { + it('prefers an explicit flag over the configured brain', async () => { + const discover = jest.fn(async () => 'ws://discovered:3000'); + await expect(resolveUpstream('ws://flag:3000', 'ws://configured:3000', discover)).resolves.toBe('ws://flag:3000'); + expect(discover).not.toHaveBeenCalled(); + }); + + it('prefers the configured brain without discovery', async () => { + const discover = jest.fn(async () => 'ws://discovered:3000'); + await expect(resolveUpstream(undefined, 'ws://configured:3000', discover)).resolves.toBe('ws://configured:3000'); + expect(discover).not.toHaveBeenCalled(); + }); + + it('uses the discovered brain when no flag or config is set', async () => { + const discover = jest.fn(async () => 'ws://discovered:3000'); + await expect(resolveUpstream(undefined, undefined, discover)).resolves.toBe('ws://discovered:3000'); + expect(discover).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when no upstream is available', async () => { + const discover = jest.fn(async (): Promise => undefined); + await expect(resolveUpstream(undefined, undefined, discover)).resolves.toBeUndefined(); + expect(discover).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/commands/receiver.ts b/packages/cli/src/commands/receiver.ts index 850d0b7..eba292f 100644 --- a/packages/cli/src/commands/receiver.ts +++ b/packages/cli/src/commands/receiver.ts @@ -6,8 +6,9 @@ * * wavegrid receiver --server ws://192.168.1.42:3333 --shard 0-24 * - * `--server` is the explicit upstream; a configured receiver.server is used - * when no flag or discovered brain is available. + * Upstream precedence is `--server`, configured receiver.server, mDNS + * discovery, coordinator election, then localhost. An explicitly joined + * remote brain must not be hijacked by a stray brain on the LAN. */ import { browse, type DiscoveredBrain } from '@wavegrid/discovery'; import { loadWavegridConfig, type ResolvedConfig } from '@wavegrid/layout'; @@ -30,6 +31,17 @@ export function brainLabel(brain: DiscoveredBrain): string { return `${brain.project} — ${where}:${brain.port}${brain.deviceName ? ` (${brain.deviceName})` : ''}`; } +/** Upstream resolution order: --server flag, then the project's receiver.server, then a discovered brain. */ +export async function resolveUpstream( + flag: string | undefined, + configured: string | undefined, + discover: () => Promise +): Promise { + if (flag) return flag; + if (configured) return configured; + return discover(); +} + export interface ReceiverOptions { cwd?: string; /** Resolve + print the plan but do not start anything (tests). */ @@ -70,16 +82,7 @@ async function selectProject(opts: ReceiverOptions): Promise { export async function runReceiver(opts: ReceiverOptions = {}): Promise { const cwd = opts.cwd ?? process.cwd(); const flags = opts.flags ?? {}; - - // `--server ws://host:port` sets the upstream the receiver dials. Without it - // we try mDNS discovery, then (if nothing is found) hold a coordinator - // election, and only then fall back to the config/localhost default. - let serverFlag = typeof flags.server === 'string' ? flags.server : undefined; - const discover = flags.discover !== false && flags['no-discover'] !== true; - if (!serverFlag && !opts.dryRun && discover) { - const discovered = await discoverServer(opts); - if (discovered) serverFlag = discovered; - } + const resolved = loadWavegridConfig({ cwd }); if (!applyShardFlag(flags.shard)) { console.log(c.red(`Invalid --shard: expected "start-end" (e.g. 0-24), got "${String(flags.shard)}"`)); @@ -87,42 +90,50 @@ export async function runReceiver(opts: ReceiverOptions = {}): Promise {} }; } + // An explicit flag or configured remote takes precedence over discovery: + // an operator who joined a remote brain must not be hijacked by a stray LAN + // brain. + const serverFlag = await resolveUpstream( + typeof flags.server === 'string' ? flags.server : undefined, + resolved.config.receiver.server, + !opts.dryRun && flags.discover !== false && flags['no-discover'] !== true + ? () => discoverServer(opts) + : async () => undefined + ); + let resolvedServer = serverFlag; + const discover = flags.discover !== false && flags['no-discover'] !== true; + if (opts.dryRun) { - const resolved = loadWavegridConfig({ cwd }); - serverFlag ??= resolved.config.receiver.server; - printPlan(resolved, serverFlag); - return { server: serverFlag ?? process.env.SIMULATOR_URL ?? '', stop: () => {} }; + printPlan(resolved, resolvedServer); + return { server: resolvedServer ?? process.env.SIMULATOR_URL ?? '', stop: () => {} }; } const store = getStore(); const project = await selectProject(opts); - const resolved = loadWavegridConfig({ cwd }); - if (!serverFlag && resolved.config.receiver.server) serverFlag = resolved.config.receiver.server; - // No brain on the LAN and this project replicates config across devices → // elect a coordinator so sync still has an authority. The winner promotes // itself to a transient brain (server + local receiver); everyone else homes // to it. Simple/one-laptop projects skip this entirely. - if (!serverFlag && discover && p2pEligible(resolved, flags)) { + if (!resolvedServer && discover && p2pEligible(resolved, flags)) { const device = store.getDevice(); console.log(c.gray(' No brain on the LAN — holding a coordinator election (mDNS)…')); const result = await coordinate({ project, deviceId: device.id }); if (result.role === 'client' && result.server) { console.log(` ${c.green('✓')} homing to elected brain ${c.cyan(result.server)}`); - serverFlag = result.server; + resolvedServer = result.server; } else { console.log(` ${c.green('▶')} ${c.bold('promoted to transient brain')} ${c.gray('— no server on the LAN; peers will connect here.')}`); return promoteToBrain({ store, project, resolved }); } } - if (serverFlag) process.env.SIMULATOR_URL = serverFlag; + if (resolvedServer) process.env.SIMULATOR_URL = resolvedServer; // Wire env first (may set SHARD_START/END from this device's assigned shard) // so the printed plan reflects the shard the receiver will actually drive. applyReceiverEnv(store, project, resolved); - printPlan(resolved, serverFlag, project); + printPlan(resolved, resolvedServer, project); const { startReceiver } = await import('@wavegrid/receiver'); const receiverHandle = startReceiver(resolved); @@ -209,9 +220,10 @@ async function promoteToBrain(ctx: { /** * Browse the LAN for advertised brains. Returns a ws:// URL, or undefined to - * fall through to the config/localhost default. Prompts when several are found - * and a prompter is available; picks the only one automatically. Discovery is - * pure convenience — the connection still authenticates with the shared key. + * fall through to coordinator election or the config/localhost default. + * Prompts when several are found and a prompter is available; picks the only + * one automatically. Discovery is pure convenience — the connection still + * authenticates with the shared key. */ async function discoverServer(opts: ReceiverOptions): Promise { console.log(c.gray(' Searching the LAN for a Wavegrid brain (mDNS)…')); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index caf1ecc..00b5915 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,7 +5,7 @@ export { buildEnvLines, runEnvExport } from './commands/env'; export { runInit } from './commands/init'; export { runPrintConfig } from './commands/print-config'; export { runProjects, runUse } from './commands/projects'; -export { type ReceiverOptions, type ReceiverResult, runReceiver } from './commands/receiver'; +export { type ReceiverOptions, type ReceiverResult, resolveUpstream, runReceiver } from './commands/receiver'; export { applyReceiverEnv, applyServerEnv, applyShardFlag, lanAddresses, resolveUiDir } from './commands/runtime'; export { runSecretsInit, runSecretsList, runSecretsSet } from './commands/secrets'; export { runServer, type ServerOptions, type ServerResult } from './commands/server'; diff --git a/packages/desktop/src/renderer/routes/join-brain.tsx b/packages/desktop/src/renderer/routes/join-brain.tsx index e81e74e..5723ae9 100644 --- a/packages/desktop/src/renderer/routes/join-brain.tsx +++ b/packages/desktop/src/renderer/routes/join-brain.tsx @@ -42,7 +42,7 @@ export function JoinBrain({ value, saved, onChange, onSave, onClear, busy, disab )}

- This laptop runs only a receiver and dials that brain instead of starting its own server. Its receiver key must match the brain’s project: import the project from the brain’s export with secrets (wavegrid projects export --with-secrets, or Projects → Export here), or set it with wavegrid projects secrets set receiverKey. Takes effect on the next Start. + This laptop runs only a receiver and dials that brain instead of starting its own server. Its receiver key must match the brain’s project: import the project from the brain’s export with secrets (wavegrid projects export --include-secrets, or Projects → Export here), or set it with wavegrid projects secrets set receiverKey. Takes effect on the next Start. Without the brain’s secrets the embedded artist UI shows the brain’s login screen — import with secrets (or set receiverKey) to avoid it.

); From 3b7eebc856e0189ece4819f201145de91938bf95 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 21 Sep 2026 04:05:28 +0000 Subject: [PATCH 4/4] docs: login-screen note names jwtSecret --- .agents/skills/wavegrid-distributed-show/SKILL.md | 5 +++-- packages/desktop/src/renderer/routes/join-brain.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.agents/skills/wavegrid-distributed-show/SKILL.md b/.agents/skills/wavegrid-distributed-show/SKILL.md index f183696..b7b301a 100644 --- a/.agents/skills/wavegrid-distributed-show/SKILL.md +++ b/.agents/skills/wavegrid-distributed-show/SKILL.md @@ -50,8 +50,9 @@ still overrides it. The receiver key must match the brain's project: `wavegrid projects export --include-secrets` on the brain and import the bundle (or use Desktop Projects → Export/Import), or run `wavegrid projects secrets set receiverKey`. Clearing `receiver.server` returns to local-brain mode. Importing -without secrets means the embedded artist UI shows the brain's login screen; -import with `--include-secrets` (or set `receiverKey`) to avoid it. +without secrets means the embedded artist UI shows the brain's login screen +(the desktop signs it in with the project's `jwtSecret`); import with +`--include-secrets` to avoid it. **At showtime:** operator paints → UI → server `broadcastCommand()` → every receiver filters to its shard → OSC to its hardware. diff --git a/packages/desktop/src/renderer/routes/join-brain.tsx b/packages/desktop/src/renderer/routes/join-brain.tsx index 5723ae9..07a1e75 100644 --- a/packages/desktop/src/renderer/routes/join-brain.tsx +++ b/packages/desktop/src/renderer/routes/join-brain.tsx @@ -42,7 +42,7 @@ export function JoinBrain({ value, saved, onChange, onSave, onClear, busy, disab )}

- This laptop runs only a receiver and dials that brain instead of starting its own server. Its receiver key must match the brain’s project: import the project from the brain’s export with secrets (wavegrid projects export --include-secrets, or Projects → Export here), or set it with wavegrid projects secrets set receiverKey. Takes effect on the next Start. Without the brain’s secrets the embedded artist UI shows the brain’s login screen — import with secrets (or set receiverKey) to avoid it. + This laptop runs only a receiver and dials that brain instead of starting its own server. Its receiver key must match the brain’s project: import the project from the brain’s export with secrets (wavegrid projects export --include-secrets, or Projects → Export here), or set it with wavegrid projects secrets set receiverKey. Takes effect on the next Start. Without the brain’s secrets (its jwtSecret) the embedded artist UI shows the brain’s login screen — import with secrets to avoid it.

);