- 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.
95 lines
3.3 KiB
TypeScript
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
|
|
}
|