Merge feat/desktop-electron: Electron all-in-one desktop shell (Mac/Windows)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
BIN
desktop/assets/trayTemplate.png
Normal file
BIN
desktop/assets/trayTemplate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
desktop/assets/trayTemplate@2x.png
Normal file
BIN
desktop/assets/trayTemplate@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
24
desktop/build.mjs
Normal file
24
desktop/build.mjs
Normal 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' })
|
||||
64
desktop/electron-builder.yml
Normal file
64
desktop/electron-builder.yml
Normal 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
4936
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
desktop/package.json
Normal file
33
desktop/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
desktop/resources/README.md
Normal file
13
desktop/resources/README.md
Normal 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
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
73
desktop/src/deep-link.ts
Normal 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
|
||||
}
|
||||
72
desktop/src/embedded-server.ts
Normal file
72
desktop/src/embedded-server.ts
Normal 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
75
desktop/src/live-poll.ts
Normal 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
32
desktop/src/logger.ts
Normal 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
251
desktop/src/main.ts
Normal 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
53
desktop/src/menu.ts
Normal 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)
|
||||
}
|
||||
45
desktop/src/notifications.ts
Normal file
45
desktop/src/notifications.ts
Normal 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 }
|
||||
}
|
||||
105
desktop/src/notify-policy.ts
Normal file
105
desktop/src/notify-policy.ts
Normal 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
63
desktop/src/port.ts
Normal 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
104
desktop/src/prefs.ts
Normal 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
19
desktop/src/preload.ts
Normal 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))
|
||||
},
|
||||
})
|
||||
43
desktop/src/server-config.ts
Normal file
43
desktop/src/server-config.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
82
desktop/src/settings-store.ts
Normal file
82
desktop/src/settings-store.ts
Normal 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
28
desktop/src/shell.ts
Normal 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
32
desktop/src/tray.ts
Normal 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
77
desktop/src/types.ts
Normal 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
54
desktop/src/window.ts
Normal 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
18
desktop/tsconfig.json
Normal 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
286
docs/DESKTOP_PLAN.md
Normal 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`)** —— P0–P2 的逻辑代码、单测、Electron 加固、esbuild 打包在本环境全部通过(桌面 `tsc` 0 错、全量 1401 tests 绿、覆盖率达标)。**未验证(需真机/CI,见 §9 P1 D5–D7)**:实际启动 Electron GUI、node-pty 的 Electron-ABI 重编译、`.dmg`/`.exe` 打包。详见 `PROGRESS_LOG.md` 首条。本文是"怎么做"的蓝图,配合 `TECH_DOC.md`(why)+ `ARCHITECTURE.md`(how)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 目标与范围
|
||||
|
||||
### 做什么
|
||||
|
||||
一个 Mac / Windows 原生桌面 App,双击即用:
|
||||
|
||||
- 窗口里是**完全复用的现有前端**(xterm.js 多标签 UI,0 改动)。
|
||||
- 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-pty(native 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.js(0 改动) │ │
|
||||
│ │ 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 服务隔离方式,可加载原生模块)把服务器挪出 main,IPC 转发端口即可。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 # contextBridge:renderer ↔ 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.icns(Mac)/ icon.ico(Win)/ 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',
|
||||
// ★ 跨平台默认 shell:config.ts 默认 $SHELL||/bin/zsh,Windows 上必须覆盖
|
||||
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-only,localhost 内嵌完全满足。
|
||||
|
||||
---
|
||||
|
||||
## 5. node-pty 打包(唯一的原生模块难点)
|
||||
|
||||
- **ABI 重编译**:node-pty 是 native `.node` addon,Electron 的 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`(可 universal:arm64+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 / 验证方式 / 估时。P0–P1 出"能跑的一体化 App",P2 加原生价值,P3 分发。
|
||||
|
||||
### P0 — 骨架跑通("它启动了")· ~1 天
|
||||
- **D1** `desktop/` 脚手架:package.json(electron/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 localhost,contextIsolation 加固。*验证*:窗口里出现现有终端 UI,能开标签、跑 shell、WS 连通(前端 0 改动)。
|
||||
- **D4** 生命周期:`before-quit` → `server.close()`;单实例锁。*验证*:退出无残留 pty 进程。
|
||||
|
||||
### P1 — 跨平台真安装包 · ~2–4 天
|
||||
- **D5** node-pty 对 Electron ABI 重编译(`install-app-deps`)+ `asarUnpack`。*验证*:打包后的 App(非 dev)里 pty 正常 spawn。
|
||||
- **D6** electron-builder:Mac `.dmg`。*验证*:装到干净 Mac,双击可用。
|
||||
- **D7** electron-builder:Win `.exe` + Windows 默认 shell/ConPTY/USE_TMUX=0。*验证*:装到 Win10+,PowerShell 会话可用,项目发现功能可用。
|
||||
|
||||
### P2 — 原生集成(相对浏览器标签的价值) · ~2–3 天
|
||||
- **D8** 原生通知:审批门/Claude 状态 → Electron Notification(先快路,再接 main 状态总线覆盖全会话)。*验证*:窗口最小化时 Claude 待审批弹系统通知,点通知回到会话。
|
||||
- **D9** 托盘 + 聚合状态 + 上下文菜单。
|
||||
- **D10** 应用菜单/快捷键映射 + 深链 `terminalapp://` + 开机自启 + 设置页(端口/LAN 开关/默认 shell/自启,electron-store)。
|
||||
|
||||
### P3 — 分发打磨(可选) · ~1–3 天
|
||||
- **D11** 代码签名 + 公证(Mac)/ Authenticode(Win)。
|
||||
- **D12** electron-updater 自动更新(GitHub Releases)。
|
||||
|
||||
**合计**:能跑的一体化 App(P0+P1)≈ **3–5 天**;加满原生价值(P2)再 **2–3 天**;分发签名(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>
|
||||
@@ -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 D5–D7)**: `.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 行待拆分。
|
||||
|
||||
|
||||
96
test/desktop/deep-link.test.ts
Normal file
96
test/desktop/deep-link.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
134
test/desktop/live-poll.test.ts
Normal file
134
test/desktop/live-poll.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
113
test/desktop/notifications.test.ts
Normal file
113
test/desktop/notifications.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
188
test/desktop/notify-policy.test.ts
Normal file
188
test/desktop/notify-policy.test.ts
Normal 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
97
test/desktop/port.test.ts
Normal 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
218
test/desktop/prefs.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
101
test/desktop/server-config.test.ts
Normal file
101
test/desktop/server-config.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
188
test/desktop/settings-store.test.ts
Normal file
188
test/desktop/settings-store.test.ts
Normal 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())
|
||||
})
|
||||
})
|
||||
39
test/desktop/shell.test.ts
Normal file
39
test/desktop/shell.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user