feat(desktop): Electron all-in-one desktop shell (Mac/Windows) embedding the server

Add a `desktop/` Electron app that embeds the existing Node server + node-pty
(all-in-one): the window loads http://127.0.0.1:<port>/ and reuses the frontend
unchanged; other LAN devices can still connect. The server needs zero changes —
startServer(cfg)/loadConfig already support programmatic embedding.

- Pure, unit-tested modules: port, shell, server-config, deep-link, notify-policy,
  notifications, live-poll, prefs, settings-store (94 tests; desktop/src ~97% cov)
- Electron glue: main/window/tray/menu/preload/embedded-server/logger
  (hardened: contextIsolation, sandbox, deny foreign-origin navigation)
- Native value: OS notifications driven by /live-sessions status, tray, deep links
- Packaging (electron-builder -> arm64 .dmg): ships dist/ + public/ + node_modules
  on-disk under Resources so the server resolves its deps from /Applications;
  node-pty rebuilt for the Electron ABI
- Docs: docs/DESKTOP_PLAN.md; PROGRESS_LOG updated

Verified: desktop tsc clean; 1401 tests pass; coverage >=80% (desktop/src 97/95/100/97);
.dmg built and launch-tested on arm64 (server boots, UI serves 200, node-pty loads).
This commit is contained in:
Yaojia Wang
2026-07-02 06:13:17 +02:00
parent 2af57e6686
commit cf8cfccab4
39 changed files with 7781 additions and 0 deletions

2
.gitignore vendored
View File

@@ -4,6 +4,8 @@ node_modules/
# build output
dist/
public/build/
desktop/build/
desktop/dist-app/
# local Claude Code settings (not shared)
.claude/settings.local.json

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

24
desktop/build.mjs Normal file
View File

@@ -0,0 +1,24 @@
/**
* desktop/build.mjs — bundle the Electron main + preload with esbuild.
*
* Source is ESM TypeScript (desktop/src/*.ts); output is CJS (.cjs) so Electron
* loads it as the main/preload entry regardless of package "type". `electron`
* and `node-pty` are marked external (provided by the Electron runtime / loaded
* as a native addon at runtime, never bundled). The compiled server under
* ../dist is loaded via a runtime dynamic import (a computed specifier), so
* esbuild leaves it external automatically.
*/
import { build } from 'esbuild'
const shared = {
bundle: true,
platform: 'node',
target: 'node20',
format: 'cjs',
sourcemap: true,
logLevel: 'info',
external: ['electron', 'node-pty'],
}
await build({ ...shared, entryPoints: ['src/main.ts'], outfile: 'build/main.cjs' })
await build({ ...shared, entryPoints: ['src/preload.ts'], outfile: 'build/preload.cjs' })

View File

@@ -0,0 +1,64 @@
# electron-builder config for the web-terminal desktop shell (Mac focus).
#
# PACKAGING MODEL — server + its node_modules on disk (extraResources), NOT in asar:
# The embedded server (src/server.ts) is ESM, resolves the frontend via
# path.join(__dirname,'..','public'), and imports express/ws/web-push/node-pty.
# embedded-server.ts loads it from process.resourcesPath when packaged. So we ship,
# as real files under Contents/Resources (siblings): dist/ + public/ + node_modules/.
# The server then resolves `require('express')` etc. via a plain Node walk-up to
# Resources/node_modules — no asar, no ESM-from-asar, no native-module-in-asar issues.
# (An earlier build shipped only node-pty and "worked" in-place only because it borrowed
# the repo's node_modules up the tree; from /Applications that failed with
# "Cannot find package 'express'". This ships the full runtime dep tree.)
#
# node_modules is filtered to drop build-only tooling (electron/electron-builder/esbuild/
# typescript + their heavy binaries) — never the server's runtime deps or their transitives.
# node-pty is rebuilt for the Electron ABI (npmRebuild) before the copy.
#
# App icon: auto-generated from resources/icon.png. Tray icon: shipped in-asar under assets/.
# NOTE: Windows packaging needs a Windows machine (no wine here).
appId: com.webterminal.desktop
productName: Web Terminal
directories:
output: dist-app
buildResources: resources
files:
- build/**/*
- assets/**/*
- package.json
extraMetadata:
main: build/main.cjs
extraResources:
- from: ../dist
to: dist
- from: ../public
to: public
filter:
- "**/*"
- "!**/*.ts"
- "!**/*.map"
- from: node_modules
to: node_modules
filter:
- "**/*"
# build-only tooling — never needed at runtime by express/ws/web-push/node-pty:
- "!electron/**"
- "!electron-builder/**"
- "!esbuild/**"
- "!@esbuild/**"
- "!typescript/**"
- "!app-builder-bin/**"
- "!dmg-builder/**"
- "!7zip-bin/**"
- "!@electron/**"
- "!@develar/**"
# Rebuild native deps (node-pty) against the Electron ABI before packaging + copy.
npmRebuild: true
mac:
target:
- dmg
- zip
category: public.app-category.developer-tools
win:
target:
- nsis

4936
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
desktop/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "web-terminal-desktop",
"version": "0.1.0",
"private": true,
"type": "module",
"author": "Yaojia Wang",
"description": "Electron desktop shell (Mac + Windows) that embeds the web-terminal server + node-pty. All-in-one: a native terminal that is also the LAN server.",
"main": "build/main.cjs",
"engines": {
"node": ">=18"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"build": "node build.mjs",
"build:server": "cd .. && npm run build && npm run build:web",
"build:all": "npm run build:server && npm run build",
"start": "npm run build:all && electron .",
"dist:mac": "npm run build:all && electron-builder --mac",
"dist:win": "npm run build:all && electron-builder --win"
},
"dependencies": {
"express": "^5.2.1",
"node-pty": "^1.1.0",
"web-push": "3.6.7",
"ws": "^8.21.0"
},
"devDependencies": {
"electron": "^43.0.0",
"electron-builder": "^26.15.3",
"esbuild": "^0.28.1",
"typescript": "^6.0.3"
}
}

View File

@@ -0,0 +1,13 @@
# desktop/resources
App + tray icons for electron-builder packaging.
Required for `npm run dist:mac` / `dist:win` (P1 packaging, not needed for dev/typecheck/tests):
- `icon.icns` — macOS app icon (1024×1024 source, `.icns`).
- `icon.ico` — Windows app icon (256×256 `.ico`).
- `trayTemplate.png` / `trayTemplate@2x.png` — menubar/tray icon (macOS template image, monochrome).
Generate from `../../public/icon.svg` (the existing PWA icon) with any icon toolchain
(e.g. `electron-icon-builder`, or `iconutil` on macOS). These are binary assets and
are intentionally not committed as placeholders — add them before the first packaged build.

BIN
desktop/resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

73
desktop/src/deep-link.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* desktop/src/deep-link.ts — B2: terminalapp:// deep-link parsing.
*
* Maps the OS custom-protocol URL `terminalapp://join/<id>` onto the existing
* frontend's `/?join=<id>` route (see DESKTOP_PLAN §4.3). Pure + hand-validated
* (no URL-parsing surprises): the URL class does the heavy lifting inside a
* try/catch so a malformed link yields null instead of throwing at the boundary.
*/
import type { DeepLink } from './types.js'
/** Custom protocol registered with the OS (electron app.setAsDefaultProtocolClient). */
export const DEEP_LINK_PROTOCOL = 'terminalapp'
/** The single legal host segment: terminalapp://join/<id>. */
const JOIN_HOST = 'join'
/** URL.protocol includes the trailing colon, so compare against that form. */
const PROTOCOL_WITH_COLON = `${DEEP_LINK_PROTOCOL}:`
/**
* Parse `terminalapp://join/<id>` into a DeepLink, or null if it is not a
* well-formed join link. Rejects: wrong scheme, wrong host, missing/empty id,
* ids with '/' or control chars, and anything the URL parser chokes on.
*/
export function parseDeepLink(url: string): DeepLink | null {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return null
}
if (parsed.protocol !== PROTOCOL_WITH_COLON) return null
if (parsed.host !== JOIN_HOST) return null
// pathname is '/<id>' for a single segment; strip the leading slash and require
// exactly one non-empty segment (no nested paths like join/a/b).
const rawPath = parsed.pathname.replace(/^\/+/, '')
if (rawPath === '' || rawPath.includes('/')) return null
const id = safeDecode(rawPath)
if (id === null) return null
if (!isValidId(id)) return null
return { join: id }
}
/** Render a DeepLink as the frontend query-route the window navigates to. */
export function deepLinkToPath(link: DeepLink): string {
return `/?join=${encodeURIComponent(link.join)}`
}
/** decodeURIComponent can throw on malformed percent-encoding — narrow to null. */
function safeDecode(value: string): string | null {
try {
return decodeURIComponent(value)
} catch {
return null
}
}
/** An id is valid if it is non-empty, not whitespace-only, and control-char free. */
function isValidId(id: string): boolean {
if (id.trim() === '') return false
if (id.includes('/')) return false
// Reject C0/C1 control characters (they can't be part of a real session id).
for (const ch of id) {
const code = ch.charCodeAt(0)
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return false
}
return true
}

View File

@@ -0,0 +1,72 @@
/**
* desktop/src/embedded-server.ts — GLUE: start the reused backend server inside
* the Electron main process.
*
* All pure logic lives in the sibling modules (port / server-config / shell);
* this file only wires them to electron + the compiled backend under dist/. It
* resolves a DEFINITE free port BEFORE loadConfig (the backend derives
* allowedOrigins from the port — config.ts M1), then dynamically imports the
* compiled ESM server (dist/server.js) and config (dist/config.js) by absolute
* file URL. The specifiers are computed so esbuild leaves them external (never
* bundling native node-pty). The Config type import from ../../src/types.js is
* type-only and erased at build time — no runtime dependency on src/.
*/
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { app } from 'electron'
import type { Config } from '../../src/types.js'
import type { DesktopPrefs, EmbeddedServer } from './types.js'
import type { Logger } from './logger.js'
import { pickFreePort } from './port.js'
import { buildServerEnv } from './server-config.js'
/** Default listen port when the user hasn't pinned one (free-port fallback still applies). */
const DEFAULT_PORT = 3000
export interface EmbeddedServerDeps {
readonly prefs: DesktopPrefs
readonly logger: Logger
}
/** The runtime handle the compiled backend's startServer() returns. */
interface ServerHandle {
close(): Promise<void>
}
/**
* Start the embedded backend and return a lifecycle handle. Throws (after
* logging) if the compiled server can't be imported or fails to start — the app
* is useless without it, so callers must surface the failure, not swallow it.
*/
export async function startEmbeddedServer(deps: EmbeddedServerDeps): Promise<EmbeddedServer> {
const { prefs, logger } = deps
const port = await pickFreePort(prefs.port ?? DEFAULT_PORT)
const env = buildServerEnv({ prefs, port, platform: process.platform, env: process.env })
// dist/ is a sibling of the desktop dir in dev; under resourcesPath when packaged.
const base = app.isPackaged ? process.resourcesPath : path.join(app.getAppPath(), '..')
const configUrl = pathToFileURL(path.join(base, 'dist', 'config.js')).href
const serverUrl = pathToFileURL(path.join(base, 'dist', 'server.js')).href
try {
const { loadConfig } = (await import(configUrl)) as {
loadConfig: (e: Record<string, string | undefined>) => Config
}
const { startServer } = (await import(serverUrl)) as {
startServer: (c: Config) => ServerHandle
}
const cfg = loadConfig(env)
const handle = startServer(cfg)
logger.info(`embedded server listening on http://${cfg.bindHost}:${cfg.port}`)
return {
port,
bindHost: cfg.bindHost,
allowedOrigins: cfg.allowedOrigins,
close: () => handle.close(),
}
} catch (err: unknown) {
logger.error('failed to start embedded server', err)
throw err
}
}

75
desktop/src/live-poll.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* desktop/src/live-poll.ts — B2: validate + map GET /live-sessions.
*
* The response body is untrusted external JSON (`unknown`), so every field is
* hand-checked before it becomes a SessionStatusSnapshot — matching the repo's
* no-validation-library convention (src/config.ts hand-parses everything).
* Boundary contract: never throw. Garbage in → [] or filtered-out elements out.
*
* The /live-sessions endpoint carries id/status/cwd but not approval state, so
* pendingApproval is fixed false and gate null here; approval snapshots come
* from the hook status bus, not this poll.
*/
import type { DesktopClaudeStatus, SessionStatusSnapshot } from './types.js'
/** Valid ClaudeStatus literals (mirrors backend ClaudeStatus, src/types.ts). */
const VALID_STATUSES: ReadonlySet<string> = new Set([
'working',
'waiting',
'idle',
'unknown',
'stuck',
])
/**
* Map the untrusted /live-sessions JSON body to snapshots. A non-array body
* yields []; elements missing a string id or a valid status literal are dropped.
*/
export function mapLiveSessions(raw: unknown): SessionStatusSnapshot[] {
if (!Array.isArray(raw)) return []
const snapshots: SessionStatusSnapshot[] = []
for (const element of raw) {
const snapshot = toSnapshot(element)
if (snapshot !== null) snapshots.push(snapshot)
}
return snapshots
}
/** Narrow one untrusted element to a snapshot, or null if it is invalid. */
function toSnapshot(element: unknown): SessionStatusSnapshot | null {
if (!isRecord(element)) return null
const { id, status, cwd, exited } = element
if (typeof id !== 'string' || id === '') return null
if (typeof status !== 'string' || !VALID_STATUSES.has(status)) return null
// Drop already-exited sessions: the backend keeps them in list() until they
// are reclaimed, but a post-exit status flip must not fire a stale "needs
// attention" notification.
if (exited === true) return null
return {
sessionId: id,
claudeStatus: status as DesktopClaudeStatus,
pendingApproval: false,
gate: null,
title: titleFromCwd(cwd),
}
}
/** Last path segment of a cwd string, or undefined when cwd is absent/blank.
* Splits on both separators so a Windows backslash path (an explicitly
* supported target — shell.ts defaults to powershell.exe) yields the folder,
* not the whole path. */
function titleFromCwd(cwd: unknown): string | undefined {
if (typeof cwd !== 'string' || cwd === '') return undefined
const segments = cwd.split(/[\\/]/).filter((segment) => segment !== '')
const last = segments[segments.length - 1]
return last === undefined ? undefined : last
}
/** Type guard: value is a non-null plain object (indexable by string). */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

32
desktop/src/logger.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* desktop/src/logger.ts — B3: tiny structured logger (GLUE).
*
* A one-line-per-message logger the glue modules (settings-store, embedded-
* server, and the Electron main files) inject instead of calling console.* —
* the project bans console.log everywhere (coding-style). Info goes to stdout;
* warn/error go to stderr. error() appends the underlying Error's message so a
* failure's cause is visible without a stack dump. No dependencies, no state.
*/
export interface Logger {
info(msg: string): void
warn(msg: string): void
error(msg: string, err?: unknown): void
}
/** Build a Logger that prefixes every line with `[scope]` and the level. */
export function createLogger(scope: string): Logger {
return {
info(msg: string): void {
process.stdout.write(`[${scope}] INFO: ${msg}\n`)
},
warn(msg: string): void {
process.stderr.write(`[${scope}] WARN: ${msg}\n`)
},
error(msg: string, err?: unknown): void {
// Only Error carries a reliable .message; other thrown values are opaque.
const detail = err instanceof Error ? `${err.message}` : ''
process.stderr.write(`[${scope}] ERROR: ${msg}${detail}\n`)
},
}
}

251
desktop/src/main.ts Normal file
View File

@@ -0,0 +1,251 @@
/**
* desktop/src/main.ts — the Electron entry point (glue only).
*
* Wires the desktop shell together: starts the embedded server (which hosts the
* unchanged frontend), opens the window against http://127.0.0.1:<port>, installs
* the app menu + tray + login item, registers the terminalapp:// deep link with a
* single-instance lock, and runs a background poller that turns session-status
* transitions into native notifications (the desktop app's core value over a
* browser tab). All real logic lives in the pure modules consumed below; this
* file owns only Electron lifecycle and dependency injection.
*
* __dirname is available because esbuild emits this as a CJS bundle (build.mjs).
*/
import { app, BrowserWindow, Menu, Notification, Tray } from 'electron'
import path from 'node:path'
import { createLogger } from './logger.js'
import { createSettingsStore } from './settings-store.js'
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 { buildAppMenu } from './menu.js'
import { createTray } from './tray.js'
import type { NotifyOptions } from './notify-policy.js'
import type { EmbeddedServer, SessionStatusSnapshot } from './types.js'
/** Live-session poll cadence — cheap loopback GET, so a few seconds is plenty. */
const POLL_INTERVAL_MS = 4000
/** IPC channel used to hand a deep-link path to the (optional) preload bridge. */
const DEEP_LINK_CHANNEL = 'deep-link'
/** Preload bundle name, emitted next to this file by esbuild. */
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')
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
let pollTimer: NodeJS.Timeout | null = null
let isQuitting = false
let isShuttingDown = false
// Previous snapshot map, keyed by sessionId; replaced (never mutated) each tick.
let prevSnapshots: ReadonlyMap<string, SessionStatusSnapshot> = new Map()
// Deep link captured before window/server were ready (cold start); dispatched once
// onReady finishes. macOS 'open-url' can fire pre-ready; Win/Linux pass it in argv.
let pendingDeepLink: string | null = null
// The first successful poll seeds the baseline WITHOUT notifying, so launching with
// pre-existing waiting/stuck sessions (e.g. openAtLogin, window unfocused) does not
// burst-notify for transitions that happened before the app started.
let pollPrimed = false
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/** Bring the window to the foreground (restoring/showing if hidden or minimized). */
function focusWindow(): void {
if (!mainWindow) return
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
/** Find the first terminalapp:// argument in a process argv list. */
function findDeepLinkArg(argv: readonly string[]): string | null {
const prefix = `${DEEP_LINK_PROTOCOL}://`
return argv.find((arg) => arg.startsWith(prefix)) ?? null
}
/**
* Apply a deep link: validate it, then navigate the window same-origin to
* /?join=<id> (the mechanism the unchanged frontend actually understands). The
* IPC send is a best-effort cooperative hook for any future in-page handler.
*/
function dispatchDeepLink(url: string): void {
const link = parseDeepLink(url)
if (!link) {
logger.warn(`ignoring malformed deep link: ${url}`)
return
}
if (!mainWindow || !server) {
// Cold start: window/server not up yet — buffer for onReady to dispatch.
pendingDeepLink = url
return
}
const targetPath = deepLinkToPath(link)
focusWindow()
mainWindow.webContents.send(DEEP_LINK_CHANNEL, targetPath)
void mainWindow.loadURL(`http://127.0.0.1:${server.port}${targetPath}`)
}
/** Hide-to-tray on window close (keep the server alive) unless we're quitting. */
function installWindowCloseGuard(win: BrowserWindow): void {
win.on('close', (event) => {
if (isQuitting) return
event.preventDefault()
win.hide()
})
win.on('closed', () => {
mainWindow = null
})
}
async function pollOnce(base: string): Promise<void> {
// Read prefs live each tick so notify toggles take effect without a restart
// (a settings UI wiring settings.set() lands in P2). NOTE: /live-sessions
// carries status only, not approval state, so mapLiveSessions fixes
// pendingApproval=false — the notifyOnApproval branch is driven by the hook
// status bus (P2/D8), not this poll; status→'waiting' is the current proxy.
const prefs = settings.get()
try {
const res = await fetch(`${base}/live-sessions`)
if (!res.ok) {
logger.warn(`live-sessions poll returned ${res.status}`)
return
}
const json: unknown = await res.json()
const snapshots = mapLiveSessions(json)
const opts: NotifyOptions = {
windowFocused: mainWindow?.isFocused() ?? false,
notifyOnApproval: prefs.notifyOnApproval,
notifyOnStatusChange: prefs.notifyOnStatusChange,
}
const { decisions, next } = computeNotifications(prevSnapshots, snapshots, opts)
if (pollPrimed) {
for (const decision of decisions) {
new Notification({ title: decision.title, body: decision.body }).show()
}
} else {
// Seed the baseline silently on the first successful poll (no burst).
pollPrimed = true
}
prevSnapshots = next
} catch (err: unknown) {
// A poll failure (server warming up, transient fetch error) must never crash.
logger.warn(`live-sessions poll failed: ${errorMessage(err)}`)
}
}
function startPoller(base: string): void {
pollTimer = setInterval(() => {
void pollOnce(base)
}, POLL_INTERVAL_MS)
}
async function onReady(): Promise<void> {
const prefs = settings.get()
server = await startEmbeddedServer({ prefs, logger })
const base = `http://127.0.0.1:${server.port}`
mainWindow = createMainWindow(`${base}/`, path.join(__dirname, PRELOAD_FILE))
installWindowCloseGuard(mainWindow)
Menu.setApplicationMenu(buildAppMenu())
try {
tray = createTray(path.join(__dirname, TRAY_ICON_SUBPATH), {
show: () => focusWindow(),
quit: () => {
isQuitting = true
app.quit()
},
})
} 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)}`)
}
// Only register auto-launch when the user enabled it. Calling this on an
// unsigned / non-standard-location build emits a native "Operation not permitted"
// line, so skip it in the default (off) case to keep the log clean.
if (prefs.openAtLogin) {
try {
app.setLoginItemSettings({ openAtLogin: true })
} catch (err: unknown) {
logger.warn(`could not set login item: ${errorMessage(err)}`)
}
}
startPoller(base)
// Cold-start deep link: macOS may have buffered an early 'open-url'; Windows/
// Linux deliver it in argv. Dispatch now that the window + server exist.
const coldLink = pendingDeepLink ?? findDeepLinkArg(process.argv)
if (coldLink) {
pendingDeepLink = null
dispatchDeepLink(coldLink)
}
logger.info(`desktop ready on ${base} (bind ${server.bindHost})`)
}
/** Graceful teardown: stop the poller, close the server, then hard-exit. */
async function shutdown(): Promise<void> {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
try {
if (server) await server.close()
} catch (err: unknown) {
logger.error('error while closing embedded server', err)
} finally {
tray?.destroy()
app.exit(0)
}
}
if (!app.requestSingleInstanceLock()) {
// Another instance owns the lock; it will receive our argv via 'second-instance'.
app.quit()
} else {
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL)
app.on('second-instance', (_event, argv) => {
focusWindow()
const url = findDeepLinkArg(argv)
if (url) dispatchDeepLink(url)
})
// macOS delivers deep links via 'open-url', not argv.
app.on('open-url', (_event, url) => {
dispatchDeepLink(url)
})
app.on('activate', () => {
focusWindow()
})
// Tray keeps the app (and embedded server) alive after the last window closes,
// so remote devices stay connected. Intentionally does not quit on any platform.
app.on('window-all-closed', () => {})
app.on('before-quit', (event) => {
isQuitting = true
if (isShuttingDown) return
isShuttingDown = true
// Take over quit so the async server.close() actually completes before exit.
event.preventDefault()
void shutdown()
})
app.whenReady().then(onReady).catch((err: unknown) => {
logger.error('failed to start desktop app', err)
app.quit()
})
}

53
desktop/src/menu.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* desktop/src/menu.ts — the native application menu.
*
* Built entirely from Electron's built-in roles (copy/paste/reload/zoom/…) so it
* needs no cooperation from the (unchanged) frontend. There are deliberately no
* page-driving custom actions here — the desktop shell adds native chrome, not
* new terminal behaviour. The caller passes the result to Menu.setApplicationMenu.
*/
import { Menu, type MenuItemConstructorOptions } from 'electron'
export function buildAppMenu(): Menu {
const isMac = process.platform === 'darwin'
const template: MenuItemConstructorOptions[] = [
...(isMac ? [{ role: 'appMenu' } as MenuItemConstructorOptions] : []),
{
label: 'File',
submenu: [isMac ? { role: 'close' } : { role: 'quit' }],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [{ role: 'minimize' }, { role: 'close' }],
},
]
return Menu.buildFromTemplate(template)
}

View File

@@ -0,0 +1,45 @@
/**
* desktop/src/notifications.ts — B2: batch notification diff across sessions.
*
* Given the previous per-session snapshots and the latest poll, compute which
* sessions warrant a native notification (delegating each decision to
* notify-policy's shouldNotify) and the NEW snapshot map to carry forward.
*
* Pure: never mutates `prev`. The returned `next` map is rebuilt only from the
* current snapshots, so sessions that ended (absent this round) naturally drop
* out and won't re-fire on their next appearance.
*/
import { shouldNotify, type NotifyOptions } from './notify-policy.js'
import type { NotifyDecision, SessionStatusSnapshot } from './types.js'
/** Result of diffing a poll: what to fire now + the state to remember. */
export interface NotificationsResult {
readonly decisions: readonly NotifyDecision[]
/** Fresh snapshot map (sessionId → snapshot) for the next round. Handed off as
* ReadonlyMap so callers can't mutate the carried-forward state (immutability
* is a CRITICAL project rule); the concrete Map is still assignable. */
readonly next: ReadonlyMap<string, SessionStatusSnapshot>
}
/**
* Diff the latest snapshots against the previous round. Collect only the
* notifying decisions, and build a new snapshot map keyed by sessionId. The
* input `prev` map is treated as read-only and left untouched.
*/
export function computeNotifications(
prev: ReadonlyMap<string, SessionStatusSnapshot>,
snapshots: readonly SessionStatusSnapshot[],
opts: NotifyOptions,
): NotificationsResult {
const decisions: NotifyDecision[] = []
const next = new Map<string, SessionStatusSnapshot>()
for (const snapshot of snapshots) {
const decision = shouldNotify(prev.get(snapshot.sessionId), snapshot, opts)
if (decision.notify) decisions.push(decision)
next.set(snapshot.sessionId, snapshot)
}
return { decisions, next }
}

View File

@@ -0,0 +1,105 @@
/**
* desktop/src/notify-policy.ts — B2: pure notification policy for one session.
*
* Decides whether a status transition (prev → next snapshot) is worth a native
* OS notification, and what it should say. This is the "core value" of the
* desktop shell (DESKTOP_PLAN §4.1): approval gates and status changes surface
* even when the window is minimized — but never nag when the user is looking.
*
* Pure & side-effect free; embedded-server/notifications glue calls it per
* snapshot and fires the actual Electron Notification.
*/
import type { NotifyDecision, SessionStatusSnapshot } from './types.js'
/** Runtime context the policy needs beyond the two snapshots. */
export interface NotifyOptions {
/** True when the app window is focused — suppresses all notifications. */
readonly windowFocused: boolean
/** User pref: notify when a session raises a pending approval. */
readonly notifyOnApproval: boolean
/** User pref: notify when a session's Claude status changes. */
readonly notifyOnStatusChange: boolean
}
/** Length of the session-id prefix used in the fallback label. */
const ID_LABEL_LENGTH = 8
/** A silent decision — reused whenever nothing is worth notifying about. */
const NO_NOTIFY: NotifyDecision = { notify: false, title: '', body: '' }
/**
* Statuses worth a notification. 'working'/'idle'/'unknown' are steady-state
* noise; only 'waiting' (needs you) and 'stuck' (something's wrong) are notable.
*/
const NOTABLE_STATUSES: ReadonlySet<string> = new Set(['waiting', 'stuck'])
/**
* Decide whether next warrants a notification, given the previous snapshot and
* runtime options. Priority: focus-suppression > approval rising-edge > status
* change to a notable status > silence.
*/
export function shouldNotify(
prev: SessionStatusSnapshot | undefined,
next: SessionStatusSnapshot,
opts: NotifyOptions,
): NotifyDecision {
// 1. Don't nag when the user is already looking at the app.
if (opts.windowFocused) return NO_NOTIFY
// 2. Approval rising edge takes priority — a held gate is the most actionable.
if (isApprovalRisingEdge(prev, next) && opts.notifyOnApproval) {
return approvalDecision(next)
}
// 3. Status change into a notable state ('waiting' | 'stuck').
if (opts.notifyOnStatusChange && isNotableStatusChange(prev, next)) {
return statusDecision(next)
}
return NO_NOTIFY
}
/** True only on the transition into pendingApproval (false/undefined → true). */
function isApprovalRisingEdge(
prev: SessionStatusSnapshot | undefined,
next: SessionStatusSnapshot,
): boolean {
return next.pendingApproval === true && prev?.pendingApproval !== true
}
/** True when the status actually changed and the new status is notable. */
function isNotableStatusChange(
prev: SessionStatusSnapshot | undefined,
next: SessionStatusSnapshot,
): boolean {
if (prev?.claudeStatus === next.claudeStatus) return false
return NOTABLE_STATUSES.has(next.claudeStatus)
}
/** Notification for a held approval gate, naming the gate kind when known. */
function approvalDecision(next: SessionStatusSnapshot): NotifyDecision {
// 'plan' gates read "plan approval"; every other gate (incl. null/tool) reads
// "tool approval" — never the ungrammatical "tool tool".
const gatePhrase = next.gate === 'plan' ? 'plan approval' : 'tool approval'
return {
notify: true,
title: 'Approval needed',
body: `${sessionLabel(next)} is waiting for ${gatePhrase}.`,
}
}
/** Notification for a notable status change ('waiting' | 'stuck'). */
function statusDecision(next: SessionStatusSnapshot): NotifyDecision {
const title = next.claudeStatus === 'stuck' ? 'Session may be stuck' : 'Session needs attention'
return {
notify: true,
title,
body: `${sessionLabel(next)} is now ${next.claudeStatus}.`,
}
}
/** Human label for a session: its title, else a short id prefix. */
function sessionLabel(snapshot: SessionStatusSnapshot): string {
return snapshot.title ?? `session ${snapshot.sessionId.slice(0, ID_LABEL_LENGTH)}`
}

63
desktop/src/port.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* desktop/src/port.ts — pick a bindable TCP port for the embedded server.
*
* The backend derives allowedOrigins from the concrete port (config.ts M1), so
* the desktop shell must resolve a DEFINITE port BEFORE loadConfig — never hand
* loadConfig a PORT=0, or http://127.0.0.1:<port> would fall off the Origin
* whitelist. We probe the preferred port on the loopback interface; if it is
* taken (EADDRINUSE) we ask the OS for a free one (listen on 0), read it, and
* close — so the returned port was confirmed bindable a moment ago.
*/
import net from 'node:net'
/** Loopback host we probe against — the embedded server always binds here in private mode. */
const LOOPBACK_HOST = '127.0.0.1'
/** listen(OS_ASSIGN_PORT) → the kernel picks a free ephemeral port for us. */
const OS_ASSIGN_PORT = 0
/**
* Return `preferred` if it can be bound on 127.0.0.1; otherwise an OS-assigned
* free port. A bind failure that is EADDRINUSE falls back to an OS-assigned
* port; any other error (e.g. EACCES on a privileged port) is rethrown.
*/
export async function pickFreePort(preferred: number): Promise<number> {
try {
return await tryBind(preferred)
} catch (err: unknown) {
if (isAddrInUse(err)) return tryBind(OS_ASSIGN_PORT)
throw err
}
}
/** Bind a throwaway server on `port` (0 → OS-assigned), read the actual port, close, resolve it. */
function tryBind(port: number): Promise<number> {
return new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.on('error', (err) => {
server.close()
reject(err)
})
server.listen(port, LOOPBACK_HOST, () => {
const address = server.address()
const bound = address !== null && typeof address === 'object' ? address.port : null
server.close(() => {
if (bound === null) {
reject(new Error('pickFreePort: server.address() did not return an AddressInfo'))
return
}
resolve(bound)
})
})
})
}
/** True iff `err` is a Node errno error whose code is EADDRINUSE. */
function isAddrInUse(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'EADDRINUSE'
)
}

104
desktop/src/prefs.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* desktop/src/prefs.ts — B3: pure prefs defaults, validation & merge.
*
* Preferences are persisted as JSON in the app's userData dir (see
* settings-store.ts). Everything read back from disk is UNTRUSTED (a user could
* hand-edit it, or an older/newer app version wrote a different shape), so
* validatePrefs re-derives a fully-formed DesktopPrefs field-by-field, falling
* back to the default whenever a field is the wrong type or out of range — it
* never throws. Validation is hand-rolled (no zod), matching src/config.ts.
*
* All functions are pure and return NEW objects (immutability — coding-style
* CRITICAL rule); they never mutate their arguments.
*/
import type { DesktopPrefs } from './types.js'
/** Valid TCP port range (inclusive); mirrors src/config.ts's PORT bounds. */
const MIN_PORT = 1
const MAX_PORT = 65535
/**
* The baseline preferences for a fresh install. LAN sharing is OFF by default —
* this app hands a full shell to anyone who can reach the port, so binding to
* 127.0.0.1 (private) is the safe default; LAN exposure is an explicit opt-in.
*
* `platform` is accepted for future per-platform defaults; today's defaults are
* platform-independent, but keeping the parameter avoids a signature change
* later (callers already thread the platform through).
*/
export function defaultPrefs(platform: NodeJS.Platform): DesktopPrefs {
void platform // reserved for future per-platform defaults (see doc-comment)
return {
port: null,
lanSharing: false,
shellPath: null,
openAtLogin: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
}
/** True iff `value` is a plain object (not null, not an array) we can index. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Adopt a boolean field, else fall back — anything non-boolean is rejected. */
function pickBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
}
/** Adopt an integer port in [MIN_PORT, MAX_PORT] or an explicit null, else fall back. */
function pickPort(value: unknown, fallback: number | null): number | null {
if (value === null) return null
if (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= MIN_PORT &&
value <= MAX_PORT
) {
return value
}
return fallback
}
/** Adopt a non-empty (non-whitespace) string path or an explicit null, else fall back. */
function pickShellPath(value: unknown, fallback: string | null): string | null {
if (value === null) return null
if (typeof value === 'string' && value.trim() !== '') return value
return fallback
}
/**
* 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
* valid; a non-object `raw` (or any bad field) yields the defaults. Never throws.
*/
export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopPrefs {
const defaults = defaultPrefs(platform)
if (!isRecord(raw)) return defaults
return {
port: pickPort(raw['port'], defaults.port),
lanSharing: pickBoolean(raw['lanSharing'], defaults.lanSharing),
shellPath: pickShellPath(raw['shellPath'], defaults.shellPath),
openAtLogin: pickBoolean(raw['openAtLogin'], defaults.openAtLogin),
notifyOnApproval: pickBoolean(raw['notifyOnApproval'], defaults.notifyOnApproval),
notifyOnStatusChange: pickBoolean(raw['notifyOnStatusChange'], defaults.notifyOnStatusChange),
}
}
/**
* Return a shallow copy of `patch` with `undefined`-valued fields removed, so a
* partial update never clobbers an existing pref with `undefined` (e.g. a form
* that only sends the fields it changed).
*/
function definedFieldsOf(patch: Partial<DesktopPrefs>): Partial<DesktopPrefs> {
const entries = Object.entries(patch).filter(([, value]) => value !== undefined)
return Object.fromEntries(entries) as Partial<DesktopPrefs>
}
/** Immutably merge a partial patch over a base, ignoring undefined patch fields. */
export function mergePrefs(base: DesktopPrefs, patch: Partial<DesktopPrefs>): DesktopPrefs {
return { ...base, ...definedFieldsOf(patch) }
}

19
desktop/src/preload.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* desktop/src/preload.ts — the minimal, contextIsolation-safe renderer bridge.
*
* Exposes exactly one thing: a deep-link listener. The unchanged frontend does
* not require this bridge (deep links are actually applied by main via a same-
* origin navigation to /?join=<id>), so it is an optional, forward-looking hook
* for future page-cooperative features. Crucially it exposes NO Node access
* (no require/fs/process) — only a callback registration over one whitelisted
* IPC channel — so it cannot widen the renderer's attack surface.
*/
import { contextBridge, ipcRenderer } from 'electron'
const DEEP_LINK_CHANNEL = 'deep-link'
contextBridge.exposeInMainWorld('desktop', {
onDeepLink: (cb: (path: string) => void): void => {
ipcRenderer.on(DEEP_LINK_CHANNEL, (_event, path: string) => cb(path))
},
})

View File

@@ -0,0 +1,43 @@
/**
* desktop/src/server-config.ts — build the env overrides fed to the backend's
* loadConfig() for the embedded server.
*
* Starts from a COPY of the ambient env (never mutates it) and pins the four
* values the desktop shell controls: PORT (the free port we already resolved),
* BIND_HOST (private 127.0.0.1 by default; 0.0.0.0 only when LAN sharing is on),
* SHELL_PATH (platform-aware default so Windows gets PowerShell), and USE_TMUX
* (forced off on Windows — tmux is a *nix keepalive; elsewhere respect the
* ambient env, defaulting off). Every other ambient var passes through untouched.
*/
import type { DesktopPrefs } from './types.js'
import { defaultShellForPlatform } from './shell.js'
/** Loopback bind host — private mode; the window connects here and always passes Origin checks. */
const PRIVATE_BIND_HOST = '127.0.0.1'
/** Wildcard bind host — LAN sharing (phones/tablets reconnect); opt-in, no auth. */
const LAN_BIND_HOST = '0.0.0.0'
/** tmux keepalive is a *nix feature; the desktop shell defaults it off (and forces off on Windows). */
const TMUX_OFF = '0'
export interface ServerEnvInput {
readonly prefs: DesktopPrefs
readonly port: number
readonly platform: NodeJS.Platform
readonly env: NodeJS.ProcessEnv
}
/**
* Produce the env-override object for loadConfig(). Immutable: input.env is
* spread into a NEW object and is never mutated.
*/
export function buildServerEnv(input: ServerEnvInput): Record<string, string | undefined> {
const { prefs, port, platform, env } = input
return {
...env,
PORT: String(port),
BIND_HOST: prefs.lanSharing ? LAN_BIND_HOST : PRIVATE_BIND_HOST,
SHELL_PATH: prefs.shellPath ?? defaultShellForPlatform(platform, env),
USE_TMUX: platform === 'win32' ? TMUX_OFF : (env.USE_TMUX ?? TMUX_OFF),
}
}

View File

@@ -0,0 +1,82 @@
/**
* desktop/src/settings-store.ts — B3: on-disk prefs persistence (get/set).
*
* A minimal, synchronous JSON store for DesktopPrefs — hand-rolled instead of
* electron-store to match the project's minimal-deps convention. It uses ONLY
* node:fs + node:path (no electron) so it is unit-testable against a real temp
* dir; the caller supplies the userData directory. Reads always go through
* validatePrefs, so a corrupt or hand-edited file degrades to defaults rather
* than crashing the app; writes are best-effort (a failure is logged, and the
* in-memory merged result is still returned so the session reflects the change).
*/
import fs from 'node:fs'
import path from 'node:path'
import type { Logger } from './logger.js'
import type { DesktopPrefs } from './types.js'
import { defaultPrefs, mergePrefs, validatePrefs } from './prefs.js'
/** Filename under the store dir; 2-space JSON stays human-readable/editable. */
const PREFS_FILENAME = 'prefs.json'
const JSON_INDENT = 2
export interface SettingsStore {
get(): DesktopPrefs
set(patch: Partial<DesktopPrefs>): DesktopPrefs
}
/** True iff `err` is a Node "file not found" error — the normal first-run case. */
function isFileNotFound(err: unknown): boolean {
return err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOENT'
}
/**
* Create a SettingsStore backed by `<dir>/prefs.json`.
* - get(): read + parse + validate; missing file → defaults (silent);
* unreadable/corrupt file → defaults (logged warn, never throws).
* - set(): merge the patch over current prefs, persist, return the merged prefs;
* a write failure is logged (error) but still returns the merged result.
*/
export function createSettingsStore(
dir: string,
platform: NodeJS.Platform,
logger: Logger,
): SettingsStore {
const filePath = path.join(dir, PREFS_FILENAME)
function get(): DesktopPrefs {
let contents: string
try {
contents = fs.readFileSync(filePath, 'utf8')
} catch (err: unknown) {
// A missing file is expected before the first save — don't cry wolf.
if (!isFileNotFound(err)) {
logger.warn(`could not read prefs at ${filePath}, using defaults`)
}
return defaultPrefs(platform)
}
try {
const parsed: unknown = JSON.parse(contents)
return validatePrefs(parsed, platform)
} catch {
// Corrupt JSON on disk (hand-edit gone wrong) — fall back, don't crash.
logger.warn(`prefs at ${filePath} is not valid JSON, using defaults`)
return defaultPrefs(platform)
}
}
function set(patch: Partial<DesktopPrefs>): DesktopPrefs {
const merged = mergePrefs(get(), patch)
try {
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(filePath, JSON.stringify(merged, null, JSON_INDENT), 'utf8')
} catch (err: unknown) {
// Best-effort persistence: report the failure but still hand back the
// merged prefs so the settings UI reflects the user's change this session.
logger.error(`failed to persist prefs to ${filePath}`, err)
}
return merged
}
return { get, set }
}

28
desktop/src/shell.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* desktop/src/shell.ts — the default login shell to spawn per host platform.
*
* The backend's loadConfig defaults SHELL_PATH to `$SHELL ?? /bin/zsh`, which is
* meaningless on Windows (no $SHELL, no /bin/zsh). The desktop shell overrides
* SHELL_PATH with this platform-aware default (see server-config.ts) so a
* Windows install spawns PowerShell (ConPTY-friendly) instead of a bogus path.
*/
/** Windows default: PowerShell works with node-pty's ConPTY backend out of the box. */
const WINDOWS_DEFAULT_SHELL = 'powershell.exe'
/** macOS fallback when $SHELL is unset (the default login shell since Catalina). */
const MACOS_FALLBACK_SHELL = '/bin/zsh'
/** Linux / other POSIX fallback when $SHELL is unset. */
const POSIX_FALLBACK_SHELL = '/bin/bash'
/**
* Resolve the shell to spawn for `platform`. win32 → PowerShell; darwin →
* $SHELL or /bin/zsh; anything else (linux, etc.) → $SHELL or /bin/bash.
*/
export function defaultShellForPlatform(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): string {
if (platform === 'win32') return WINDOWS_DEFAULT_SHELL
if (platform === 'darwin') return env.SHELL ?? MACOS_FALLBACK_SHELL
return env.SHELL ?? POSIX_FALLBACK_SHELL
}

32
desktop/src/tray.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* 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).
*
* 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'
const TRAY_TOOLTIP = 'Web Terminal'
export interface TrayHandlers {
show(): void
quit(): void
}
export function createTray(iconPath: string, handlers: TrayHandlers): Tray {
const tray = new Tray(iconPath)
const menu = Menu.buildFromTemplate([
{ label: 'Show Web Terminal', click: handlers.show },
{ type: 'separator' },
{ label: 'Quit', click: handlers.quit },
])
tray.setToolTip(TRAY_TOOLTIP)
tray.setContextMenu(menu)
return tray
}

77
desktop/src/types.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* desktop/src/types.ts — frozen shared contract for the Electron desktop shell.
*
* This is the single source of truth for cross-module data shapes (mirrors the
* backend's src/types.ts role). Modules own disjoint files but all agree here.
* All shapes are readonly (immutability — coding-style CRITICAL rule).
*
* Status/gate string unions are kept in sync with the backend (src/types.ts):
* ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck'
* PermissionGate = 'tool' | 'plan'
* They are re-declared (not imported) so the desktop package stays decoupled;
* notify-policy references these literals directly.
*/
/** Host platform (subset of NodeJS.Platform we branch on). */
export type Platform = 'darwin' | 'win32' | 'linux'
/** Claude activity, mirrored from backend ClaudeStatus (src/types.ts:97). */
export type DesktopClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck'
/** Approval gate kind, mirrored from backend PermissionGate (src/types.ts:101). */
export type DesktopPermissionGate = 'tool' | 'plan'
/**
* User preferences, persisted as JSON in the app's userData dir (hand-rolled
* store — matches the project's minimal-deps convention; no electron-store).
*/
export interface DesktopPrefs {
/** Preferred listen port; null → default (3000, with free-port fallback). */
readonly port: number | null
/** true → bind 0.0.0.0 (LAN sharing, no auth); false → 127.0.0.1 (private). */
readonly lanSharing: boolean
/** Shell override; null → platform default (see shell.ts). */
readonly shellPath: string | null
/** Launch the app on OS login. */
readonly openAtLogin: boolean
/** Fire a native notification when a session holds a pending approval. */
readonly notifyOnApproval: boolean
/** Fire a native notification when a session's Claude status changes. */
readonly notifyOnStatusChange: boolean
}
/** Handle returned by the embedded server, for lifecycle + window wiring. */
export interface EmbeddedServer {
/** The concrete port the server bound to (already free-checked). */
readonly port: number
/** The host it bound to ('0.0.0.0' or '127.0.0.1'). */
readonly bindHost: string
/** Origins the server will accept (derived by the backend from port + NICs). */
readonly allowedOrigins: readonly string[]
/** Graceful shutdown: closes HTTP + WS and kills all PTYs. */
close(): Promise<void>
}
/** A single session's status snapshot, fed to the notification policy. */
export interface SessionStatusSnapshot {
readonly sessionId: string
readonly claudeStatus: DesktopClaudeStatus
readonly pendingApproval: boolean
/** Gate kind when an approval is held (for a richer notification body). */
readonly gate: DesktopPermissionGate | null
/** Human label for the session (folder/process), if known. */
readonly title: string | undefined
}
/** The decision produced by notify-policy for one status transition. */
export interface NotifyDecision {
readonly notify: boolean
readonly title: string
readonly body: string
}
/** Parsed `terminalapp://` deep link. */
export interface DeepLink {
/** Session id to join (from terminalapp://join/<id>). */
readonly join: string
}

54
desktop/src/window.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* desktop/src/window.ts — creates the single BrowserWindow that hosts the
* unchanged web frontend, loaded from the embedded localhost server.
*
* 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:<port> 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.
*/
import { BrowserWindow } from 'electron'
const WINDOW_WIDTH = 1100
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 {
try {
return new URL(url).origin
} catch {
return null
}
}
export function createMainWindow(url: string, preloadPath: string): BrowserWindow {
const win = new BrowserWindow({
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
backgroundColor: BACKGROUND_COLOR,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: preloadPath,
},
})
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.
win.webContents.on('will-navigate', (event, targetUrl) => {
if (allowedOrigin === null) return
if (originOf(targetUrl) !== allowedOrigin) {
event.preventDefault()
}
})
void win.loadURL(url)
return win
}

18
desktop/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"// note": "Electron desktop shell. TYPE-CHECK ONLY (noEmit) — the runnable output is produced by esbuild (build.mjs → build/*.cjs). Mirrors the backend tsconfig (NodeNext, strict). Type-only imports of ../../src/types.js are erased at build time; node-pty & the compiled server (../dist) are loaded at runtime via dynamic import, never bundled.",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true
},
"include": ["src"]
}

286
docs/DESKTOP_PLAN.md Normal file
View File

@@ -0,0 +1,286 @@
# DESKTOP_PLAN.md — Electron 桌面客户端Mac + Windows一体化
> 落地方案文档。目标:把现有 web-terminal 打包成 Mac / Windows 原生桌面 App。
> 拓扑选型:**一体化App 内嵌 Node 服务器 + node-pty**。框架:**Electron**。
> 状态:**`desktop/` 代码已实现并验证2026-07-01分支 `feat/desktop-electron`** —— P0P2 的逻辑代码、单测、Electron 加固、esbuild 打包在本环境全部通过(桌面 `tsc` 0 错、全量 1401 tests 绿、覆盖率达标)。**未验证(需真机/CI见 §9 P1 D5D7**:实际启动 Electron GUI、node-pty 的 Electron-ABI 重编译、`.dmg`/`.exe` 打包。详见 `PROGRESS_LOG.md` 首条。本文是"怎么做"的蓝图,配合 `TECH_DOC.md`why+ `ARCHITECTURE.md`how
---
## 0. 目标与范围
### 做什么
一个 Mac / Windows 原生桌面 App双击即用
- 窗口里是**完全复用的现有前端**xterm.js 多标签 UI0 改动)。
- App **自己内嵌 Node 服务器 + node-pty**,无需单独启动 `npm start`。一次安装 = 一个原生终端 **同时** 又是给手机/平板复用的 LAN 服务器("vibe coding发个任务走开手机上重连查看"这个核心场景原样保留)。
- 原生集成:菜单栏/托盘常驻、**审批门的原生系统通知**(本 App 相对"开浏览器标签"的最大价值)、深链、开机自启。
### 不做v1 范围外)
- 鉴权/登录、多用户隔离沿用现有威胁模型LAN-only建议 Tailscale
- 自动更新(列为 P3可选
- 把前端重构成"可配置远程 base-url"——一体化模式下窗口连 `localhost`,用不到。
### 一个已被验证掉的大风险
现有服务器 **早就为内嵌设计好了**,几乎零重构:
```ts
// src/server.ts:187 —— 可编程启动,返回 close() 句柄
export function startServer(cfg: Config): { close(): Promise<void> }
// src/config.ts:245 —— 从 env-like 对象构造 Config
export function loadConfig(env: EnvLike): Config
// server.ts 底部 import.meta.url === process.argv[1] 守卫
// → 被 import 时【无副作用】,只有作为主脚本直接运行才 listen。
```
Electron 主进程直接 `import { startServer, loadConfig }`,构造 config → 启动 → 拿 `close()` 干净退出。**服务器代码一行不用改。**
---
## 1. 整体架构 / 进程模型
```
┌─────────────────────────── Electron App ───────────────────────────┐
│ │
│ Main process (Node.js 运行时) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ desktop/main.ts │ │
│ │ 1. pickFreePort() → 选一个空闲端口 │ │
│ │ 2. cfg = loadConfig({ ...env, PORT, BIND_HOST, SHELL_PATH})│ │
│ │ 3. server = startServer(cfg) ← 复用 src/server.ts │ │
│ │ └─ Express + ws + node-ptynative addon 在 main 跑) │ │
│ │ 4. BrowserWindow.loadURL(`http://127.0.0.1:${PORT}/`) │ │
│ │ 5. Tray / Notification / deep-link / auto-launch │ │
│ │ 6. app.on('quit') → server.close() + pty.kill(all) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ▲ IPC (preload) │
│ │ │
│ Renderer process (Chromium) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 现有前端 public/build/main.js0 改动) │ │
│ │ location.host = 127.0.0.1:PORT │ │
│ │ → WS ws://127.0.0.1:PORT/term ✓ Origin 白名单自动通过 │ │
│ │ → REST /sessions /prefs … ✓ 同源 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
▲ ws://<LAN-IP>:PORT/term (手机/平板,若开 LAN 模式)
```
**为什么服务器跑在 main 进程而不是子进程**:服务器是纯 I/O 的"字节搬运工"CLAUDE.md 的核心设计点),事件循环压力小,跑在 main 足够node-pty 原生模块在 main 加载最简单。**升级路**:若未来 main 卡顿,改用 Electron `utilityProcess`22+,官方推荐的 Node 服务隔离方式,可加载原生模块)把服务器挪出 mainIPC 转发端口即可。P1 先用 in-main。
**为什么窗口 loadURL localhost 而不是 file://**(关键决策,来自同源约束):前端严格同源——`buildWsUrl()``location.host`,所有 `fetch` 是相对路径。窗口加载 `http://127.0.0.1:PORT/``location.*` 解析到内嵌服务器WS/REST 全部正确Origin=`http://127.0.0.1:PORT` 命中白名单(`config.ts``deriveAllowedOrigins` 默认放行 localhost/127.0.0.1)。**绝不要**把 `public/build` 塞进 `file://` 加载——那会让所有相对请求失效、Origin 变 `file://` 被 401。
---
## 2. 目录结构
新增一个独立 `desktop/`,与现有 `src/`server`public/`(前端)**并列且解耦**——server/前端的 `package.json` 不受 Electron 依赖污染。
```
web-terminal/
├── src/ # 服务器(不动,复用 startServer/loadConfig
├── public/ # 前端(不动)
├── dist/ # tsc 输出dist/server.js …(复用现有 npm run build
├── public/build/ # esbuild 输出main.js复用现有 npm run build:web
└── desktop/ # ★ 新增Electron App
├── package.json # electron + electron-builder + electron-store"main": "build/main.cjs"
├── tsconfig.json
├── src/
│ ├── main.ts # Electron 入口:起服务器 + 建窗口 + 托盘 + 通知 + 生命周期
│ ├── preload.ts # contextBridgerenderer ↔ main通知/深链/设置)
│ ├── embedded-server.ts # pickFreePort + 构造 cfg + startServer 封装
│ ├── window.ts # BrowserWindow 创建 + splash/loading
│ ├── tray.ts # 托盘图标 + 聚合状态 + 上下文菜单
│ ├── notifications.ts# 审批门/Claude 状态 → 原生 Notification
│ ├── deep-link.ts # terminalapp://join/<id> 协议注册
│ ├── menu.ts # 应用菜单⌘T 新标签等映射)
│ └── settings.ts # electron-store端口/LAN 开关/默认 shell/自启
├── build/ # esbuild 打包 main/preload 的输出(.cjs
├── resources/ # 图标icon.icnsMac/ icon.icoWin/ tray png
├── electron-builder.yml
└── dist-app/ # electron-builder 产物:.dmg / .exe
```
**构建管线**(三步,前两步复用现有):
1. `npm run build`(根)→ `dist/server.js`(服务器 ESM
2. `npm run build:web`(根)→ `public/build/main.js`(前端)。
3. `desktop`esbuild 打包 `desktop/src/main.ts`+`preload.ts``desktop/build/*.cjs`native 依赖 `--external:node-pty`),再 `electron-builder` 出安装包。
> 语言细节Electron main 用 **CJS**`.cjs`)最省心(`--external:node-pty` + esbuild `--format=cjs --platform=node`);服务器 `dist/*.js` 是 ESM用动态 `await import('../dist/server.js')` 从 main 里加载即可CJS 里 `import()` 合法)。若坚持全 ESM需 Electron ≥28支持 ESM main——P1 先走 CJS main + 动态 import server。
---
## 3. 内嵌服务器embedded-server.ts
```ts
// 伪代码 —— 不可变、显式错误处理
import net from 'node:net'
async function pickFreePort(preferred = 3000): Promise<number> {
// 先试 preferred占用则让 OS 分配listen(0) 拿到后立即 close再交给 startServer
// 注意:不能直接给 startServer 传 PORT=0因为 allowedOrigins 由 cfg.port 派生,
// 必须先拿到【确定端口】再 loadConfig才能让 http://127.0.0.1:<port> 进白名单。
}
export async function startEmbeddedServer(prefs: DesktopPrefs) {
const port = await pickFreePort(prefs.port ?? 3000)
const { loadConfig } = await import('../../dist/config.js')
const { startServer } = await import('../../dist/server.js')
const cfg = loadConfig({
...process.env,
PORT: String(port),
// LAN 开关:默认 0.0.0.0(保留"手机重连"场景),可在设置里改 127.0.0.1(私有)
BIND_HOST: prefs.lanSharing ? '0.0.0.0' : '127.0.0.1',
// ★ 跨平台默认 shellconfig.ts 默认 $SHELL||/bin/zshWindows 上必须覆盖
SHELL_PATH: prefs.shellPath ?? defaultShellForPlatform(),
})
const handle = startServer(cfg) // { close(): Promise<void> }
return { port, handle, allowedOrigins: cfg.allowedOrigins }
}
function defaultShellForPlatform(): string {
if (process.platform === 'win32') return 'powershell.exe' // 或 pwsh.exe / cmd.exe
return process.env.SHELL ?? '/bin/zsh'
}
```
要点:
- **端口先定后配**`allowedOrigins``cfg.port` 派生,所以必须先拿到确定端口再 `loadConfig`,否则 `http://127.0.0.1:<port>` 进不了白名单。
- **LAN 模式是有意识的开关**:默认 `0.0.0.0` 保留手机重连场景;提供设置项切 `127.0.0.1` 走纯私有。无鉴权的威胁模型不变——设置页里明示"建议配合 Tailscale"。
- **退出即清理**`app.on('before-quit')``await handle.close()`(内部 `pty.kill()` 所有会话)。
---
## 4. 原生集成
### 4.1 原生通知(本 App 的核心价值)
前端每个会话已经收 `status` 帧(`pending` 审批、Claude 状态)。要在窗口失焦/最小化时弹系统通知:
- **快路P2 起步)**preload 暴露一个 `notify()`renderer 在某会话 `pendingApproval` 转 true 或 Claude 状态变化时经 IPC 调 main 的 Electron `Notification`。只覆盖【已开标签】的会话。
- **全路(一体化独有优势)**:服务器就在 main 进程内main 可**直接**订阅 session manager 的状态总线(`/hook/status` 已有),对**所有**会话(哪怕没开标签)弹通知。点通知 → 聚焦窗口/切到该会话 → 一键批准(复用现有 approve/reject 协议)。
- 通知去重/节流:同一会话 pending 只弹一次;已聚焦窗口时不弹。
### 4.2 托盘 / 菜单栏
Tray 图标显示聚合状态(如"2 会话 · 1 待审批"),点击唤起窗口;右键菜单列会话 + 打开/退出。main 有服务器 → 直接从 live session 列表填充。
### 4.3 深链 / 单实例
- `app.requestSingleInstanceLock()`:二次启动聚焦已有窗口(同时接住深链参数)。
- 注册 `terminalapp://` 协议 → `terminalapp://join/<id>` 映射到现有 `/?join=<id>`
### 4.4 应用菜单 / 快捷键
原生菜单把 ⌘T 新标签、⌘W 关标签、⌘F 搜索等映射到前端已有能力(经 preload IPC 触发页面事件,或直接注入按键)。
### 4.5 开机自启
`app.setLoginItemSettings({ openAtLogin })`,设置页开关。
### 4.6 Claude Code hooks
`npm run setup-hooks` 现在指向本地服务器——一体化下服务器就在本机hooks 天然可用;只需把 setup 指向内嵌服务器的实际端口(可在设置页放一个"安装 hooks"按钮,用当前端口生成配置)。`POST /hook*` 是 loopback-onlylocalhost 内嵌完全满足。
---
## 5. node-pty 打包(唯一的原生模块难点)
- **ABI 重编译**node-pty 是 native `.node` addonElectron 的 Node ABI ≠ 系统 Node。`desktop/package.json``postinstall: electron-builder install-app-deps`(内部走 `@electron/rebuild`)对 Electron ABI 重编译。现有 `node_modules/node-pty/prebuilds` 是**系统 ABI**,不能直接用,但它证明了 node-pty 支持 win32/darwin 两平台,重编译产出 Electron-ABI 版即可。
- **asar 解包**`.node` 不能在 asar 内加载。electron-builder 配置 `asarUnpack: ["**/node_modules/node-pty/**"]`。Windows 上 node-pty 依赖 ConPTY`conpty.dll` / `conpty/`)——一并解包。
- **打包内容**`files` 需含 `dist/`(服务器)、`public/`(含 `public/build`)、`desktop/build`,以及生产依赖 `node_modules`express/ws/node-pty/qrcode/web-push
---
## 6. 打包 · 分发 · 签名electron-builder
```yaml
# desktop/electron-builder.yml要点
appId: com.<you>.web-terminal
mac: { target: [dmg, zip], category: public.app-category.developer-tools }
win: { target: [nsis] }
asarUnpack: ["**/node_modules/node-pty/**"]
files: ["build/**", "../dist/**", "../public/**", "../node_modules/**"]
```
- **Mac 目标**`.dmg`(可 universalarm64+x64。分发要 Apple Developer ID 签名 + 公证(`afterSign``@electron/notarize`$99/年)。**自用可自签或不签**(用户首次右键打开绕过 Gatekeeper
- **Windows 目标**NSIS `.exe`。分发要 Authenticode 证书自用可不签SmartScreen 会警告一次)。
- **自动更新P3 可选)**`electron-updater` + GitHub Releases 承载 `latest.yml` / `zip`
---
## 7. Windows 特有事项
1. **默认 shell**`config.ts` 默认 `$SHELL || /bin/zsh` 在 Win 无效 → 必须覆盖 `SHELL_PATH=powershell.exe`(或 `pwsh`/`cmd`)。见 §3。
2. **ConPTY**node-pty 在 Win 走 ConPTY需 Win10 1809+打包解包其依赖§5
3. **路径分隔符**:服务器涉及 cwd/项目扫描的地方用 Node `path`——现有代码应已跨平台,但 Win 首跑要专门测项目发现/worktree 功能(`src/http/projects.ts` / `worktrees.ts`)。
4. **`USE_TMUX`**tmux keepalive 是 *nix 特性Win 上应默认关(`USE_TMUX=0`)。
---
## 8. 安全考量(沿用现有威胁模型)
- **Origin 校验不变**:窗口连 `127.0.0.1` 自动过;开 LAN 模式时 NIC IP 也在白名单。default-deny空 Origin 被拒)继续保护。
- **无鉴权**LAN 模式仍是"谁能连端口谁就有 shell"。设置页明示风险 + 建议 Tailscale默认可考虑 `127.0.0.1`(私有)让用户显式开 LAN。
- **loopback hooks**`/hook*` 仅 loopback一体化天然满足。
- **Electron 加固**`contextIsolation: true``nodeIntegration: false`、preload 用 `contextBridge` 暴露最小 API`webPreferences``allowRunningInsecureContent`;只 loadURL 本地端口。
---
## 9. 分期任务拆解 + 工作量估算
> 风格对齐 PLAN.md每个任务给 Owns / 验证方式 / 估时。P0P1 出"能跑的一体化 App"P2 加原生价值P3 分发。
### P0 — 骨架跑通("它启动了")· ~1 天
- **D1** `desktop/` 脚手架package.jsonelectron/electron-builder/esbuild、tsconfig、esbuild 打包脚本。*验证*`electron .` 能开空窗口。
- **D2** `embedded-server.ts`pickFreePort + loadConfig(覆盖 PORT/BIND_HOST/SHELL_PATH) + startServer。*验证*main 里起服务器,`curl 127.0.0.1:<port>/sessions` 有响应。
- **D3** `window.ts`BrowserWindow loadURL localhostcontextIsolation 加固。*验证*:窗口里出现现有终端 UI能开标签、跑 shell、WS 连通(前端 0 改动)。
- **D4** 生命周期:`before-quit``server.close()`;单实例锁。*验证*:退出无残留 pty 进程。
### P1 — 跨平台真安装包 · ~24 天
- **D5** node-pty 对 Electron ABI 重编译(`install-app-deps`+ `asarUnpack`。*验证*:打包后的 App非 dev里 pty 正常 spawn。
- **D6** electron-builderMac `.dmg`。*验证*:装到干净 Mac双击可用。
- **D7** electron-builderWin `.exe` + Windows 默认 shell/ConPTY/USE_TMUX=0。*验证*:装到 Win10+PowerShell 会话可用,项目发现功能可用。
### P2 — 原生集成(相对浏览器标签的价值) · ~23 天
- **D8** 原生通知:审批门/Claude 状态 → Electron Notification先快路再接 main 状态总线覆盖全会话)。*验证*:窗口最小化时 Claude 待审批弹系统通知,点通知回到会话。
- **D9** 托盘 + 聚合状态 + 上下文菜单。
- **D10** 应用菜单/快捷键映射 + 深链 `terminalapp://` + 开机自启 + 设置页(端口/LAN 开关/默认 shell/自启electron-store
### P3 — 分发打磨(可选) · ~13 天
- **D11** 代码签名 + 公证Mac/ AuthenticodeWin
- **D12** electron-updater 自动更新GitHub Releases
**合计**:能跑的一体化 AppP0+P1**35 天**加满原生价值P2**23 天**分发签名P3视是否对外。
---
## 10. 风险与开放问题
| 风险 / 问题 | 影响 | 缓解 / 待定 |
|---|---|---|
| node-pty Electron ABI 重编译在 CI 上跨平台出包 | 中 | 用 GitHub Actions 的 mac + win runner 分别出包;本地先手动验证 |
| main 进程跑服务器导致 UI 卡顿 | 低 | 服务器是纯 I/O若真卡升级 `utilityProcess` 隔离 |
| Windows 首跑项目发现/worktree/OSC 行为差异 | 中 | P1 D7 专测;必要时给 Win 打补丁(应在 server 侧routes 归属不变) |
| 无鉴权 + 默认 LAN 暴露 | 中 | 默认 `127.0.0.1`LAN 为显式开关 + Tailscale 提示 |
| 现有 `sw.js` 以 classic 方式注册却用 ESM import疑似 bug`main.ts:87``{type:'module'}`| 低 | 桌面壳不依赖 SW但若在乎浏览器端离线/推送需单独修(与本方案解耦) |
| 是否复用 root `package.json` 还是 npm workspaces | 低 | 倾向 `desktop/` 独立 package + 相对引用 `../dist`;如需统一装依赖再上 workspaces |
**待你拍板的开放项**
1. 默认 `BIND_HOST``0.0.0.0`(开箱即可手机重连,风险高)还是 `127.0.0.1`私有LAN 需手动开)?
2. `appId` / 产品名 / 图标。
3. 是否现在就要签名分发,还是先自用(不签)。
---
## 11. 与现有文档的关系
- 本文只新增 `desktop/`**不改** `src/``public/``startServer`/`loadConfig` 已够用)。
- 若 P1 在 Windows 上发现 server 侧跨平台缺陷修复归属对应模块route 文件),并按 CLAUDE.md 记 `PROGRESS_LOG.md`——桌面壳不越界改 server。
- 冲突时ARCHITECTURE 管 how、TECH_DOC 管 why/scope本文是它们之上的"打包/分发"新层,不与协议/会话模型冲突。
</content>
</invoke>

View File

@@ -24,6 +24,15 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### ✅ 桌面客户端 v0.1 — Electron 一体化壳(代码完成 — 2026-07-01,分支 `feat/desktop-electron`,未提交)
Mac/Windows 桌面 App,**内嵌现有 Node 服务器 + node-pty**(all-in-one):主进程 `startEmbeddedServer` 复用 `src/server.ts``startServer(cfg)`/`loadConfig`(**服务器零改动** —— 它本就导出可编程启动且 import 无副作用),窗口 `loadURL('http://127.0.0.1:<port>/')` 复用**前端零改动**(前端严格同源,`location.host` 自动指向内嵌服务器;Origin 白名单默认含 localhost)。核心价值 = **原生通知**(轮询 `/live-sessions``computeNotifications` → 系统通知)+ 托盘常驻 + 深链 `terminalapp://` + 开机自启。
- **编排**: ultracode `Workflow` —— 脚手架(orchestrator 冻结 `desktop/src/types.ts` 契约 + package/tsconfig/esbuild/electron-builder)→ Build(4 builder 并行、文件互斥、对齐冻结签名)→ Review(correctness/security/typescript 三 lens 并行、schema 结构化 findings)。纯逻辑尽量抽出可单测;Electron glue(main/window/tray/menu/preload/embedded-server/logger)作为不可单测 wiring 排除出覆盖率(沿用既有 vitest.config 先例)。**无用 zod、无用 electron-store**(遵循项目手写校验 + 手写 JSON 持久化的最小依赖惯例)。
- **交付**(`desktop/src/` 17 文件 + `test/desktop/` 9 测试文件): 纯模块 `port`(pickFreePort)/`shell`(平台默认 shell,Win→powershell)/`server-config`(env 覆盖映射)/`deep-link`(解析+防注入)/`notify-policy`(通知策略)/`notifications`(批量 diff)/`live-poll`(边界校验 /live-sessions)/`prefs`(手写校验)/`settings-store`(JSON 持久化);glue `embedded-server`/`window`(加固:contextIsolation+sandbox+拒绝外域导航)/`menu`/`tray`/`preload`(最小 bridge)/`main`/`logger`
- **Review 结果**: **无 CRITICAL/HIGH 安全问题**(Electron 加固与不可信输入处理被评为扎实)。修复的真实 bug(orchestrator 直接改+补测): ① 冷启动深链丢失(缓冲 open-url/argv 到 ready 后派发) ② `createTray` 无守卫会崩启动(try/catch,失败无托盘继续) ③ 首轮轮询通知轰炸(首 tick 只建基线) ④ poller 捕获过期 prefs(每 tick 读 settings.get) ⑤ notify 'tool tool' 病句→'tool approval' ⑥ live-poll Windows 反斜杠路径 + 未过滤 exited ⑦ `NotificationsResult.next`→ReadonlyMap。
- **验证**: 桌面 `tsc --noEmit` 0 错;**全量 1401 tests 绿**(基线 1307 +94 桌面,零回归);覆盖率全局 91.65/85.39/92.44/93.48(阈值 80×4 通过)、`desktop/src` 97.56/95.04/100/97.81;esbuild 打包 `main.cjs`/`preload.cjs` 产出、electron/node-pty 保持 external、dist 走运行时动态 import。
- **本环境无法验证(留真机/CI,DESKTOP_PLAN P1 D5D7)**: `.exe`(Windows,需真机/无 wine)。**审批门通知源**(`/live-sessions` 不带 pendingApproval)留 P2/D8 接 hook 侧信道 —— 策略已实现且测试,当前以 status→'waiting' 为代理。方案文档 `docs/DESKTOP_PLAN.md`
- **✅ Mac 打包 + 启动验证(2026-07-01,本机 arm64,已实测)**: `electron-builder``.dmg`(arm64,深度 ad-hoc 签名)。**关键修复**:首版只打了 node-pty、漏了服务器其它 npm 依赖(express/ws/web-push),导致**仅原地能跑**(沿目录树借了仓库根 `node_modules`),移到 `/Applications` 即崩 `Cannot find package 'express'`。改 `electron-builder.yml`:把 `dist/` + `public/` + `node_modules`(过滤 electron/electron-builder/esbuild/typescript 等构建工具;node-pty 经 `npmRebuild` 为 Electron 43 ABI 重编译)**全部铺到磁盘 Resources**(Model 2,与 `embedded-server.ts``process.resourcesPath` 解析匹配),desktop/package.json 补 express/ws/web-push 依赖。**仓库外副本直接执行验证通过**:服务器起、`GET /` + `/build/main.js → 200``/live-sessions → []`、node-pty 加载 OK、无 `Cannot find` 报错。`main.ts` 默认不注册开机自启(消除 native "Operation not permitted" 噪音)。**Gatekeeper**:未签名 ad-hoc 在 Sequoia(darwin24)双击被拦 → `xattr -cr` 清 quarantine/provenance + 深度签名后 `open`/双击可正常启动(实测 PID 存活、200)。产物 `desktop/dist-app/Web Terminal-0.1.0-arm64.dmg`(~206M,含完整 node_modules)。**仅 arm64;Intel 需 `--x64`/`--universal`;Windows 需真机;对外分发需 Apple Developer 签名+公证。**
### ✅ VC 语音命令映射 — 语音直接 approve/reject 权限门(完成 — 2026-07-01,分支 `v0.6-projects`,未提交)
上下文门控:仅当活动 tab 有 held 权限门(`pendingApproval===true``gate==='tool'`)时,语音「确认/批准/yes…」→ 复用现有 `{type:'approve'}` WS 通道、「拒绝/取消/no…」→ `{type:'reject'}`;其余一律口述不变。**后端零改动**。安全设计:整句精确匹配(否定/口水词攻不破)+ 前导否定守卫 + reject 优先 + 置信度门(仅 approve)+ **可撤销确认窗口(1.5s,提交时复检门身份/连接,防 TOCTOU)** + stale-gate epoch。新增 `public/voice-commands.ts`(纯匹配器 100% 覆盖)+ `public/voice-confirm.ts`(确认窗口 100%),接线 `tabs/voice/terminal-session/main.ts`。**1307 tests 绿**,双 review(code+security)判 CRITICAL 已全修复并补测。规划 `docs/PLAN_VOICE_COMMANDS.md`,详见 Detailed Log 首条。**待办**:confidence fail-open 取舍留给用户;tabs.ts 968 行待拆分。

View File

@@ -0,0 +1,96 @@
/**
* test/desktop/deep-link.test.ts — B2: parseDeepLink / deepLinkToPath.
*/
import { describe, test, expect } from 'vitest'
import {
DEEP_LINK_PROTOCOL,
parseDeepLink,
deepLinkToPath,
} from '../../desktop/src/deep-link.js'
describe('DEEP_LINK_PROTOCOL', () => {
test('is the terminalapp scheme', () => {
expect(DEEP_LINK_PROTOCOL).toBe('terminalapp')
})
})
describe('parseDeepLink', () => {
test('parses a valid terminalapp://join/<id> link', () => {
// Arrange
const url = 'terminalapp://join/abc123'
// Act
const result = parseDeepLink(url)
// Assert
expect(result).toEqual({ join: 'abc123' })
})
test('decodes a percent-encoded id', () => {
// Arrange
const url = 'terminalapp://join/a%20b'
// Act
const result = parseDeepLink(url)
// Assert
expect(result).toEqual({ join: 'a b' })
})
test('returns null for a wrong scheme', () => {
expect(parseDeepLink('https://join/abc')).toBeNull()
})
test('returns null for a wrong host', () => {
expect(parseDeepLink('terminalapp://open/abc')).toBeNull()
})
test('returns null when the id is missing', () => {
expect(parseDeepLink('terminalapp://join')).toBeNull()
expect(parseDeepLink('terminalapp://join/')).toBeNull()
})
test('returns null when the id contains a nested slash', () => {
expect(parseDeepLink('terminalapp://join/a/b')).toBeNull()
})
test('returns null for a whitespace-only id', () => {
expect(parseDeepLink('terminalapp://join/%20')).toBeNull()
})
test('returns null for an id with a control character', () => {
expect(parseDeepLink('terminalapp://join/a%01b')).toBeNull()
})
test('returns null for malformed percent-encoding', () => {
expect(parseDeepLink('terminalapp://join/%E0%A4%A')).toBeNull()
})
test('returns null for a malformed URL', () => {
expect(parseDeepLink('not a url')).toBeNull()
expect(parseDeepLink('')).toBeNull()
})
})
describe('deepLinkToPath', () => {
test('renders the /?join= route for a simple id', () => {
expect(deepLinkToPath({ join: 'abc123' })).toBe('/?join=abc123')
})
test('encodes an id needing escaping', () => {
expect(deepLinkToPath({ join: 'a b/c' })).toBe('/?join=a%20b%2Fc')
})
test('round-trips a percent-decoded id back through encoding', () => {
// Arrange
const parsed = parseDeepLink('terminalapp://join/a%20b')
expect(parsed).not.toBeNull()
// Act
const path = deepLinkToPath(parsed!)
// Assert
expect(path).toBe('/?join=a%20b')
})
})

View File

@@ -0,0 +1,134 @@
/**
* test/desktop/live-poll.test.ts — B2: mapLiveSessions boundary validation.
*/
import { describe, test, expect } from 'vitest'
import { mapLiveSessions } from '../../desktop/src/live-poll.js'
describe('mapLiveSessions', () => {
test('returns [] when the body is not an array', () => {
expect(mapLiveSessions(null)).toEqual([])
expect(mapLiveSessions(undefined)).toEqual([])
expect(mapLiveSessions({})).toEqual([])
expect(mapLiveSessions('nope')).toEqual([])
expect(mapLiveSessions(42)).toEqual([])
})
test('maps a well-formed element to a snapshot', () => {
// Arrange
const raw = [
{ id: 'sess-1', status: 'waiting', cwd: '/Users/me/projects/web-terminal', exited: false },
]
// Act
const result = mapLiveSessions(raw)
// Assert
expect(result).toEqual([
{
sessionId: 'sess-1',
claudeStatus: 'waiting',
pendingApproval: false,
gate: null,
title: 'web-terminal',
},
])
})
test('accepts every valid status literal', () => {
const raw = [
{ id: 'a', status: 'working', cwd: null },
{ id: 'b', status: 'waiting', cwd: null },
{ id: 'c', status: 'idle', cwd: null },
{ id: 'd', status: 'unknown', cwd: null },
{ id: 'e', status: 'stuck', cwd: null },
]
const result = mapLiveSessions(raw)
expect(result.map((s) => s.claudeStatus)).toEqual([
'working',
'waiting',
'idle',
'unknown',
'stuck',
])
})
test('filters out elements missing a string id', () => {
const raw = [
{ status: 'idle', cwd: null },
{ id: 42, status: 'idle', cwd: null },
{ id: '', status: 'idle', cwd: null },
{ id: 'ok', status: 'idle', cwd: null },
]
const result = mapLiveSessions(raw)
expect(result).toHaveLength(1)
expect(result[0].sessionId).toBe('ok')
})
test('filters out elements with an invalid or missing status', () => {
const raw = [
{ id: 'a', status: 'busy', cwd: null },
{ id: 'b', status: 42, cwd: null },
{ id: 'c', cwd: null },
{ id: 'd', status: 'idle', cwd: null },
]
const result = mapLiveSessions(raw)
expect(result.map((s) => s.sessionId)).toEqual(['d'])
})
test('derives the title from the last cwd path segment', () => {
const raw = [{ id: 'a', status: 'idle', cwd: '/Users/me/projects/foo/' }]
const result = mapLiveSessions(raw)
expect(result[0].title).toBe('foo')
})
test('derives the title from a Windows backslash cwd', () => {
const raw = [{ id: 'a', status: 'idle', cwd: 'C:\\Users\\me\\projects\\foo' }]
const result = mapLiveSessions(raw)
expect(result[0].title).toBe('foo')
})
test('filters out already-exited sessions', () => {
const raw = [
{ id: 'live', status: 'waiting', cwd: null, exited: false },
{ id: 'dead', status: 'waiting', cwd: null, exited: true },
]
const result = mapLiveSessions(raw)
expect(result.map((s) => s.sessionId)).toEqual(['live'])
})
test('leaves title undefined when cwd is null or blank', () => {
const raw = [
{ id: 'a', status: 'idle', cwd: null },
{ id: 'b', status: 'idle', cwd: '' },
{ id: 'c', status: 'idle' },
]
const result = mapLiveSessions(raw)
expect(result.map((s) => s.title)).toEqual([undefined, undefined, undefined])
})
test('never throws on garbage array elements', () => {
const raw = [null, undefined, 42, 'str', [], { id: 'ok', status: 'idle', cwd: null }]
// Act
const act = (): unknown => mapLiveSessions(raw)
// Assert
expect(act).not.toThrow()
expect(mapLiveSessions(raw)).toHaveLength(1)
})
})

View File

@@ -0,0 +1,113 @@
/**
* test/desktop/notifications.test.ts — B2: computeNotifications batch diff.
*/
import { describe, test, expect } from 'vitest'
import { computeNotifications } from '../../desktop/src/notifications.js'
import type { NotifyOptions } from '../../desktop/src/notify-policy.js'
import type { SessionStatusSnapshot } from '../../desktop/src/types.js'
function snapshot(overrides: Partial<SessionStatusSnapshot> = {}): SessionStatusSnapshot {
return {
sessionId: 'sess-a',
claudeStatus: 'idle',
pendingApproval: false,
gate: null,
title: undefined,
...overrides,
}
}
const OPTS: NotifyOptions = {
windowFocused: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
describe('computeNotifications', () => {
test('collects only the notifying decisions', () => {
// Arrange: sess-a raises an approval (notify), sess-b is unchanged (silent).
const prev = new Map<string, SessionStatusSnapshot>([
['sess-a', snapshot({ sessionId: 'sess-a', pendingApproval: false })],
['sess-b', snapshot({ sessionId: 'sess-b', claudeStatus: 'working' })],
])
const snapshots = [
snapshot({ sessionId: 'sess-a', pendingApproval: true, gate: 'tool' }),
snapshot({ sessionId: 'sess-b', claudeStatus: 'working' }),
]
// Act
const { decisions } = computeNotifications(prev, snapshots, OPTS)
// Assert
expect(decisions).toHaveLength(1)
expect(decisions[0].title).toBe('Approval needed')
})
test('returns a new map keyed by sessionId', () => {
const snapshots = [
snapshot({ sessionId: 'sess-a' }),
snapshot({ sessionId: 'sess-b' }),
]
const { next } = computeNotifications(new Map(), snapshots, OPTS)
expect(next).toBeInstanceOf(Map)
expect([...next.keys()].sort()).toEqual(['sess-a', 'sess-b'])
expect(next.get('sess-a')).toEqual(snapshots[0])
})
test('does not mutate the previous map', () => {
// Arrange
const prev = new Map<string, SessionStatusSnapshot>([
['sess-a', snapshot({ sessionId: 'sess-a', claudeStatus: 'working' })],
])
const snapshots = [snapshot({ sessionId: 'sess-a', claudeStatus: 'waiting' })]
// Act
const { next } = computeNotifications(prev, snapshots, OPTS)
// Assert: prev is untouched; next is a distinct object with the new value.
expect(prev.size).toBe(1)
expect(prev.get('sess-a')?.claudeStatus).toBe('working')
expect(next).not.toBe(prev)
expect(next.get('sess-a')?.claudeStatus).toBe('waiting')
})
test('drops sessions that ended (absent from snapshots) from next', () => {
// Arrange: sess-b existed before but is gone this round.
const prev = new Map<string, SessionStatusSnapshot>([
['sess-a', snapshot({ sessionId: 'sess-a' })],
['sess-b', snapshot({ sessionId: 'sess-b' })],
])
const snapshots = [snapshot({ sessionId: 'sess-a' })]
// Act
const { next } = computeNotifications(prev, snapshots, OPTS)
// Assert
expect(next.has('sess-b')).toBe(false)
expect(next.has('sess-a')).toBe(true)
})
test('handles multiple notifying sessions', () => {
const snapshots = [
snapshot({ sessionId: 'sess-a', pendingApproval: true, gate: 'plan' }),
snapshot({ sessionId: 'sess-b', claudeStatus: 'stuck' }),
]
const { decisions, next } = computeNotifications(new Map(), snapshots, OPTS)
expect(decisions).toHaveLength(2)
expect(next.size).toBe(2)
})
test('returns no decisions for an empty snapshot list', () => {
const prev = new Map<string, SessionStatusSnapshot>([['sess-a', snapshot()]])
const { decisions, next } = computeNotifications(prev, [], OPTS)
expect(decisions).toEqual([])
expect(next.size).toBe(0)
})
})

View File

@@ -0,0 +1,188 @@
/**
* test/desktop/notify-policy.test.ts — B2: shouldNotify decision rules.
*/
import { describe, test, expect } from 'vitest'
import { shouldNotify, type NotifyOptions } from '../../desktop/src/notify-policy.js'
import type { SessionStatusSnapshot } from '../../desktop/src/types.js'
function snapshot(overrides: Partial<SessionStatusSnapshot> = {}): SessionStatusSnapshot {
return {
sessionId: 'sess-1234abcd-ef',
claudeStatus: 'idle',
pendingApproval: false,
gate: null,
title: undefined,
...overrides,
}
}
const OPTS: NotifyOptions = {
windowFocused: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
describe('shouldNotify — focus suppression', () => {
test('suppresses everything when the window is focused', () => {
// Arrange
const prev = snapshot()
const next = snapshot({ pendingApproval: true, gate: 'plan', claudeStatus: 'waiting' })
// Act
const decision = shouldNotify(prev, next, { ...OPTS, windowFocused: true })
// Assert
expect(decision).toEqual({ notify: false, title: '', body: '' })
})
})
describe('shouldNotify — approval rising edge', () => {
test('notifies on the false → true approval edge', () => {
const prev = snapshot({ pendingApproval: false })
const next = snapshot({ pendingApproval: true, gate: 'tool' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(true)
expect(decision.title).toBe('Approval needed')
expect(decision.body).toContain('tool approval')
expect(decision.body).not.toContain('tool tool')
})
test('reads "tool approval" (never "tool tool") for a tool or null gate', () => {
for (const gate of ['tool', null] as const) {
const next = snapshot({ pendingApproval: true, gate })
const decision = shouldNotify(undefined, next, OPTS)
expect(decision.body).toContain('tool approval')
expect(decision.body).not.toContain('tool tool')
}
})
test('notifies when there is no previous snapshot and approval is pending', () => {
const next = snapshot({ pendingApproval: true, gate: 'plan' })
const decision = shouldNotify(undefined, next, OPTS)
expect(decision.notify).toBe(true)
expect(decision.body).toContain('plan approval')
})
test('does NOT notify when approval was already pending', () => {
const prev = snapshot({ pendingApproval: true, gate: 'tool' })
const next = snapshot({ pendingApproval: true, gate: 'tool' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(false)
})
test('is gated off by notifyOnApproval=false', () => {
const prev = snapshot({ pendingApproval: false })
const next = snapshot({ pendingApproval: true, gate: 'tool' })
const decision = shouldNotify(prev, next, { ...OPTS, notifyOnApproval: false })
expect(decision.notify).toBe(false)
})
test('uses the session title in the body when known', () => {
const next = snapshot({ pendingApproval: true, gate: 'plan', title: 'web-terminal' })
const decision = shouldNotify(undefined, next, OPTS)
expect(decision.body).toContain('web-terminal')
})
test('falls back to a short id label when title is undefined', () => {
const next = snapshot({ pendingApproval: true, gate: 'tool', title: undefined })
const decision = shouldNotify(undefined, next, OPTS)
expect(decision.body).toContain('session sess-123')
})
})
describe('shouldNotify — status change', () => {
test('notifies on a transition to waiting', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'waiting' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(true)
expect(decision.body).toContain('waiting')
})
test('notifies on a transition to stuck', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'stuck' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(true)
expect(decision.title).toBe('Session may be stuck')
expect(decision.body).toContain('stuck')
})
test('does NOT notify on a transition to working', () => {
const prev = snapshot({ claudeStatus: 'idle' })
const next = snapshot({ claudeStatus: 'working' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(false)
})
test('does NOT notify on a transition to idle', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'idle' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(false)
})
test('does NOT notify when the status is unchanged', () => {
const prev = snapshot({ claudeStatus: 'waiting' })
const next = snapshot({ claudeStatus: 'waiting' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(false)
})
test('is gated off by notifyOnStatusChange=false', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'waiting' })
const decision = shouldNotify(prev, next, { ...OPTS, notifyOnStatusChange: false })
expect(decision.notify).toBe(false)
})
})
describe('shouldNotify — priority', () => {
test('approval edge takes priority over a concurrent status change', () => {
// Arrange: both an approval rising edge AND a status change to waiting.
const prev = snapshot({ pendingApproval: false, claudeStatus: 'working' })
const next = snapshot({ pendingApproval: true, gate: 'plan', claudeStatus: 'waiting' })
// Act
const decision = shouldNotify(prev, next, OPTS)
// Assert: it is the approval notification, not the status one.
expect(decision.title).toBe('Approval needed')
})
test('returns a silent decision when nothing is notable', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'unknown' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision).toEqual({ notify: false, title: '', body: '' })
})
})

97
test/desktop/port.test.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* test/desktop/port.test.ts — unit tests for pickFreePort (B1).
*
* Real loopback binds (not mocks) exercise both branches: the preferred port is
* free (return it) and the preferred port is occupied (fall back to an OS port).
*/
import { describe, test, expect } from 'vitest'
import net from 'node:net'
import { pickFreePort } from '../../desktop/src/port.js'
const LOOPBACK = '127.0.0.1'
const MIN_PORT = 1
const MAX_PORT = 65535
/** Bind a server on 127.0.0.1:0 and resolve with the live server + its OS-assigned port. */
function occupyEphemeralPort(): Promise<{ server: net.Server; port: number }> {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.on('error', reject)
server.listen(0, LOOPBACK, () => {
const address = server.address()
if (address !== null && typeof address === 'object') resolve({ server, port: address.port })
else reject(new Error('no AddressInfo'))
})
})
}
function closeServer(server: net.Server): Promise<void> {
return new Promise((resolve) => server.close(() => resolve()))
}
/** Acquire a currently-free port number (bind ephemeral, then release it). */
async function getFreePort(): Promise<number> {
const { server, port } = await occupyEphemeralPort()
await closeServer(server)
return port
}
/** Confirm a port can be bound right now, then release it. */
async function isBindable(port: number): Promise<boolean> {
const server = net.createServer()
try {
await new Promise<void>((resolve, reject) => {
server.on('error', reject)
server.listen(port, LOOPBACK, () => resolve())
})
await closeServer(server)
return true
} catch {
return false
}
}
describe('pickFreePort', () => {
test('returns the preferred port when it is free', async () => {
// Arrange
const preferred = await getFreePort()
// Act
const result = await pickFreePort(preferred)
// Assert
expect(result).toBe(preferred)
})
test('returns a different, bindable port when the preferred port is occupied', async () => {
// Arrange
const { server, port: occupied } = await occupyEphemeralPort()
try {
// Act
const result = await pickFreePort(occupied)
// Assert
expect(result).not.toBe(occupied)
expect(Number.isInteger(result)).toBe(true)
expect(result).toBeGreaterThanOrEqual(MIN_PORT)
expect(result).toBeLessThanOrEqual(MAX_PORT)
expect(await isBindable(result)).toBe(true)
} finally {
await closeServer(server)
}
})
test('returns an integer within the valid TCP port range', async () => {
// Arrange
const preferred = await getFreePort()
// Act
const result = await pickFreePort(preferred)
// Assert
expect(Number.isInteger(result)).toBe(true)
expect(result).toBeGreaterThanOrEqual(MIN_PORT)
expect(result).toBeLessThanOrEqual(MAX_PORT)
})
})

218
test/desktop/prefs.test.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* test/desktop/prefs.test.ts — B3: unit tests for prefs defaults/validate/merge.
*
* Covers: defaultPrefs shape (and platform-independence); validatePrefs adopting
* valid fields and falling back per-field on wrong types / out-of-range port /
* empty shellPath / non-object raw / null / array; that it never throws; and
* mergePrefs immutability + ignoring undefined patch fields.
*/
import { describe, test, expect } from 'vitest'
import type { DesktopPrefs } from '../../desktop/src/types.js'
import { defaultPrefs, validatePrefs, mergePrefs } from '../../desktop/src/prefs.js'
const EXPECTED_DEFAULTS: DesktopPrefs = {
port: null,
lanSharing: false,
shellPath: null,
openAtLogin: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
describe('defaultPrefs', () => {
test('returns the documented baseline shape (LAN sharing off by default)', () => {
// Arrange / Act
const prefs = defaultPrefs('darwin')
// Assert
expect(prefs).toEqual(EXPECTED_DEFAULTS)
})
test('is platform-independent (same defaults across platforms)', () => {
// Arrange / Act
const darwin = defaultPrefs('darwin')
const win32 = defaultPrefs('win32')
const linux = defaultPrefs('linux')
// Assert
expect(win32).toEqual(darwin)
expect(linux).toEqual(darwin)
})
test('returns a new object each call (no shared mutable state)', () => {
// Arrange / Act
const a = defaultPrefs('darwin')
const b = defaultPrefs('darwin')
// Assert
expect(a).not.toBe(b)
})
})
describe('validatePrefs', () => {
test('adopts every field from a fully-valid object', () => {
// Arrange
const raw = {
port: 8080,
lanSharing: true,
shellPath: '/bin/bash',
openAtLogin: true,
notifyOnApproval: false,
notifyOnStatusChange: false,
}
// Act
const prefs = validatePrefs(raw, 'darwin')
// Assert
expect(prefs).toEqual(raw)
})
test('falls back to defaults when raw is not an object', () => {
// Assert — strings, numbers, booleans are not prefs objects
expect(validatePrefs('nope', 'darwin')).toEqual(EXPECTED_DEFAULTS)
expect(validatePrefs(42, 'darwin')).toEqual(EXPECTED_DEFAULTS)
expect(validatePrefs(true, 'darwin')).toEqual(EXPECTED_DEFAULTS)
})
test('falls back to defaults when raw is null', () => {
expect(validatePrefs(null, 'darwin')).toEqual(EXPECTED_DEFAULTS)
})
test('falls back to defaults when raw is undefined', () => {
expect(validatePrefs(undefined, 'darwin')).toEqual(EXPECTED_DEFAULTS)
})
test('falls back to defaults when raw is an array (arrays are not records)', () => {
expect(validatePrefs([1, 2, 3], 'darwin')).toEqual(EXPECTED_DEFAULTS)
})
test('falls back per-field on wrong types, keeping valid siblings', () => {
// Arrange — lanSharing is a number (invalid); port/shellPath are valid
const raw = {
port: 3001,
lanSharing: 1,
shellPath: '/bin/zsh',
openAtLogin: 'yes',
notifyOnApproval: null,
notifyOnStatusChange: 0,
}
// Act
const prefs = validatePrefs(raw, 'darwin')
// Assert — valid fields adopted, invalid fields fall back to defaults
expect(prefs.port).toBe(3001)
expect(prefs.shellPath).toBe('/bin/zsh')
expect(prefs.lanSharing).toBe(false)
expect(prefs.openAtLogin).toBe(false)
expect(prefs.notifyOnApproval).toBe(true)
expect(prefs.notifyOnStatusChange).toBe(true)
})
test('rejects out-of-range and non-integer ports, keeping default null', () => {
expect(validatePrefs({ port: 0 }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: 65536 }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: -1 }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: 3.5 }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: NaN }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: '3000' }, 'darwin').port).toBeNull()
})
test('accepts boundary ports (1 and 65535)', () => {
expect(validatePrefs({ port: 1 }, 'darwin').port).toBe(1)
expect(validatePrefs({ port: 65535 }, 'darwin').port).toBe(65535)
})
test('accepts an explicit null port', () => {
expect(validatePrefs({ port: null }, 'darwin').port).toBeNull()
})
test('rejects empty and whitespace-only shellPath, keeping default null', () => {
expect(validatePrefs({ shellPath: '' }, 'darwin').shellPath).toBeNull()
expect(validatePrefs({ shellPath: ' ' }, 'darwin').shellPath).toBeNull()
})
test('accepts an explicit null shellPath', () => {
expect(validatePrefs({ shellPath: null }, 'darwin').shellPath).toBeNull()
})
test('rejects a non-string shellPath, keeping default null', () => {
expect(validatePrefs({ shellPath: 123 }, 'darwin').shellPath).toBeNull()
})
test('never throws on hostile input shapes', () => {
// Assert — a grab-bag of malformed inputs must all return defaults, no throw
const hostile: unknown[] = [Symbol('x'), () => 0, new Map(), 0n]
for (const raw of hostile) {
expect(() => validatePrefs(raw, 'darwin')).not.toThrow()
expect(validatePrefs(raw, 'darwin')).toEqual(EXPECTED_DEFAULTS)
}
})
})
describe('mergePrefs', () => {
test('applies defined patch fields over the base', () => {
// Arrange
const base = defaultPrefs('darwin')
// Act
const merged = mergePrefs(base, { port: 4000, lanSharing: true })
// Assert
expect(merged.port).toBe(4000)
expect(merged.lanSharing).toBe(true)
expect(merged.shellPath).toBeNull()
})
test('ignores undefined patch fields (does not clobber base)', () => {
// Arrange
const base = mergePrefs(defaultPrefs('darwin'), { port: 5000, shellPath: '/bin/bash' })
// Act — an explicit-undefined patch field must be ignored
const merged = mergePrefs(base, { port: undefined, lanSharing: true })
// Assert
expect(merged.port).toBe(5000)
expect(merged.shellPath).toBe('/bin/bash')
expect(merged.lanSharing).toBe(true)
})
test('applies falsy-but-defined patch fields (false / null)', () => {
// Arrange
const base = mergePrefs(defaultPrefs('darwin'), { port: 6000, notifyOnApproval: true })
// Act
const merged = mergePrefs(base, { port: null, notifyOnApproval: false })
// Assert — null/false are defined values and must overwrite
expect(merged.port).toBeNull()
expect(merged.notifyOnApproval).toBe(false)
})
test('does not mutate the base object (immutability)', () => {
// Arrange
const base = defaultPrefs('darwin')
const snapshot = { ...base }
// Act
const merged = mergePrefs(base, { port: 7000 })
// Assert — base untouched, a new object returned
expect(base).toEqual(snapshot)
expect(merged).not.toBe(base)
})
test('does not mutate the patch object', () => {
// Arrange
const base = defaultPrefs('darwin')
const patch = { port: 8000 }
// Act
mergePrefs(base, patch)
// Assert
expect(patch).toEqual({ port: 8000 })
})
})

View File

@@ -0,0 +1,101 @@
/**
* test/desktop/server-config.test.ts — unit tests for buildServerEnv (B1).
*
* Covers the PORT / BIND_HOST / SHELL_PATH / USE_TMUX mapping, the immutability
* guarantee (input.env untouched), and pass-through of unrelated env vars.
*/
import { describe, test, expect } from 'vitest'
import { buildServerEnv } from '../../desktop/src/server-config.js'
import type { DesktopPrefs } from '../../desktop/src/types.js'
const BASE_PREFS: DesktopPrefs = {
port: null,
lanSharing: false,
shellPath: null,
openAtLogin: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
describe('buildServerEnv', () => {
test('sets PORT to the stringified port', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 4321, platform: 'darwin', env: {} })
expect(result.PORT).toBe('4321')
})
test('binds 127.0.0.1 when lanSharing is off', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'darwin', env: {} })
expect(result.BIND_HOST).toBe('127.0.0.1')
})
test('binds 0.0.0.0 when lanSharing is on', () => {
// Arrange
const prefs = { ...BASE_PREFS, lanSharing: true }
// Act
const result = buildServerEnv({ prefs, port: 3000, platform: 'darwin', env: {} })
// Assert
expect(result.BIND_HOST).toBe('0.0.0.0')
})
test('uses prefs.shellPath as SHELL_PATH when provided (overriding the platform default)', () => {
// Arrange
const prefs = { ...BASE_PREFS, shellPath: '/usr/bin/fish' }
// Act
const result = buildServerEnv({ prefs, port: 3000, platform: 'darwin', env: { SHELL: '/bin/zsh' } })
// Assert
expect(result.SHELL_PATH).toBe('/usr/bin/fish')
})
test('falls back to the platform default SHELL_PATH when prefs.shellPath is null', () => {
const win = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'win32', env: {} })
expect(win.SHELL_PATH).toBe('powershell.exe')
const mac = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'darwin', env: { SHELL: '/bin/zsh' } })
expect(mac.SHELL_PATH).toBe('/bin/zsh')
})
test('forces USE_TMUX off on win32 even when the ambient env enables it', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'win32', env: { USE_TMUX: '1' } })
expect(result.USE_TMUX).toBe('0')
})
test('passes ambient USE_TMUX through on non-Windows platforms', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'linux', env: { USE_TMUX: '1' } })
expect(result.USE_TMUX).toBe('1')
})
test('defaults USE_TMUX to off on non-Windows when the env is unset', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'linux', env: {} })
expect(result.USE_TMUX).toBe('0')
})
test('does not mutate the input env', () => {
// Arrange
const env = { PATH: '/usr/bin', USE_TMUX: '1' }
const snapshot = { ...env }
// Act
buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'win32', env })
// Assert
expect(env).toEqual(snapshot)
})
test('preserves unrelated ambient env vars', () => {
// Arrange
const env = { PATH: '/usr/bin', HOME: '/Users/me', CUSTOM: 'x' }
// Act
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'darwin', env })
// Assert
expect(result.PATH).toBe('/usr/bin')
expect(result.HOME).toBe('/Users/me')
expect(result.CUSTOM).toBe('x')
})
})

View File

@@ -0,0 +1,188 @@
/**
* test/desktop/settings-store.test.ts — B3: unit tests for the on-disk prefs store.
*
* Uses a REAL temp dir under os.tmpdir() (unique per test via process.pid + an
* incrementing counter — Math.random/Date.now are intentionally avoided). Covers:
* missing file → defaults (no warn); set→get round-trip; on-disk file contents;
* corrupt JSON → defaults + warn; and a write failure → error logged, merged
* result still returned. A spy Logger asserts the logging contract.
*/
import { describe, test, expect, afterEach, vi } from 'vitest'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Logger } from '../../desktop/src/logger.js'
import type { DesktopPrefs } from '../../desktop/src/types.js'
import { createSettingsStore } from '../../desktop/src/settings-store.js'
import { defaultPrefs } from '../../desktop/src/prefs.js'
const PLATFORM: NodeJS.Platform = 'darwin'
// ── unique temp-dir helper (no Math.random / Date.now) ──────────────────────────
let dirCounter = 0
const createdDirs: string[] = []
function makeTempDir(): string {
dirCounter += 1
const dir = path.join(os.tmpdir(), `web-terminal-store-test-${process.pid}-${dirCounter}`)
fs.mkdirSync(dir, { recursive: true })
createdDirs.push(dir)
return dir
}
interface SpyLogger extends Logger {
info: ReturnType<typeof vi.fn>
warn: ReturnType<typeof vi.fn>
error: ReturnType<typeof vi.fn>
}
function makeSpyLogger(): SpyLogger {
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}
afterEach(() => {
for (const dir of createdDirs) {
fs.rmSync(dir, { recursive: true, force: true })
}
createdDirs.length = 0
})
describe('createSettingsStore.get', () => {
test('returns defaults when the prefs file does not exist (and logs no warn)', () => {
// Arrange
const dir = makeTempDir()
const logger = makeSpyLogger()
const store = createSettingsStore(dir, PLATFORM, logger)
// Act
const prefs = store.get()
// Assert — missing file is the normal first-run case, not a warning
expect(prefs).toEqual(defaultPrefs(PLATFORM))
expect(logger.warn).not.toHaveBeenCalled()
})
test('returns defaults and logs a warn when the file is corrupt JSON', () => {
// Arrange
const dir = makeTempDir()
const logger = makeSpyLogger()
fs.writeFileSync(path.join(dir, 'prefs.json'), '{ not valid json', 'utf8')
const store = createSettingsStore(dir, PLATFORM, logger)
// Act
const prefs = store.get()
// Assert
expect(prefs).toEqual(defaultPrefs(PLATFORM))
expect(logger.warn).toHaveBeenCalledTimes(1)
})
test('validates untrusted on-disk fields, falling back per bad field', () => {
// Arrange — a hand-edited file with an out-of-range port and wrong-typed flag
const dir = makeTempDir()
const logger = makeSpyLogger()
fs.writeFileSync(
path.join(dir, 'prefs.json'),
JSON.stringify({ port: 999999, lanSharing: 'yes', shellPath: '/bin/bash' }),
'utf8',
)
const store = createSettingsStore(dir, PLATFORM, logger)
// Act
const prefs = store.get()
// Assert
expect(prefs.port).toBeNull()
expect(prefs.lanSharing).toBe(false)
expect(prefs.shellPath).toBe('/bin/bash')
})
})
describe('createSettingsStore.set', () => {
test('persists the patch and round-trips via a fresh store', () => {
// Arrange
const dir = makeTempDir()
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
// Act
const merged = store.set({ port: 4321, lanSharing: true })
const reread = createSettingsStore(dir, PLATFORM, makeSpyLogger()).get()
// Assert
expect(merged.port).toBe(4321)
expect(merged.lanSharing).toBe(true)
expect(reread).toEqual(merged)
})
test('merges over previously-saved prefs rather than replacing them', () => {
// Arrange
const dir = makeTempDir()
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
store.set({ port: 4321, shellPath: '/bin/zsh' })
// Act — a second partial set keeps the earlier shellPath
const merged = store.set({ lanSharing: true })
// Assert
expect(merged.port).toBe(4321)
expect(merged.shellPath).toBe('/bin/zsh')
expect(merged.lanSharing).toBe(true)
})
test('writes pretty 2-space JSON to <dir>/prefs.json', () => {
// Arrange
const dir = makeTempDir()
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
// Act
store.set({ port: 5000 })
const onDisk = fs.readFileSync(path.join(dir, 'prefs.json'), 'utf8')
const parsed: unknown = JSON.parse(onDisk)
// Assert — content matches and the file is indented (pretty-printed)
expect(parsed).toMatchObject({ port: 5000 })
expect(onDisk).toContain('\n "port": 5000')
})
test('creates the store directory if it does not yet exist (mkdir -p)', () => {
// Arrange — point at a not-yet-created nested subdir
const dir = path.join(makeTempDir(), 'nested', 'deeper')
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
// Act
store.set({ port: 6000 })
// Assert
expect(fs.existsSync(path.join(dir, 'prefs.json'))).toBe(true)
})
test('logs an error but still returns the merged prefs when the write fails', () => {
// Arrange — make the store "dir" live under a regular FILE, so mkdirSync fails
const base = makeTempDir()
const blocker = path.join(base, 'blocker')
fs.writeFileSync(blocker, 'i am a file, not a directory', 'utf8')
const unwritableDir = path.join(blocker, 'sub')
const logger = makeSpyLogger()
const store = createSettingsStore(unwritableDir, PLATFORM, logger)
// Act
const merged = store.set({ port: 7000 })
// Assert — best-effort: error surfaced, caller still gets the change
expect(logger.error).toHaveBeenCalledTimes(1)
expect(merged.port).toBe(7000)
})
test('returned merged prefs are a fully-formed DesktopPrefs', () => {
// Arrange
const dir = makeTempDir()
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
// Act
const merged: DesktopPrefs = store.set({ port: 8000 })
// Assert — every field present (defaults + patch)
expect(Object.keys(merged).sort()).toEqual(Object.keys(defaultPrefs(PLATFORM)).sort())
})
})

View File

@@ -0,0 +1,39 @@
/**
* test/desktop/shell.test.ts — unit tests for defaultShellForPlatform (B1).
*
* Covers every platform branch, with and without env.SHELL set.
*/
import { describe, test, expect } from 'vitest'
import { defaultShellForPlatform } from '../../desktop/src/shell.js'
describe('defaultShellForPlatform', () => {
test('returns powershell.exe on win32 regardless of env.SHELL', () => {
// Arrange / Act / Assert
expect(defaultShellForPlatform('win32', {})).toBe('powershell.exe')
expect(defaultShellForPlatform('win32', { SHELL: '/bin/zsh' })).toBe('powershell.exe')
})
test('returns env.SHELL on darwin when it is set', () => {
// Arrange
const env = { SHELL: '/opt/homebrew/bin/fish' }
// Act
const result = defaultShellForPlatform('darwin', env)
// Assert
expect(result).toBe('/opt/homebrew/bin/fish')
})
test('falls back to /bin/zsh on darwin when env.SHELL is unset', () => {
expect(defaultShellForPlatform('darwin', {})).toBe('/bin/zsh')
})
test('returns env.SHELL on linux when it is set', () => {
expect(defaultShellForPlatform('linux', { SHELL: '/usr/bin/bash' })).toBe('/usr/bin/bash')
})
test('falls back to /bin/bash on linux when env.SHELL is unset', () => {
expect(defaultShellForPlatform('linux', {})).toBe('/bin/bash')
})
})

View File

@@ -24,6 +24,20 @@ export default defineConfig({
'public/title-util.ts',
'public/voice-commands.ts',
'public/voice-confirm.ts',
// Desktop (Electron) shell: only the PURE logic modules are measured.
// The Electron glue (main/window/tray/menu/notifications/preload/
// embedded-server/settings-store) is thin, side-effectful wiring around
// the electron/native/dist APIs — untestable as a unit and excluded on
// the same principle as the thin DOM-wiring public/*.ts above.
'desktop/src/port.ts',
'desktop/src/shell.ts',
'desktop/src/server-config.ts',
'desktop/src/deep-link.ts',
'desktop/src/notify-policy.ts',
'desktop/src/notifications.ts',
'desktop/src/live-poll.ts',
'desktop/src/prefs.ts',
'desktop/src/settings-store.ts',
],
thresholds: {
lines: 80,