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

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
}