/** * desktop/src/tray.ts — the menubar/system-tray icon that keeps the app alive * after its window is hidden (the embedded server must keep running so remote * devices stay connected — the vibe-coding scenario). * * The context menu also hosts the D-1 host-picker (PLAN_NATIVE_TUNNEL / * C-Desktop): "This machine (local)" ↔ each configured remote tunnel host, as a * radio group, plus add/remove. The menu is rebuilt (never mutated) whenever the * host list or selection changes — call buildTrayMenu() and setContextMenu(). * * Icon requirement: `iconPath` MUST point at an existing image. new Tray() with a * missing/invalid path can throw on some platforms; that failure surfaces to the * caller rather than being swallowed (a broken install should be visible, not * silently degrade). The caller owns whether to guard startup around it. */ import { Menu, Tray } from 'electron' import type { MenuItemConstructorOptions } from 'electron' import type { RemoteHost } from './types.js' const TRAY_TOOLTIP = 'Web Terminal' /** Label for the local (embedded server) entry — selectedHostId === null. */ const LOCAL_HOST_LABEL = 'This machine (local)' export interface TrayHandlers { show(): void quit(): void /** Switch the active host; null → "This machine (local)". */ selectHost(id: string | null): void /** Prompt for + add a new remote host. */ addHost(): void /** Remove a configured remote host by id. */ removeHost(id: string): void } /** The host-picker state the menu renders (the local entry is implicit). */ export interface TrayHostState { readonly remoteHosts: readonly RemoteHost[] /** Selected host id, or null → local. */ readonly selectedHostId: string | null } /** Build the tray context menu for the given host state (pure construction). */ export function buildTrayMenu(state: TrayHostState, handlers: TrayHandlers): Menu { const localItem: MenuItemConstructorOptions = { label: LOCAL_HOST_LABEL, type: 'radio', checked: state.selectedHostId === null, click: () => handlers.selectHost(null), } const hostItems: MenuItemConstructorOptions[] = state.remoteHosts.map((host) => ({ label: host.name, type: 'radio', checked: state.selectedHostId === host.id, click: () => handlers.selectHost(host.id), })) const selectedRemoteId = state.selectedHostId !== null && state.remoteHosts.some((host) => host.id === state.selectedHostId) ? state.selectedHostId : null const template: MenuItemConstructorOptions[] = [ { label: 'Host', enabled: false }, localItem, ...hostItems, { type: 'separator' }, { label: 'Add remote host…', click: () => handlers.addHost() }, { label: 'Remove selected host', enabled: selectedRemoteId !== null, click: () => { if (selectedRemoteId !== null) handlers.removeHost(selectedRemoteId) }, }, { type: 'separator' }, { label: 'Show Web Terminal', click: () => handlers.show() }, { label: 'Quit', click: () => handlers.quit() }, ] return Menu.buildFromTemplate(template) } export function createTray( iconPath: string, state: TrayHostState, handlers: TrayHandlers, ): Tray { const tray = new Tray(iconPath) tray.setToolTip(TRAY_TOOLTIP) tray.setContextMenu(buildTrayMenu(state, handlers)) return tray }