Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions packages/cli/src/commands/osc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { OscConfig, WavegridConfig } from '@wavegrid/layout';
import { LOOPBACK_HOST, normalizeOscHost, type OscConfig, type WavegridConfig } from '@wavegrid/layout';
import type { Inquirerer, Question } from 'inquirerer';
import c from 'yanse';

Expand Down Expand Up @@ -53,7 +53,7 @@ function num(flags: Flags, key: string): number | undefined {
/** Non-interactive setters (no TTY / scripted). Return true on success. */
function applyFromFlags(flags: Flags, kind: string): boolean {
if (kind === 'beyond') {
const host = str(flags, 'host');
const host = normalizeOscHost(str(flags, 'host') ?? '');
if (!host) return false;
const port = num(flags, 'port') ?? 7001;
const gridOrder = str(flags, 'grid-order') === 'column' ? 'column' : 'row';
Expand All @@ -64,7 +64,7 @@ function applyFromFlags(flags: Flags, kind: string): boolean {
return true;
}
if (kind === 'fb4') {
const host = str(flags, 'host');
const host = normalizeOscHost(str(flags, 'host') ?? '');
if (!host) return false;
const port = num(flags, 'port') ?? 8000;
const project = save(flags, (config) => {
Expand All @@ -90,8 +90,8 @@ async function wizardBeyond(prompter: Inquirerer, current?: OscConfig): Promise<
{
type: 'text',
name: 'host',
message: 'BEYOND host (the machine running BEYOND, e.g. 192.168.1.50)',
default: current?.beyond?.host,
message: `BEYOND host \u2014 ${LOOPBACK_HOST} for this machine, or the LAN IP of the PC running BEYOND`,
default: current?.beyond?.host ?? LOOPBACK_HOST,
required: true
} as Question,
{
Expand All @@ -110,15 +110,21 @@ async function wizardBeyond(prompter: Inquirerer, current?: OscConfig): Promise<
required: true
} as Question
])) as unknown as { host: string; port: number; gridOrder: 'row' | 'column' };
return { beyond: { host: answers.host.trim(), port: Number(answers.port), gridOrder: answers.gridOrder } };
return {
beyond: {
host: normalizeOscHost(answers.host),
port: Number(answers.port),
gridOrder: answers.gridOrder
}
};
}

async function wizardFb4(prompter: Inquirerer, current?: OscConfig): Promise<OscConfig> {
const answers = (await prompter.prompt({}, [
{ type: 'text', name: 'host', message: 'FB4 host', default: current?.fb4?.host, required: true } as Question,
{ type: 'number', name: 'port', message: 'FB4 OSC port', default: current?.fb4?.port ?? 8000, required: true } as Question
])) as unknown as { host: string; port: number };
return { fb4: { host: answers.host.trim(), port: Number(answers.port) } };
return { fb4: { host: normalizeOscHost(answers.host), port: Number(answers.port) } };
}

async function wizardRouting(prompter: Inquirerer, current?: OscConfig): Promise<OscConfig> {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/receiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import c from 'yanse';

import { type Flags, getStore, resolveProjectName } from '../project';
import { coordinate } from './coordinate';
import { applyReceiverEnv, applyServerEnv, applyShardFlag } from './runtime';
import { applyReceiverEnv, applyServerEnv, applyShardFlag, awaitBind } from './runtime';

/** Turn a discovered brain into the ws:// URL the receiver dials. */
export function brainToWsUrl(brain: DiscoveredBrain): string {
Expand Down Expand Up @@ -179,8 +179,8 @@ async function promoteToBrain(ctx: {
const serverHandle = startServer(resolved, {
advertise: { project, deviceId: device.id, deviceName: device.name, transient: true }
});
// Let the server bind before the local receiver dials in.
await new Promise((r) => setTimeout(r, 250));
// The receiver dials in only once the port is actually bound.
await awaitBind(serverHandle);
const receiverHandle = startReceiver(resolved);

let stopped = false;
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,23 @@ export function applyGeneratedRouting(
}
}

/**
* Wait for the brain to actually bind its port, and fail with the bind error
* rather than a stack trace from an unhandled `error` event. Without this a
* clashing port left the CLI printing "brain up" while nothing listened.
*/
export async function awaitBind(handle: { ready: Promise<void>; stop: () => void }): Promise<void> {
try {
await handle.ready;
} catch (e) {
handle.stop();
console.log('');
console.log(c.red(` ✗ ${e instanceof Error ? e.message : String(e)}`));
console.log('');
throw e;
}
}

/** IPv4 LAN addresses of this machine — the URLs operators point iPads/receivers at. */
export function lanAddresses(): string[] {
const out: string[] = [];
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { Inquirerer, Question } from 'inquirerer';
import c from 'yanse';

import { type Flags, getStore, resolveProjectName } from '../project';
import { applyServerEnv, printLanUrls } from './runtime';
import { applyServerEnv, awaitBind, printLanUrls } from './runtime';

export interface ServerOptions {
cwd?: string;
Expand Down Expand Up @@ -73,6 +73,7 @@ export async function runServer(opts: ServerOptions = {}): Promise<ServerResult>
const serverHandle = startServer(resolved, {
advertise: { project, deviceId: device.id, deviceName: device.name }
});
await awaitBind(serverHandle);

let stopped = false;
const stop = () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import c from 'yanse';

import { findConfigFile } from '../config-file';
import { type Flags, getStore, resolveProjectName } from '../project';
import { applyReceiverEnv, applyServerEnv } from './runtime';
import { applyReceiverEnv, applyServerEnv, awaitBind } from './runtime';

export interface StartOptions {
cwd?: string;
Expand Down Expand Up @@ -107,8 +107,8 @@ export async function runStart(opts: StartOptions = {}): Promise<StartResult> {
const { startReceiver } = await import('@wavegrid/receiver');

const serverHandle = startServer(resolved);
// Let the server bind before the receiver dials in.
await new Promise((r) => setTimeout(r, 250));
// The receiver dials in only once the port is actually bound.
await awaitBind(serverHandle);
const receiverHandle = startReceiver(resolved);

let stopped = false;
Expand Down
57 changes: 50 additions & 7 deletions packages/desktop/src/main/brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,15 @@ interface RunningBrain {
runMode: BrainStatus['runMode'];
server: ServerHandle;
receiver: ReceiverHandle | null;
/** Why the output stage isn't running, when the brain came up without it. */
receiverError: string | null;
}

let current: RunningBrain | null = null;

/** Why the last start attempt failed — kept so the UI can explain a red brain. */
let lastError: string | null = null;

/** IPv4 LAN addresses — the URLs operators point iPads / receivers at. */
function lanAddresses(): string[] {
const out: string[] = [];
Expand Down Expand Up @@ -82,9 +87,20 @@ export function status(): BrainStatus {
project: current.project,
runMode: current.runMode,
receiverRunning: current.receiver != null,
lanUrls: lanAddresses().map((ip) => `http://${ip}:${new URL(current!.url).port}`)
lanUrls: lanAddresses().map((ip) => `http://${ip}:${new URL(current!.url).port}`),
receiverError: current.receiverError,
lastError: null
}
: { running: false, url: null, project: null, runMode: null, receiverRunning: false, lanUrls: [] };
: {
running: false,
url: null,
project: null,
runMode: null,
receiverRunning: false,
lanUrls: [],
receiverError: null,
lastError
};
runtime.lastStatus = s;
return s;
}
Expand All @@ -97,7 +113,17 @@ function broadcast(): BrainStatus {

export async function startBrain(project: string): Promise<BrainStatus> {
if (current) await stopBrain();
lastError = null;
try {
return await start(project);
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
broadcast();
throw err;
}
}

async function start(project: string): Promise<BrainStatus> {
const store = openStore();
if (!store.hasProject(project)) throw new Error(`Unknown project: ${project}`);
if (store.getActiveProject() !== project) store.setActiveProject(project);
Expand All @@ -108,16 +134,25 @@ export async function startBrain(project: string): Promise<BrainStatus> {

const { startServer } = await import('@wavegrid/server');
const server = startServer(resolved);
// Let the server bind before the receiver dials in.
await new Promise((r) => setTimeout(r, 250));
// Wait for the actual bind: a port clash surfaces here rather than leaving
// the UI reporting a running show with nothing listening.
try {
await server.ready;
} catch (err) {
server.stop();
throw err;
}

let receiver: ReceiverHandle | null = null;
let receiverError: string | null = null;
try {
const { startReceiver } = await import('@wavegrid/receiver');
receiver = startReceiver(resolved);
} catch (err) {
// A receiver failure (no OSC target, network) must not take down the show:
// the brain + laser UI still run console-only.
// the brain + laser UI still run console-only. Reported, not just logged —
// otherwise the show looks healthy while nothing reaches the lasers.
receiverError = err instanceof Error ? err.message : String(err);
console.error('[brain] receiver failed to start:', err);
}

Expand All @@ -127,7 +162,8 @@ export async function startBrain(project: string): Promise<BrainStatus> {
url: `http://127.0.0.1:${port}`,
runMode: resolved.runMode,
server,
receiver
receiver,
receiverError
};
return broadcast();
}
Expand All @@ -150,7 +186,14 @@ export async function startLocalReceiver(): Promise<BrainStatus> {
const store = openStore();
applyReceiverEnv(store, current.project);
const { startReceiver } = await import('@wavegrid/receiver');
current.receiver = startReceiver(loadWavegridConfig());
try {
current.receiver = startReceiver(loadWavegridConfig());
current.receiverError = null;
} catch (err) {
current.receiverError = err instanceof Error ? err.message : String(err);
broadcast();
throw err;
}
return broadcast();
}

Expand Down
11 changes: 9 additions & 2 deletions packages/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
stopLocalReceiver
} from '@/main/brain';
import { buildDoctorReport } from '@/main/doctor';
import { type LaserSyncState, syncLaser } from '@/main/laser-view';
import { invalidateLaserView, type LaserSyncState, syncLaser } from '@/main/laser-view';
import { buildLightMapView } from '@/main/light-map';
import { applyOscTarget, toOscTarget } from '@/main/osc-target';
import {
Expand Down Expand Up @@ -99,7 +99,14 @@ function devices(project: string): DeviceInfo[] {
* store / brain — the renderer never touches the store or `fs` directly. */
export function registerAllIpc(): void {
ipcMain.handle('brain:status', () => status());
ipcMain.handle('brain:start', (_e, project: string) => startBrain(project));
// The embedded artist UI is served on the same origin whichever project runs,
// so it has to be reloaded explicitly or it keeps the previous project's
// layout and light map.
ipcMain.handle('brain:start', async (_e, project: string) => {
const s = await startBrain(project);
invalidateLaserView();
return s;
});
ipcMain.handle('brain:stop', () => stopBrain());
// Receiver-only controls: the output stage reads its OSC target, shard, and
// light map at startup, so restarting just the receiver applies a config
Expand Down
15 changes: 15 additions & 0 deletions packages/desktop/src/main/laser-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export function resetLaserView(): void {
loadedUrl = null;
}

/**
* Drop the loaded-URL memo so the next sync reloads the page. The brain serves
* a different project on the same origin after a project switch, so without
* this the embedded UI keeps rendering the previous project's layout.
*/
export function invalidateLaserView(): void {
loadedUrl = null;
if (view && !view.webContents.isDestroyed()) void view.webContents.reload();
}

function ensureView(): WebContentsView | null {
const win = runtime.mainWindow;
if (!win || win.isDestroyed()) return null;
Expand All @@ -34,6 +44,11 @@ function ensureView(): WebContentsView | null {
void shell.openExternal(url);
return { action: 'deny' };
});
// A load that failed (brain not listening yet) must not count as loaded, or
// the URL gate below would never retry and the panel would stay blank.
created.webContents.on('did-fail-load', () => {
loadedUrl = null;
});
win.contentView.addChildView(created);
view = created;
return created;
Expand Down
12 changes: 7 additions & 5 deletions packages/desktop/src/main/osc-target.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Pure helpers translating between the stored OscConfig and the flat
// OscTarget the renderer binds to. Same four choices as the CLI's
// `wavegrid projects osc` wizard: BEYOND, FB4, a routing file, or none.
import type { OscConfig } from '@wavegrid/layout';
import { normalizeOscHost, type OscConfig } from '@wavegrid/layout';
import type { ProjectConfig } from '@wavegrid/settings';

import type { OscTarget } from '@/types/ipc';
Expand Down Expand Up @@ -67,8 +67,10 @@ export function applyOscTarget(existing: ProjectConfig | null, target: OscTarget
const keep = routing ? { routing } : {};

if (target.kind === 'beyond') {
const host = target.host.trim();
if (!host) throw new Error('BEYOND needs the host running BEYOND (e.g. 192.168.1.50).');
const host = normalizeOscHost(target.host);
if (!host) {
throw new Error('BEYOND needs the host running BEYOND — 127.0.0.1 for this laptop, or its LAN IP.');
}
return {
...prev,
osc: {
Expand All @@ -82,8 +84,8 @@ export function applyOscTarget(existing: ProjectConfig | null, target: OscTarget
};
}
if (target.kind === 'fb4') {
const host = target.host.trim();
if (!host) throw new Error('FB4 needs a host address.');
const host = normalizeOscHost(target.host);
if (!host) throw new Error('FB4 needs a host address — the FB4 device’s IP.');
return {
...prev,
osc: { ...keep, fb4: { host, port: validPort(target.port, DEFAULT_FB4_PORT) } }
Expand Down
4 changes: 3 additions & 1 deletion packages/desktop/src/main/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export const runtime: Runtime = {
project: null,
runMode: null,
receiverRunning: false,
lanUrls: []
lanUrls: [],
receiverError: null,
lastError: null
}
};

Expand Down
Loading
Loading