feat(desktop): Electron all-in-one desktop shell (Mac/Windows) embedding the server
Add a `desktop/` Electron app that embeds the existing Node server + node-pty (all-in-one): the window loads http://127.0.0.1:<port>/ and reuses the frontend unchanged; other LAN devices can still connect. The server needs zero changes — startServer(cfg)/loadConfig already support programmatic embedding. - Pure, unit-tested modules: port, shell, server-config, deep-link, notify-policy, notifications, live-poll, prefs, settings-store (94 tests; desktop/src ~97% cov) - Electron glue: main/window/tray/menu/preload/embedded-server/logger (hardened: contextIsolation, sandbox, deny foreign-origin navigation) - Native value: OS notifications driven by /live-sessions status, tray, deep links - Packaging (electron-builder -> arm64 .dmg): ships dist/ + public/ + node_modules on-disk under Resources so the server resolves its deps from /Applications; node-pty rebuilt for the Electron ABI - Docs: docs/DESKTOP_PLAN.md; PROGRESS_LOG updated Verified: desktop tsc clean; 1401 tests pass; coverage >=80% (desktop/src 97/95/100/97); .dmg built and launch-tested on arm64 (server boots, UI serves 200, node-pty loads).
This commit is contained in:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user