Files
web-terminal/desktop/src/live-poll.ts
Yaojia Wang cf8cfccab4 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).
2026-07-02 06:13:17 +02:00

76 lines
2.8 KiB
TypeScript

/**
* 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)
}