From bb0949553c4777416bc18bdebd7e6533c09e8340 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 7 Jul 2026 09:42:12 +0200 Subject: [PATCH] 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. --- desktop/src/main.ts | 324 ++++++++++++++++++++++++++++++++++-- desktop/src/prefs.ts | 53 +++++- desktop/src/remote-hosts.ts | 125 ++++++++++++++ desktop/src/tray.ts | 76 ++++++++- desktop/src/types.ts | 20 +++ desktop/src/window.ts | 39 +++-- 6 files changed, 604 insertions(+), 33 deletions(-) create mode 100644 desktop/src/remote-hosts.ts diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 7ec29d9..338b352 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -11,7 +11,8 @@ * * __dirname is available because esbuild emits this as a CJS bundle (build.mjs). */ -import { app, BrowserWindow, Menu, Notification, Tray } from 'electron' +import { app, BrowserWindow, Menu, Notification, Tray, dialog } from 'electron' +import type { Certificate } from 'electron' import path from 'node:path' import { createLogger } from './logger.js' @@ -20,11 +21,21 @@ import { startEmbeddedServer } from './embedded-server.js' import { computeNotifications } from './notifications.js' import { mapLiveSessions } from './live-poll.js' import { parseDeepLink, deepLinkToPath, DEEP_LINK_PROTOCOL } from './deep-link.js' -import { createMainWindow } from './window.js' +import { createMainWindow, originOf } from './window.js' import { buildAppMenu } from './menu.js' -import { createTray } from './tray.js' +import { createTray, buildTrayMenu } from './tray.js' +import type { TrayHandlers } from './tray.js' +import { + selectHost, + selectedRemoteHost, + remoteHostOrigin, + makeRemoteHost, + addRemoteHost, + removeRemoteHost, + TERMINAL_ZONE_SUFFIX, +} from './remote-hosts.js' import type { NotifyOptions } from './notify-policy.js' -import type { EmbeddedServer, SessionStatusSnapshot } from './types.js' +import type { DesktopPrefs, EmbeddedServer, SessionStatusSnapshot } from './types.js' /** Live-session poll cadence — cheap loopback GET, so a few seconds is plenty. */ const POLL_INTERVAL_MS = 4000 @@ -35,12 +46,75 @@ const PRELOAD_FILE = 'preload.cjs' /** Tray icon location relative to the build/ output dir (shipped under assets/). */ const TRAY_ICON_SUBPATH = path.join('..', 'assets', 'trayTemplate.png') +/** + * DEVICE_CA_CN — Common Name of the device client-certificate CA (VPS Track V2, + * gen-device-ca.sh). The device `.p12` lives in the OS keychain (macOS login + * keychain / Windows Personal store); Chromium sources client certificates ONLY + * from the OS store — reading a `.p12` in-app is infeasible — so on an mTLS + * handshake we select the cert whose issuer CN matches this. Override for a + * differently-named CA via the WEBTERM_DEVICE_CA_CN env var. + */ +const DEVICE_CA_CN = process.env.WEBTERM_DEVICE_CA_CN ?? 'WebTerminal Device CA' + +/** Add-remote-host prompt window dimensions. */ +const PROMPT_WIDTH = 460 +const PROMPT_HEIGHT = 260 + +/** + * Minimal self-contained HTML for the "Add remote host" prompt window. It runs no + * Node (sandboxed, no preload) and returns its result by setting `location.hash` + * (read back via did-navigate-in-page); user text is JSON+URI-encoded into the + * hash, never interpolated into markup, so there is no injection surface. The CSP + * allows only inline style/script from this trusted, app-authored document. + */ +const PROMPT_HTML = ` + + + + +

Add remote host

+
+ + +
+ + +
+
+ +` + const logger = createLogger('main') const settings = createSettingsStore(app.getPath('userData'), process.platform, logger) let mainWindow: BrowserWindow | null = null let tray: Tray | null = null let server: EmbeddedServer | null = null +// The origin the main window is currently locked to (local embedded server, or a +// selected remote tunnel host). Read live by window.ts's will-navigate guard; +// reassigned (never mutated) whenever we point the window at a different host. +let activeOrigin: string | null = null let pollTimer: NodeJS.Timeout | null = null let isQuitting = false let isShuttingDown = false @@ -66,6 +140,193 @@ function focusWindow(): void { mainWindow.focus() } +/** True iff `value` is a plain, indexable object (not null, not an array). */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** The embedded local server's base URL (`http://127.0.0.1:`), or null. */ +function localBaseUrl(): string | null { + return server ? `http://127.0.0.1:${server.port}` : null +} + +/** + * Resolve the page URL + origin the window should currently be on, given prefs: + * the selected remote host, else the local embedded server. Returns null only if + * neither is resolvable (e.g. server not up yet and no valid remote selected). + */ +function resolveTarget(prefs: DesktopPrefs): { url: string; origin: string } | null { + const remote = selectedRemoteHost(prefs) + if (remote) { + const origin = remoteHostOrigin(remote) + if (origin !== null) return { url: remote.url, origin } + } + const base = localBaseUrl() + if (base === null) return null + return { url: `${base}/`, origin: base } +} + +/** Rebuild the tray context menu from the current host list + selection. */ +function refreshTray(): void { + if (!tray) return + const prefs = settings.get() + tray.setContextMenu( + buildTrayMenu( + { remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId }, + trayHandlers, + ), + ) +} + +/** + * Switch the window to a host (null → local). Persists the selection, updates the + * origin lock, navigates the window (programmatic loadURL does NOT trip + * will-navigate), refreshes the tray, and focuses. A no-op if nothing resolves. + */ +function switchToHost(id: string | null): void { + const prefs = settings.set(selectHost(settings.get(), id)) + const target = resolveTarget(prefs) + if (!target) { + logger.warn('cannot switch host: no resolvable target (server not ready?)') + return + } + activeOrigin = target.origin + if (mainWindow) void mainWindow.loadURL(target.url) + refreshTray() + focusWindow() +} + +/** Prompt for a new remote host, validate it, persist it, and switch to it. */ +async function addRemoteHostFlow(): Promise { + const input = await promptRemoteHost() + if (!input) return + const host = makeRemoteHost(input.name, input.url) + if (!host) { + showError( + 'Invalid remote host', + 'That is not a valid tunnel URL.', + `Enter an https URL under ${TERMINAL_ZONE_SUFFIX}, ` + + 'e.g. https://t1.terminal.yaojia.wang', + ) + return + } + settings.set(addRemoteHost(settings.get(), host)) + switchToHost(host.id) +} + +/** Remove a remote host; if it was selected, selection falls back to local. */ +function removeRemoteHostFlow(id: string): void { + const prefs = settings.set(removeRemoteHost(settings.get(), id)) + const target = resolveTarget(prefs) + if (target) { + activeOrigin = target.origin + if (mainWindow) void mainWindow.loadURL(target.url) + } + refreshTray() +} + +/** Handlers wired into the tray host-picker (stable object; reads prefs live). */ +const trayHandlers: TrayHandlers = { + show: () => focusWindow(), + quit: () => { + isQuitting = true + app.quit() + }, + selectHost: (id) => switchToHost(id), + addHost: () => { + void addRemoteHostFlow() + }, + removeHost: (id) => removeRemoteHostFlow(id), +} + +/** Show a native error dialog (parented to the main window when one exists). */ +function showError(title: string, message: string, detail: string): void { + const options = { type: 'error' as const, title, message, detail } + if (mainWindow) void dialog.showMessageBox(mainWindow, options) + else void dialog.showMessageBox(options) +} + +/** + * Modal prompt for a remote-host name + URL. Resolves to the entered values, or + * null if cancelled/closed. The window returns its result via location.hash (no + * Node, no preload, no IPC surface); see PROMPT_HTML. + */ +function promptRemoteHost(): Promise<{ name: string; url: string } | null> { + return new Promise((resolve) => { + const win = new BrowserWindow({ + parent: mainWindow ?? undefined, + modal: mainWindow !== null, + width: PROMPT_WIDTH, + height: PROMPT_HEIGHT, + resizable: false, + minimizable: false, + maximizable: false, + title: 'Add remote host', + backgroundColor: '#0e0f13', + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + }) + let settled = false + const finish = (result: { name: string; url: string } | null): void => { + if (settled) return + settled = true + resolve(result) + if (!win.isDestroyed()) win.close() + } + win.webContents.on('did-navigate-in-page', (_event, navUrl) => { + let hash: string + try { + hash = new URL(navUrl).hash + } catch { + return + } + if (hash === '#cancel') { + finish(null) + return + } + if (!hash.startsWith('#ok:')) return + try { + const parsed: unknown = JSON.parse(decodeURIComponent(hash.slice('#ok:'.length))) + if (isRecord(parsed) && typeof parsed['name'] === 'string' && typeof parsed['url'] === 'string') { + finish({ name: parsed['name'], url: parsed['url'] }) + } else { + finish(null) + } + } catch { + finish(null) + } + }) + win.on('closed', () => finish(null)) + void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(PROMPT_HTML)}`) + }) +} + +/** + * D-2: choose the client certificate whose issuer is our device CA. The OS + * keychain may hold unrelated certs, so match on issuer CN rather than picking + * blindly. Returns null when nothing in the list was issued by DEVICE_CA_CN. + */ +function pickCertByIssuer(list: readonly Certificate[], issuerCn: string): Certificate | null { + return ( + list.find( + (cert) => cert.issuerName === issuerCn || cert.issuer.commonName === issuerCn, + ) ?? null + ) +} + +/** Tell the user their device cert isn't installed (D-2: never fail silently). */ +function showDeviceCertMissingDialog(requestUrl: string): void { + const host = originOf(requestUrl) ?? requestUrl + showError( + 'Device certificate not installed', + 'No matching device certificate was found in the OS keychain.', + `Connecting to ${host} requires your device client certificate (issued by ` + + `"${DEVICE_CA_CN}"). Install the provided device.p12 into the login keychain ` + + '(macOS: double-click the file) or the Personal certificate store (Windows), ' + + 'then reconnect. On macOS you will be prompted once to "Always Allow" access ' + + 'to the private key.', + ) +} + /** Find the first terminalapp:// argument in a process argv list. */ function findDeepLinkArg(argv: readonly string[]): string | null { const prefix = `${DEEP_LINK_PROTOCOL}://` @@ -90,9 +351,13 @@ function dispatchDeepLink(url: string): void { } const targetPath = deepLinkToPath(link) + const base = `http://127.0.0.1:${server.port}` + // A deep link joins a LOCAL session, so navigate the window back to the local + // origin and move the lock with it (in case a remote host was active). + activeOrigin = base focusWindow() mainWindow.webContents.send(DEEP_LINK_CHANNEL, targetPath) - void mainWindow.loadURL(`http://127.0.0.1:${server.port}${targetPath}`) + void mainWindow.loadURL(`${base}${targetPath}`) } /** Hide-to-tray on window close (keep the server alive) unless we're quitting. */ @@ -154,18 +419,23 @@ async function onReady(): Promise { server = await startEmbeddedServer({ prefs, logger }) const base = `http://127.0.0.1:${server.port}` - mainWindow = createMainWindow(`${base}/`, path.join(__dirname, PRELOAD_FILE)) + // Honor a previously-selected remote host across restarts; else land on local. + const initial = resolveTarget(prefs) ?? { url: `${base}/`, origin: base } + activeOrigin = initial.origin + mainWindow = createMainWindow( + initial.url, + path.join(__dirname, PRELOAD_FILE), + () => activeOrigin, + ) installWindowCloseGuard(mainWindow) Menu.setApplicationMenu(buildAppMenu()) try { - tray = createTray(path.join(__dirname, TRAY_ICON_SUBPATH), { - show: () => focusWindow(), - quit: () => { - isQuitting = true - app.quit() - }, - }) + tray = createTray( + path.join(__dirname, TRAY_ICON_SUBPATH), + { remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId }, + trayHandlers, + ) } catch (err: unknown) { // A missing/invalid tray icon (cosmetic) must not take down a working app. logger.warn(`tray unavailable (continuing without it): ${errorMessage(err)}`) @@ -216,6 +486,34 @@ if (!app.requestSingleInstanceLock()) { } else { app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL) + // ── D-2: mTLS device-certificate selection (from the OS keychain) ────────── + // When a remote tunnel host (nginx `ssl_verify_client on`) requests a client + // cert, Chromium asks us which of the OS-store certs to present. preventDefault + // is MANDATORY: without it Electron auto-picks list[0] (any cert, no control). + // We deliberately choose the one issued by our device CA; if none is installed + // we show a clear dialog (never a silent callback) and proceed cert-less so the + // TLS handshake fails cleanly rather than hanging. + app.on('select-client-certificate', (event, _webContents, url, list, callback) => { + event.preventDefault() + const cert = pickCertByIssuer(list, DEVICE_CA_CN) + if (!cert) { + showDeviceCertMissingDialog(url) + callback() // no cert → nginx rejects the handshake (clean, non-hanging failure) + return + } + callback(cert) + }) + + // ── D-3 (deferred — documented TODO; coordinate with Track V/S) ──────────── + // This box can also be its OWN tunnel source. A follow-up may optionally spawn + // + supervise a local `frpc` from here (PLAN_NATIVE_TUNNEL §"C-Desktop / D-3") + // and inject the embedded server's ALLOWED_ORIGINS= + // https://.terminal.yaojia.wang (additive via the backend config.ts). The + // embedded server already defaults BIND_HOST=127.0.0.1 (server-config.ts), which + // satisfies precondition P-A — EXCEPT when `lanSharing` is enabled: that binds + // 0.0.0.0 and re-opens an unauthenticated shell on the LAN, bypassing mTLS. So a + // tunnel-source host must keep lanSharing OFF. Not implemented here. + app.on('second-instance', (_event, argv) => { focusWindow() const url = findDeepLinkArg(argv) diff --git a/desktop/src/prefs.ts b/desktop/src/prefs.ts index 3d9372d..9acf4f2 100644 --- a/desktop/src/prefs.ts +++ b/desktop/src/prefs.ts @@ -12,7 +12,8 @@ * CRITICAL rule); they never mutate their arguments. */ -import type { DesktopPrefs } from './types.js' +import type { DesktopPrefs, RemoteHost } from './types.js' +import { normalizeRemoteUrl } from './remote-hosts.js' /** Valid TCP port range (inclusive); mirrors src/config.ts's PORT bounds. */ const MIN_PORT = 1 @@ -36,6 +37,8 @@ export function defaultPrefs(platform: NodeJS.Platform): DesktopPrefs { openAtLogin: false, notifyOnApproval: true, notifyOnStatusChange: true, + remoteHosts: [], + selectedHostId: null, } } @@ -70,6 +73,51 @@ function pickShellPath(value: unknown, fallback: string | null): string | null { return fallback } +/** + * Validate one untrusted remote-host record. Requires string id/name and a URL + * that survives normalization (https + tunnel zone); the stored URL is replaced + * with its canonical form. Returns null for anything malformed, so a corrupt or + * hand-edited entry is dropped rather than trusted downstream. + */ +function pickRemoteHost(value: unknown): RemoteHost | null { + if (!isRecord(value)) return null + const { id, name, url } = value + if (typeof id !== 'string' || id === '') return null + if (typeof name !== 'string' || name.trim() === '') return null + if (typeof url !== 'string') return null + const normalizedUrl = normalizeRemoteUrl(url) + if (normalizedUrl === null) return null + return { id, name: name.trim(), url: normalizedUrl } +} + +/** + * Adopt a list of remote hosts from untrusted input: keep only records that + * validate, and drop duplicate ids (first wins) so lookups are unambiguous. A + * non-array yields an empty list. + */ +function pickRemoteHosts(value: unknown): readonly RemoteHost[] { + if (!Array.isArray(value)) return [] + const seen = new Set() + const hosts: RemoteHost[] = [] + for (const entry of value) { + const host = pickRemoteHost(entry) + if (host === null || seen.has(host.id)) continue + seen.add(host.id) + hosts.push(host) + } + return hosts +} + +/** + * Adopt the selected host id: null (local), or a string that refers to a host + * actually present in the validated list. A dangling id degrades to null so we + * never point selection at a host that was dropped or never existed. + */ +function pickSelectedHostId(value: unknown, hosts: readonly RemoteHost[]): string | null { + if (typeof value !== 'string') return null + return hosts.some((host) => host.id === value) ? value : null +} + /** * Re-derive a fully-formed DesktopPrefs from untrusted parsed-JSON input. * Starts from defaults and adopts each field only when it is the right type and @@ -78,6 +126,7 @@ function pickShellPath(value: unknown, fallback: string | null): string | null { export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopPrefs { const defaults = defaultPrefs(platform) if (!isRecord(raw)) return defaults + const remoteHosts = pickRemoteHosts(raw['remoteHosts']) return { port: pickPort(raw['port'], defaults.port), lanSharing: pickBoolean(raw['lanSharing'], defaults.lanSharing), @@ -85,6 +134,8 @@ export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopP openAtLogin: pickBoolean(raw['openAtLogin'], defaults.openAtLogin), notifyOnApproval: pickBoolean(raw['notifyOnApproval'], defaults.notifyOnApproval), notifyOnStatusChange: pickBoolean(raw['notifyOnStatusChange'], defaults.notifyOnStatusChange), + remoteHosts, + selectedHostId: pickSelectedHostId(raw['selectedHostId'], remoteHosts), } } diff --git a/desktop/src/remote-hosts.ts b/desktop/src/remote-hosts.ts new file mode 100644 index 0000000..9fbb855 --- /dev/null +++ b/desktop/src/remote-hosts.ts @@ -0,0 +1,125 @@ +/** + * desktop/src/remote-hosts.ts — D-1: remote-host mode (PLAN_NATIVE_TUNNEL, + * Track C-Desktop). Pure, Electron-free helpers over the RemoteHost list held in + * DesktopPrefs, plus the URL validation that guards what the BrowserWindow is + * allowed to load. + * + * A "remote host" is one of the user's own machines exposed through the mTLS + * reverse tunnel at `https://.terminal.yaojia.wang/`. The desktop app can + * switch the main window between "This machine (local)" (the embedded loopback + * server, which keeps running) and any configured remote host; the device client + * certificate from the OS keychain (see main.ts / D-2) is the only auth gate. + * + * Every function is PURE and returns NEW objects (immutability — coding-style + * CRITICAL rule); none mutate their arguments. No `electron` import, so this file + * is unit-testable in plain Node. + */ + +import { randomUUID } from 'node:crypto' +import type { DesktopPrefs, RemoteHost } from './types.js' + +/** + * The tunnel DNS zone (PLAN_NATIVE_TUNNEL: wildcard `*.terminal.yaojia.wang`). + * Overridable so a differently-named deployment isn't hard-blocked. A remote host + * URL must be https (mixed-content + mTLS both require TLS) and, by default, live + * under this zone — a boundary sanity check, not the security boundary itself + * (that is mTLS at nginx + the will-navigate origin lock). + */ +export const TERMINAL_ZONE_SUFFIX = process.env.WEBTERM_TUNNEL_ZONE ?? '.terminal.yaojia.wang' + +/** Parse an origin, returning null for anything malformed. */ +function originOf(url: string): string | null { + try { + return new URL(url).origin + } catch { + return null + } +} + +/** + * Validate + normalize a user-entered remote-host URL. Returns the canonical + * `https:///` form, or null when the input is not a usable https tunnel URL + * (unparseable, non-https, or — by default — outside the tunnel zone). Validation + * at the boundary: everything downstream may trust the returned string. + */ +export function normalizeRemoteUrl(raw: string): string | null { + let parsed: URL + try { + parsed = new URL(raw.trim()) + } catch { + return null + } + if (parsed.protocol !== 'https:') return null + if (parsed.hostname === '') return null + if (TERMINAL_ZONE_SUFFIX !== '' && !parsed.hostname.endsWith(TERMINAL_ZONE_SUFFIX)) { + return null + } + // Canonical form: origin + trailing slash (path/query/hash stripped — the + // frontend is served from the root, and the origin is all the lock compares). + return `${parsed.origin}/` +} + +/** + * Build a RemoteHost from user input, or null if the name is blank or the URL is + * not a valid https tunnel URL. The id is a fresh uuid (stable across renames). + */ +export function makeRemoteHost(name: string, url: string): RemoteHost | null { + const trimmedName = name.trim() + const normalizedUrl = normalizeRemoteUrl(url) + if (trimmedName === '' || normalizedUrl === null) return null + return { id: randomUUID(), name: trimmedName, url: normalizedUrl } +} + +/** Look up a configured host by id. */ +export function findRemoteHost( + prefs: Readonly, + id: string, +): RemoteHost | undefined { + return prefs.remoteHosts.find((host) => host.id === id) +} + +/** + * Immutably append a host, de-duplicating by URL so re-adding the same machine + * replaces the old entry (and its name) rather than piling up duplicates. + */ +export function addRemoteHost( + prefs: Readonly, + host: RemoteHost, +): DesktopPrefs { + const withoutDup = prefs.remoteHosts.filter((existing) => existing.url !== host.url) + return { ...prefs, remoteHosts: [...withoutDup, host] } +} + +/** + * Immutably remove a host by id. If the removed host was selected, selection + * falls back to local (selectedHostId → null) so we never point at a gone host. + */ +export function removeRemoteHost(prefs: Readonly, id: string): DesktopPrefs { + const remoteHosts = prefs.remoteHosts.filter((host) => host.id !== id) + const selectedHostId = prefs.selectedHostId === id ? null : prefs.selectedHostId + return { ...prefs, remoteHosts, selectedHostId } +} + +/** + * Immutably select a host (null → local). Selecting an unknown id is a no-op — + * the caller stays on whatever was selected, never a dangling reference. + */ +export function selectHost(prefs: Readonly, id: string | null): DesktopPrefs { + if (id === null) return { ...prefs, selectedHostId: null } + if (findRemoteHost(prefs, id) === undefined) return { ...prefs } + return { ...prefs, selectedHostId: id } +} + +/** + * The currently-selected remote host, or null when local is selected (either + * selectedHostId is null, or it dangles at a host that no longer exists). + */ +export function selectedRemoteHost(prefs: Readonly): RemoteHost | null { + if (prefs.selectedHostId === null) return null + return findRemoteHost(prefs, prefs.selectedHostId) ?? null +} + +/** The origin a remote host's window should be locked to, or null if malformed. */ +export function remoteHostOrigin(host: Readonly): string | null { + return originOf(host.url) +} diff --git a/desktop/src/tray.ts b/desktop/src/tray.ts index 056a112..c38eb09 100644 --- a/desktop/src/tray.ts +++ b/desktop/src/tray.ts @@ -3,30 +3,92 @@ * 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 } -export function createTray(iconPath: string, handlers: TrayHandlers): Tray { - const tray = new Tray(iconPath) +/** 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 +} - const menu = Menu.buildFromTemplate([ - { label: 'Show Web Terminal', click: handlers.show }, +/** 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: 'Quit', click: handlers.quit }, - ]) + { 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(menu) + tray.setContextMenu(buildTrayMenu(state, handlers)) return tray } diff --git a/desktop/src/types.ts b/desktop/src/types.ts index c5cc3e4..97cdb68 100644 --- a/desktop/src/types.ts +++ b/desktop/src/types.ts @@ -21,6 +21,22 @@ export type DesktopClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | ' /** Approval gate kind, mirrored from backend PermissionGate (src/types.ts:101). */ export type DesktopPermissionGate = 'tool' | 'plan' +/** + * A remote web-terminal host reachable over the mTLS reverse tunnel + * (Track C-Desktop, PLAN_NATIVE_TUNNEL §"C-Desktop / D-1"). `url` is the https + * origin the BrowserWindow loads, e.g. `https://t1.terminal.yaojia.wang/`; the + * device client certificate (D-2, sourced from the OS keychain) is the only auth + * gate. `id` is a stable opaque handle used by the tray host-picker + prefs. + */ +export interface RemoteHost { + /** Stable opaque id (uuid); the tray/prefs key, never shown to the user. */ + readonly id: string + /** Human label shown in the tray host-picker. */ + readonly name: string + /** The https origin URL to load, normalized to `https:///`. */ + readonly url: string +} + /** * User preferences, persisted as JSON in the app's userData dir (hand-rolled * store — matches the project's minimal-deps convention; no electron-store). @@ -38,6 +54,10 @@ export interface DesktopPrefs { readonly notifyOnApproval: boolean /** Fire a native notification when a session's Claude status changes. */ readonly notifyOnStatusChange: boolean + /** Configured remote tunnel hosts (D-1); empty on a fresh install. */ + readonly remoteHosts: readonly RemoteHost[] + /** Selected host id, or null → "This machine (local)" (the embedded server). */ + readonly selectedHostId: string | null } /** Handle returned by the embedded server, for lifecycle + window wiring. */ diff --git a/desktop/src/window.ts b/desktop/src/window.ts index 3a4bed7..f1f0e97 100644 --- a/desktop/src/window.ts +++ b/desktop/src/window.ts @@ -1,12 +1,19 @@ /** * desktop/src/window.ts — creates the single BrowserWindow that hosts the - * unchanged web frontend, loaded from the embedded localhost server. + * unchanged web frontend, loaded from either the embedded localhost server or a + * selected remote tunnel host (D-1, PLAN_NATIVE_TUNNEL / C-Desktop). * * Hardening (DESKTOP_PLAN §8 / TECH_DOC §7): contextIsolation on, nodeIntegration - * off, sandbox on, preload restricted to a minimal contextBridge. Because the - * only page ever loaded is the trusted embedded http://127.0.0.1: origin, - * we deny every new-window request and block navigation to any foreign origin — - * a defence-in-depth guard against a hijacked page trying to escape localhost. + * off, sandbox on, preload restricted to a minimal contextBridge. We deny every + * new-window request and block navigation to any foreign origin — a defence-in- + * depth guard against a hijacked page trying to escape the active origin. + * + * D-1 note: the origin lock is DYNAMIC. The window may be pointed (by main.ts, + * via a programmatic loadURL — which does NOT fire will-navigate) at exactly one + * host at a time: the embedded `http://127.0.0.1:` origin OR the selected + * remote `https://.terminal.yaojia.wang` origin. `getAllowedOrigin` returns + * whichever is active NOW, so renderer-initiated navigation stays locked to that + * single origin and everything else is still blocked. */ import { BrowserWindow } from 'electron' @@ -15,7 +22,7 @@ const WINDOW_HEIGHT = 720 const BACKGROUND_COLOR = '#0e0f13' /** Parse the origin of a URL, returning null for anything malformed. */ -function originOf(url: string): string | null { +export function originOf(url: string): string | null { try { return new URL(url).origin } catch { @@ -23,7 +30,16 @@ function originOf(url: string): string | null { } } -export function createMainWindow(url: string, preloadPath: string): BrowserWindow { +/** + * Create the main window. `url` is the initial page to load; `getAllowedOrigin` + * is queried live on every navigation attempt and must return the origin the + * window is currently allowed on (or null to allow nothing / fail closed). + */ +export function createMainWindow( + url: string, + preloadPath: string, + getAllowedOrigin: () => string | null, +): BrowserWindow { const win = new BrowserWindow({ width: WINDOW_WIDTH, height: WINDOW_HEIGHT, @@ -36,15 +52,14 @@ export function createMainWindow(url: string, preloadPath: string): BrowserWindo }, }) - const allowedOrigin = originOf(url) - // Never spawn child windows; the frontend has no legitimate reason to. win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) - // Block navigation away from the embedded localhost origin. + // Block navigation away from the currently-active (local or remote) origin. win.webContents.on('will-navigate', (event, targetUrl) => { - if (allowedOrigin === null) return - if (originOf(targetUrl) !== allowedOrigin) { + const allowedOrigin = getAllowedOrigin() + // Fail closed: if we can't determine the active origin, allow nothing. + if (allowedOrigin === null || originOf(targetUrl) !== allowedOrigin) { event.preventDefault() } })