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
55 changes: 4 additions & 51 deletions packages/cli/src/commands/env.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,13 @@
import { loadWavegridConfig, type WavegridConfig } from '@wavegrid/layout';
import { configEnvMap, loadWavegridConfig } from '@wavegrid/layout';
import { writeFileSync } from 'fs';
import { join } from 'path';
import c from 'yanse';

import { type Flags, getStore, resolveProjectName } from '../project';

/**
* Project the non-secret resolved config into the env-var names the server and
* receiver read directly. This is how config authored in the store or a local
* wavegrid.json (not env) reaches the runtime.
*/
export function configEnvMap(config: WavegridConfig): Record<string, string> {
const env: Record<string, string> = {};
const set = (k: string, v: string | number | undefined) => {
if (v !== undefined && v !== '') env[k] = String(v);
};

if (config.layout.preset) set('WAVEGRID_LAYOUT', config.layout.preset);
set('WAVEGRID_MODE', config.mode);
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('RECEIVER_ALPHA', config.receiver.alpha);
set('FALLBACK_DELAY', config.receiver.fallbackDelay);
if (config.receiver.shard) {
set('SHARD_START', config.receiver.shard.start);
set('SHARD_END', config.receiver.shard.end);
}
set('LIGHT_MAP_CONFIG', config.receiver.lightMap);

if (config.osc.beyond) {
set('BEYOND_HOST', config.osc.beyond.host);
set('BEYOND_PORT', config.osc.beyond.port);
set('BEYOND_GRID_ORDER', config.osc.beyond.gridOrder);
}
if (config.osc.fb4) {
set('FB4_HOST', config.osc.fb4.host);
set('FB4_PORT', config.osc.fb4.port);
}
set('ROUTING_CONFIG', config.osc.routingConfig);

if (config.debug.osc) set('DEBUG_OSC', '1');
set('DEBUG_UI_PORT', config.debug.uiPort);

return env;
}

/** Set config-derived env vars that aren't already present (operator env wins). */
export function applyConfigToEnv(config: WavegridConfig): void {
const map = configEnvMap(config);
for (const [k, v] of Object.entries(map)) {
if (!process.env[k]) process.env[k] = v;
}
}
// The config→env projection lives in @wavegrid/layout beside the loader that
// parses it back, so the desktop app applies exactly what the CLI does.
export { applyConfigToEnv, configEnvMap } from '@wavegrid/layout';

/**
* Build the `.env` lines for a project from its resolved config + secrets.
Expand Down
145 changes: 145 additions & 0 deletions packages/desktop/__tests__/receiver-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* The desktop show has to reach the same lasers the OSC debugger does.
*
* `startReceiver` takes its OSC target from `process.env` alone — the resolved
* config it is handed is only geometry — so the desktop app used to start a
* receiver with no target at all: paint reached the brain over the WebSocket,
* the show looked healthy, and nothing reached BEYOND. These tests pin the env
* the receiver is handed for a given project.
*/
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import {
DEFAULT_CONFIG,
loadWavegridConfig,
type ResolvedConfig,
type WavegridConfig
} from '@wavegrid/layout';
import type { SettingsStore } from '@wavegrid/settings';

import { applyReceiverEnv, resolveProjectConfig } from '@/main/receiver-env';

const root = mkdtempSync(join(tmpdir(), 'wavegrid-receiver-env-'));

/** Just the store surface the receiver env needs. */
const store = {
requireSecret: () => 'receiver-key',
stateDir: (project: string) => join(root, project, 'state'),
logsDir: (project: string) => join(root, project, 'logs'),
getDevice: () => ({ id: 'device-1', name: 'booth' }),
getDeviceRecord: () => ({ shard: { start: 0, end: 11 } })
} as unknown as SettingsStore;

/** Resolve a project config the way the appstash mirror would, env-free. */
function resolve(config: Partial<WavegridConfig>): ResolvedConfig {
return loadWavegridConfig({
cwd: join(root, 'nonexistent'),
env: {},
overrides: { ...DEFAULT_CONFIG, ...config }
});
}

const beyond = resolve({
layout: { preset: 'grace-cathedral' },
osc: { beyond: { host: '10.0.0.5', port: 8000, gridOrder: 'row' } }
});
const consoleOnly = resolve({ osc: {} });

/** Keys `applyReceiverEnv` owns, cleared so each test starts from a cold app. */
const OWNED = [
'BEYOND_HOST',
'BEYOND_PORT',
'BEYOND_GRID_ORDER',
'FB4_HOST',
'FB4_PORT',
'ROUTING_CONFIG',
'SHARD_START',
'SHARD_END',
'WAVEGRID_LAYOUT',
'WG_RECEIVER_KEY',
'WG_STATE_DIR',
'WG_DEVICE_ID',
'WG_DEVICE_NAME',
'RECEIVER_LOG'
];

beforeEach(() => {
for (const key of OWNED) delete process.env[key];
});

describe('applyReceiverEnv', () => {
it('hands the receiver the project’s BEYOND target', () => {
applyReceiverEnv(store, 'grace', beyond);
expect(process.env.BEYOND_HOST).toBe('10.0.0.5');
expect(process.env.BEYOND_PORT).toBe('8000');
expect(process.env.BEYOND_GRID_ORDER).toBe('row');
expect(process.env.WAVEGRID_LAYOUT).toBe('grace-cathedral');
});

it('hands it an FB4 target', () => {
applyReceiverEnv(store, 'fb4', resolve({ osc: { fb4: { host: '192.168.1.40', port: 8000 } } }));
expect(process.env.FB4_HOST).toBe('192.168.1.40');
expect(process.env.FB4_PORT).toBe('8000');
});

// 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', () => {
applyReceiverEnv(store, 'grace', beyond);
applyReceiverEnv(store, 'rehearsal', consoleOnly);
expect(process.env.BEYOND_HOST).toBeUndefined();
expect(process.env.BEYOND_PORT).toBeUndefined();
expect(process.env.ROUTING_CONFIG).toBeUndefined();
});

it('repoints at the switched-to project’s own target', () => {
applyReceiverEnv(store, 'grace', beyond);
applyReceiverEnv(
store,
'other',
resolve({ osc: { beyond: { host: '10.0.0.9', port: 7001, gridOrder: 'column' } } })
);
expect(process.env.BEYOND_HOST).toBe('10.0.0.9');
expect(process.env.BEYOND_PORT).toBe('7001');
});

it('points at a routing file generated from a multi-target spec', () => {
const routed = resolve({
layout: { preset: 'grace-cathedral' },
osc: {
routing: {
targets: {
left: { type: 'beyond', host: '10.0.0.5', port: 8000 },
right: { type: 'beyond', host: '10.0.0.6', port: 8000 }
},
cannons: Array.from({ length: 25 }, (_, logical) => ({
logical,
target: logical < 13 ? 'left' : 'right'
}))
}
}
});
applyReceiverEnv(store, 'routed', routed);
expect(process.env.ROUTING_CONFIG).toBe(join(root, 'routed', 'state', 'routing', 'this-device.json'));
});

it('names the project’s own state dir and receiver log', () => {
applyReceiverEnv(store, 'grace', beyond);
expect(process.env.WG_STATE_DIR).toBe(join(root, 'grace', 'state'));
expect(process.env.RECEIVER_LOG).toBe(join(root, 'grace', 'logs', 'receiver.log'));
expect(process.env.WG_DEVICE_NAME).toBe('booth');
});
});

describe('resolveProjectConfig', () => {
// Env outranks the project layer in the loader, and applyReceiverEnv writes
// into env — so reading the live env would resolve one project's show against
// another project's lasers.
it('ignores the env the app itself wrote', () => {
applyReceiverEnv(store, 'grace', beyond);
expect(process.env.BEYOND_HOST).toBe('10.0.0.5');
expect(resolveProjectConfig().config.osc.beyond?.host).not.toBe('10.0.0.5');
});
});
30 changes: 30 additions & 0 deletions packages/desktop/__tests__/show-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { hasOscOutput, oscOutputs } from '@/renderer/lib/show-output';
import type { BrainStatus } from '@/types/ipc';

const status = (receiverOutputs: string[]): BrainStatus => ({
running: true,
url: 'http://127.0.0.1:3000',
project: 'grace',
runMode: 'simple',
receiverRunning: true,
lanUrls: [],
receiverError: null,
receiverOutputs,
lastError: null
});

describe('what the show is driving', () => {
// A receiver with no target starts cleanly and reports no error, so this is
// the only signal that separates a healthy show from a dark rig.
it('treats a console-only receiver as no OSC output', () => {
expect(hasOscOutput(status(['Console']))).toBe(false);
expect(hasOscOutput(status([]))).toBe(false);
expect(oscOutputs(status(['Console']))).toEqual([]);
});

it('recognises any real output', () => {
const driving = status(['Console', 'BEYOND OSC → 10.0.0.5:8000 (row-major, rgb)']);
expect(hasOscOutput(driving)).toBe(true);
expect(oscOutputs(driving)).toEqual(['BEYOND OSC → 10.0.0.5:8000 (row-major, rgb)']);
});
});
25 changes: 10 additions & 15 deletions packages/desktop/src/main/brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { createRequire } from 'node:module';
import { networkInterfaces } from 'node:os';
import { join } from 'node:path';

import { loadWavegridConfig, type ResolvedConfig } from '@wavegrid/layout';
import type { ResolvedConfig } from '@wavegrid/layout';
import type { ReceiverHandle } from '@wavegrid/receiver';
import type { ServerHandle } from '@wavegrid/server';
import { openStore, type SettingsStore } from '@wavegrid/settings';

import { applyReceiverEnv, resolveProjectConfig } from '@/main/receiver-env';
import { runtime, sendToRenderer } from '@/main/runtime';
import type { BrainStatus } from '@/types/ipc';

Expand Down Expand Up @@ -70,15 +71,6 @@ function applyServerEnv(store: SettingsStore, project: string): void {
if (uiDir) process.env.WG_UI_DIR = uiDir;
}

function applyReceiverEnv(store: SettingsStore, project: string): void {
if (!process.env.WG_RECEIVER_KEY) process.env.WG_RECEIVER_KEY = store.requireSecret(project, 'receiverKey');
process.env.WG_STATE_DIR = store.stateDir(project);
process.env.RECEIVER_LOG = join(store.logsDir(project), 'receiver.log');
const device = store.getDevice();
process.env.WG_DEVICE_ID = device.id;
process.env.WG_DEVICE_NAME = device.name;
}

export function status(): BrainStatus {
const s: BrainStatus = current
? {
Expand All @@ -89,6 +81,7 @@ export function status(): BrainStatus {
receiverRunning: current.receiver != null,
lanUrls: lanAddresses().map((ip) => `http://${ip}:${new URL(current!.url).port}`),
receiverError: current.receiverError,
receiverOutputs: current.receiver?.outputs ?? [],
lastError: null
}
: {
Expand All @@ -99,6 +92,7 @@ export function status(): BrainStatus {
receiverRunning: false,
lanUrls: [],
receiverError: null,
receiverOutputs: [],
lastError
};
runtime.lastStatus = s;
Expand Down Expand Up @@ -128,9 +122,8 @@ async function start(project: string): Promise<BrainStatus> {
if (!store.hasProject(project)) throw new Error(`Unknown project: ${project}`);
if (store.getActiveProject() !== project) store.setActiveProject(project);

const resolved: ResolvedConfig = loadWavegridConfig();
const resolved: ResolvedConfig = resolveProjectConfig();
applyServerEnv(store, project);
applyReceiverEnv(store, project);

const { startServer } = await import('@wavegrid/server');
const server = startServer(resolved);
Expand All @@ -146,6 +139,7 @@ async function start(project: string): Promise<BrainStatus> {
let receiver: ReceiverHandle | null = null;
let receiverError: string | null = null;
try {
applyReceiverEnv(store, project, resolved);
const { startReceiver } = await import('@wavegrid/receiver');
receiver = startReceiver(resolved);
} catch (err) {
Expand Down Expand Up @@ -174,7 +168,7 @@ async function start(project: string): Promise<BrainStatus> {
export function runningBind(): { host: string; port: number } | null {
if (!current) return null;
return {
host: loadWavegridConfig().config.server.host,
host: resolveProjectConfig().config.server.host,
port: Number(new URL(current.url).port)
};
}
Expand All @@ -195,10 +189,11 @@ export async function startLocalReceiver(): Promise<BrainStatus> {
if (current.receiver) return status();

const store = openStore();
applyReceiverEnv(store, current.project);
const { startReceiver } = await import('@wavegrid/receiver');
try {
current.receiver = startReceiver(loadWavegridConfig());
const resolved = resolveProjectConfig();
applyReceiverEnv(store, current.project, resolved);
current.receiver = startReceiver(resolved);
current.receiverError = null;
} catch (err) {
current.receiverError = err instanceof Error ? err.message : String(err);
Expand Down
6 changes: 4 additions & 2 deletions packages/desktop/src/main/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,21 @@
* time formatting happens here so the renderer stays free of node deps.
*/
import { collectDiagnostics } from '@wavegrid/doctor';
import { loadWavegridConfig } from '@wavegrid/layout';
import { formatRanges } from '@wavegrid/server';
import { openStore } from '@wavegrid/settings';

import { receiverRunning } from '@/main/brain';
import { resolveProjectConfig } from '@/main/receiver-env';
import type { DoctorReceiver, DoctorReport } from '@/types/ipc';

export async function buildDoctorReport(project: string): Promise<DoctorReport | null> {
const store = openStore();
if (!project || !store.hasProject(project)) return null;
if (store.getActiveProject() !== project) store.setActiveProject(project);

const resolved = loadWavegridConfig();
// Via the brain, so a diagnosis reads the selected project's own config and
// not whatever a previous show left in this long-lived process's env.
const resolved = resolveProjectConfig();
const diag = await collectDiagnostics({ store, project, resolved });
const nameFor = (id: string): string =>
diag.devices.find((d) => d.id === id)?.name ?? `${id.slice(0, 8)}…`;
Expand Down
Loading
Loading