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
30 changes: 29 additions & 1 deletion packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
checkEnvHijack,
collectDiagnostics,
type Diagnostics,
overallStatus
type NetworkReport,
overallStatus,
probeNetwork
} from '@wavegrid/doctor';
import { loadWavegridConfig } from '@wavegrid/layout';
import { formatRanges, type SystemStatus } from '@wavegrid/server';
Expand Down Expand Up @@ -93,6 +95,25 @@ function renderSync(diag: Diagnostics): void {
}
}

/**
* Can anything else on the wifi reach this brain? Only printed while the show
* is up, because every line of it is a live probe of the running server.
*/
function renderNetwork(net: NetworkReport): void {
console.log('');
console.log(c.bold(' Network'));
const tone = net.verdict === 'proven-reachable' ? c.green : net.verdict === 'unproven' || net.verdict === 'isolation-likely' ? c.yellow : c.red;
console.log(` ${tone('●')} ${net.summary}`);
if (net.hint) console.log(` ${c.gray('↳ ' + net.hint)}`);
for (const p of net.selfProbes) {
const mark = p.reachable ? c.green('✓') : c.red('✗');
console.log(` ${mark} ${c.cyan(p.url)} ${c.gray(p.reachable ? 'open from this machine' : 'blocked from this machine')}`);
}
for (const v of net.visitors) {
console.log(` • ${c.cyan(v.address)} ${c.gray(seenAgo(v.lastSeen))}`);
}
}

function seenAgo(lastSeen: number): string {
const secs = Math.round((Date.now() - lastSeen) / 1000);
if (secs < 60) return `seen ${secs}s ago`;
Expand Down Expand Up @@ -160,6 +181,13 @@ export async function runDoctor(flags: Flags = {}, cwd = process.cwd()): Promise

if (diag.server) {
renderSystem(diag.server);
renderNetwork(
await probeNetwork({
bindHost: diag.server.server.host,
port: diag.server.server.port,
visitors: diag.server.lanVisitors
})
);
} else if (diag.serverError === 'not-running') {
console.log('');
console.log(c.gray(` Server not running at ${diag.serverUrl} (start it with \`wavegrid start\`).`));
Expand Down
8 changes: 7 additions & 1 deletion packages/desktop/__tests__/main-externals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ const DESKTOP = join(__dirname, '..');
function mainImports(): string[] {
// Read the source rather than importing it: this must reflect what Rollup
// sees, not what a test bundler resolves.
const files = ['src/main.ts', 'src/main/brain.ts', 'src/main/doctor.ts', 'src/main/ipc.ts'];
const files = [
'src/main.ts',
'src/main/brain.ts',
'src/main/doctor.ts',
'src/main/ipc.ts',
'src/main/network.ts'
];
const found = new Set<string>();
for (const file of files) {
const src = readFileSync(join(DESKTOP, file), 'utf8');
Expand Down
22 changes: 22 additions & 0 deletions packages/desktop/__tests__/share-urls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { rankLanUrls } from '../src/renderer/lib/share-urls';

describe('rankLanUrls', () => {
it('offers the home/venue wifi range before docker and VPN ranges', () => {
const ranked = rankLanUrls([
'http://172.17.0.1:3000',
'http://10.8.0.6:3000',
'http://192.168.1.50:3000'
]);
expect(ranked[0]).toBe('http://192.168.1.50:3000');
expect(ranked[1]).toBe('http://10.8.0.6:3000');
});

it('sinks a self-assigned address below anything routable', () => {
const ranked = rankLanUrls(['http://169.254.3.9:3000', 'http://100.64.2.2:3000']);
expect(ranked[0]).toBe('http://100.64.2.2:3000');
});

it('leaves a single URL alone', () => {
expect(rankLanUrls(['http://192.168.1.50:3000'])).toEqual(['http://192.168.1.50:3000']);
});
});
2 changes: 2 additions & 0 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"electron-squirrel-startup": "^1.0.1",
"lucide-react": "^0.469.0",
"motion": "^13.0.0",
"qrcode": "1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^2.6.0"
Expand All @@ -56,6 +57,7 @@
"@electron/fuses": "^1.8.0",
"@tailwindcss/vite": "^4.1.10",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.3.4",
Expand Down
65 changes: 65 additions & 0 deletions packages/desktop/src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client';

import { Popover as PopoverPrimitive } from '@base-ui/react/popover';
import * as React from 'react';

import { useFloatingOverlayPortalProps } from '@/components/ui/portal';
import { cn } from '@/lib/utils';

function Popover(props: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />;
}

type PopoverTriggerProps = React.ComponentProps<typeof PopoverPrimitive.Trigger> & {
/** When true, merges props onto the child element instead of rendering a button */
asChild?: boolean;
};

function PopoverTrigger({ asChild, children, render, ...props }: PopoverTriggerProps) {
const childRender = render === undefined && asChild && React.isValidElement(children) ? children : undefined;

return (
<PopoverPrimitive.Trigger data-slot='popover-trigger' render={render ?? childRender} {...props}>
{childRender ? undefined : children}
</PopoverPrimitive.Trigger>
);
}

type PopoverContentProps = React.ComponentProps<typeof PopoverPrimitive.Popup> & {
sideOffset?: number;
side?: 'top' | 'bottom' | 'left' | 'right';
align?: 'start' | 'center' | 'end';
};

function PopoverContent({
className,
sideOffset = 6,
side = 'bottom',
align = 'center',
children,
...props
}: PopoverContentProps) {
const { container, zIndexClass } = useFloatingOverlayPortalProps();

return (
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Positioner side={side} align={align} sideOffset={sideOffset} className={zIndexClass}>
<PopoverPrimitive.Popup
data-slot='popover-content'
className={cn(
`bg-popover text-popover-foreground origin-(--transform-origin) rounded-lg border p-4 shadow-md
transition-[scale,opacity] duration-150 ease-out data-starting-style:scale-95
data-ending-style:scale-95 data-starting-style:opacity-0 data-ending-style:opacity-0
motion-reduce:transition-none`,
className,
)}
{...props}
>
{children}
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}

export { Popover, PopoverContent, PopoverTrigger };
11 changes: 11 additions & 0 deletions packages/desktop/src/main/brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ async function start(project: string): Promise<BrainStatus> {
return broadcast();
}

/** What the running server bound to, or null when the brain 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;
return {
host: loadWavegridConfig().config.server.host,
port: Number(new URL(current.url).port)
};
}

/** Whether this machine's in-process receiver is driving `project` right now. */
export function receiverRunning(project: string): boolean {
return current?.project === project && current.receiver != null;
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
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 { applyOscTarget, toOscTarget } from '@/main/osc-target';
import {
applyEditable,
Expand Down Expand Up @@ -115,6 +116,7 @@ export function registerAllIpc(): void {
ipcMain.handle('brain:stopReceiver', () => stopLocalReceiver());

ipcMain.handle('doctor:report', (_e, project: string) => buildDoctorReport(project));
ipcMain.handle('doctor:network', () => buildNetworkReport());

ipcMain.handle('projects:list', () => projectSummaries());
ipcMain.handle('projects:active', () => openStore().getActiveProject());
Expand Down
38 changes: 38 additions & 0 deletions packages/desktop/src/main/network.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Network diagnostics for the Status screen's Advanced section. Same collector
* the CLI uses, so the app and the terminal agree about why a phone can't load
* the show. Runs in the main process because it needs the OS interface list and
* the in-process server's record of who has connected.
*/
import { probeNetwork } from '@wavegrid/doctor';
import { lanVisitors } from '@wavegrid/server';

import { runningBind } from '@/main/brain';
import type { NetworkReport } from '@/types/ipc';

export async function buildNetworkReport(): Promise<NetworkReport | null> {
const bind = runningBind();
if (!bind) return null;
const report = await probeNetwork({
bindHost: bind.host,
port: bind.port,
visitors: lanVisitors().map((v) => ({
address: v.address,
userAgent: v.userAgent,
lastSeen: v.lastSeen
}))
});
return {
bindHost: report.bindHost,
port: report.port,
interfaces: report.interfaces,
selfProbes: report.selfProbes,
neighbourCount: report.neighbours.length,
neighboursKnown: report.neighboursKnown,
visitors: report.visitors,
verdict: report.verdict,
summary: report.summary,
hint: report.hint,
generatedAt: Date.now()
};
}
5 changes: 3 additions & 2 deletions packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron';

import type { AccessKeyInfo, BrainStatus, DeviceInfo, DiscoveredBrainInfo, DoctorReport, EditableConfig, ExportResult, ImportRequest, ImportSummary, LaserSyncState, LightMapView, NewProjectInput, OscTarget, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, StoreClearResult, StoreInfo, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc';
import type { AccessKeyInfo, BrainStatus, DeviceInfo, DiscoveredBrainInfo, DoctorReport, EditableConfig, ExportResult, ImportRequest, ImportSummary, LaserSyncState, LightMapView, NetworkReport, NewProjectInput, OscTarget, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, StoreClearResult, StoreInfo, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc';

// The single, narrow bridge exposed to the renderer. The renderer never imports
// @wavegrid/settings or `fs`; everything goes through these typed calls.
Expand All @@ -18,7 +18,8 @@ const api: WavegridApi = {
}
},
doctor: {
report: (project) => ipcRenderer.invoke('doctor:report', project) as Promise<DoctorReport | null>
report: (project) => ipcRenderer.invoke('doctor:report', project) as Promise<DoctorReport | null>,
network: () => ipcRenderer.invoke('doctor:network') as Promise<NetworkReport | null>
},
projects: {
list: () => ipcRenderer.invoke('projects:list') as Promise<ProjectSummary[]>,
Expand Down
9 changes: 9 additions & 0 deletions packages/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
useDiscovery,
useDoctor,
useLightMap,
useNetwork,
useOscTarget,
usePresets,
useProjectConfig,
Expand Down Expand Up @@ -156,6 +157,11 @@ export function App() {
error: doctorError,
refresh: refreshDoctor
} = useDoctor(activeScope);
const {
report: networkReport,
loading: networkLoading,
refresh: probeNetwork
} = useNetwork();
const { target: oscTarget, refresh: refreshOsc, save: saveOsc } = useOscTarget(editingScope);
const discovery = useDiscovery();
const { exportProject, importProject } = useTransfer(
Expand Down Expand Up @@ -447,6 +453,9 @@ export function App() {
await refreshDoctor();
})}
busy={busy}
network={networkReport}
networkLoading={networkLoading}
onProbeNetwork={() => void probeNetwork()}
/>
)}
{route === 'projects' && (
Expand Down
48 changes: 48 additions & 0 deletions packages/desktop/src/renderer/lib/network-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Presentation for the network verdict. Kept pure so the wording — the part an
* operator acts on at 1am — is testable without a running brain.
*/
import type { NetworkReport, NetworkVerdict } from '@/types/ipc';

export type VerdictTone = 'good' | 'bad' | 'unknown';

const TONE: Record<NetworkVerdict, VerdictTone> = {
'proven-reachable': 'good',
'loopback-only': 'bad',
'blocked-locally': 'bad',
'no-network': 'bad',
'isolation-likely': 'unknown',
unproven: 'unknown'
};

const LABEL: Record<NetworkVerdict, string> = {
'proven-reachable': 'Reachable',
'loopback-only': 'This laptop only',
'blocked-locally': 'Blocked on this machine',
'no-network': 'No network',
'isolation-likely': 'Probably isolated',
unproven: 'Not proven yet'
};

export function verdictTone(v: NetworkVerdict): VerdictTone {
return TONE[v];
}

export function verdictLabel(v: NetworkVerdict): string {
return LABEL[v];
}

/** One line describing what the machine is listening on. */
export function bindDescription(report: NetworkReport): string {
if (report.bindHost === '0.0.0.0' || report.bindHost === '::') {
return `Listening on every interface, port ${report.port}`;
}
return `Listening on ${report.bindHost} only, port ${report.port}`;
}

/** How the neighbour count should read, including "we couldn't tell". */
export function neighbourSummary(report: NetworkReport): string {
if (!report.neighboursKnown) return 'Other devices on this network: unknown';
if (report.neighbourCount === 0) return 'Other devices on this network: none visible';
return `Other devices on this network: ${report.neighbourCount}`;
}
32 changes: 32 additions & 0 deletions packages/desktop/src/renderer/lib/share-urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Ordering for the LAN URLs a phone might open. Purely a presentation concern:
* these URLs are derived from the running brain's interfaces every time status
* is read, and are never stored.
*
* A laptop at a venue usually holds several IPv4 addresses — wifi, a wired
* dock, a VPN, a virtualization bridge — and only one of them is the network
* the audience's phones are on. Home/office wifi is overwhelmingly 192.168/16,
* so that goes first; link-local means DHCP never completed and is worth
* showing last rather than hiding, since it explains a failure.
*/
export function rankLanUrls(urls: string[]): string[] {
return [...urls].sort((a, b) => rank(a) - rank(b));
}

function hostOf(url: string): string {
try {
return new URL(url).hostname;
} catch {
return '';
}
}

function rank(url: string): number {
const host = hostOf(url);
if (host.startsWith('192.168.')) return 0;
if (/^10\./.test(host)) return 1;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return 2;
// 169.254/16: self-assigned, no DHCP — reachable by nothing useful.
if (host.startsWith('169.254.')) return 4;
return 3;
}
Loading
Loading