Files
web-terminal/desktop/src/tray.ts
Yaojia Wang bb0949553c feat(desktop): remote-host mode + OS-store client-cert mTLS (D-1/D-2)
- remote-hosts.ts + DesktopPrefs (remoteHosts/selectedHostId, immutable, back-compat) + tray host-picker.
- will-navigate lock is now dynamic to the selected remote origin, fails closed on null (no open-redirect).
- select-client-certificate handler: event.preventDefault() first, pick by issuer CN, non-silent
  missing-cert dialog + cert-less callback -> clean nginx rejection (Chromium sources certs from OS store).
Verified: npm run typecheck exit 0; pure-helper assertions pass.
2026-07-07 09:42:12 +02:00

95 lines
3.3 KiB
TypeScript

/**
* 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
}