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()
}
})