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).
76 lines
2.8 KiB
TypeScript
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)
|
|
}
|