feat(desktop): Electron all-in-one desktop shell (Mac/Windows) embedding the server

Add a `desktop/` Electron app that embeds the existing Node server + node-pty
(all-in-one): the window loads http://127.0.0.1:<port>/ and reuses the frontend
unchanged; other LAN devices can still connect. The server needs zero changes —
startServer(cfg)/loadConfig already support programmatic embedding.

- Pure, unit-tested modules: port, shell, server-config, deep-link, notify-policy,
  notifications, live-poll, prefs, settings-store (94 tests; desktop/src ~97% cov)
- Electron glue: main/window/tray/menu/preload/embedded-server/logger
  (hardened: contextIsolation, sandbox, deny foreign-origin navigation)
- Native value: OS notifications driven by /live-sessions status, tray, deep links
- Packaging (electron-builder -> arm64 .dmg): ships dist/ + public/ + node_modules
  on-disk under Resources so the server resolves its deps from /Applications;
  node-pty rebuilt for the Electron ABI
- Docs: docs/DESKTOP_PLAN.md; PROGRESS_LOG updated

Verified: desktop tsc clean; 1401 tests pass; coverage >=80% (desktop/src 97/95/100/97);
.dmg built and launch-tested on arm64 (server boots, UI serves 200, node-pty loads).
This commit is contained in:
Yaojia Wang
2026-07-02 06:13:17 +02:00
parent 2af57e6686
commit cf8cfccab4
39 changed files with 7781 additions and 0 deletions

104
desktop/src/prefs.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* desktop/src/prefs.ts — B3: pure prefs defaults, validation & merge.
*
* Preferences are persisted as JSON in the app's userData dir (see
* settings-store.ts). Everything read back from disk is UNTRUSTED (a user could
* hand-edit it, or an older/newer app version wrote a different shape), so
* validatePrefs re-derives a fully-formed DesktopPrefs field-by-field, falling
* back to the default whenever a field is the wrong type or out of range — it
* never throws. Validation is hand-rolled (no zod), matching src/config.ts.
*
* All functions are pure and return NEW objects (immutability — coding-style
* CRITICAL rule); they never mutate their arguments.
*/
import type { DesktopPrefs } from './types.js'
/** Valid TCP port range (inclusive); mirrors src/config.ts's PORT bounds. */
const MIN_PORT = 1
const MAX_PORT = 65535
/**
* The baseline preferences for a fresh install. LAN sharing is OFF by default —
* this app hands a full shell to anyone who can reach the port, so binding to
* 127.0.0.1 (private) is the safe default; LAN exposure is an explicit opt-in.
*
* `platform` is accepted for future per-platform defaults; today's defaults are
* platform-independent, but keeping the parameter avoids a signature change
* later (callers already thread the platform through).
*/
export function defaultPrefs(platform: NodeJS.Platform): DesktopPrefs {
void platform // reserved for future per-platform defaults (see doc-comment)
return {
port: null,
lanSharing: false,
shellPath: null,
openAtLogin: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
}
/** True iff `value` is a plain object (not null, not an array) we can index. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Adopt a boolean field, else fall back — anything non-boolean is rejected. */
function pickBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
}
/** Adopt an integer port in [MIN_PORT, MAX_PORT] or an explicit null, else fall back. */
function pickPort(value: unknown, fallback: number | null): number | null {
if (value === null) return null
if (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= MIN_PORT &&
value <= MAX_PORT
) {
return value
}
return fallback
}
/** Adopt a non-empty (non-whitespace) string path or an explicit null, else fall back. */
function pickShellPath(value: unknown, fallback: string | null): string | null {
if (value === null) return null
if (typeof value === 'string' && value.trim() !== '') return value
return fallback
}
/**
* Re-derive a fully-formed DesktopPrefs from untrusted parsed-JSON input.
* Starts from defaults and adopts each field only when it is the right type and
* valid; a non-object `raw` (or any bad field) yields the defaults. Never throws.
*/
export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopPrefs {
const defaults = defaultPrefs(platform)
if (!isRecord(raw)) return defaults
return {
port: pickPort(raw['port'], defaults.port),
lanSharing: pickBoolean(raw['lanSharing'], defaults.lanSharing),
shellPath: pickShellPath(raw['shellPath'], defaults.shellPath),
openAtLogin: pickBoolean(raw['openAtLogin'], defaults.openAtLogin),
notifyOnApproval: pickBoolean(raw['notifyOnApproval'], defaults.notifyOnApproval),
notifyOnStatusChange: pickBoolean(raw['notifyOnStatusChange'], defaults.notifyOnStatusChange),
}
}
/**
* Return a shallow copy of `patch` with `undefined`-valued fields removed, so a
* partial update never clobbers an existing pref with `undefined` (e.g. a form
* that only sends the fields it changed).
*/
function definedFieldsOf(patch: Partial<DesktopPrefs>): Partial<DesktopPrefs> {
const entries = Object.entries(patch).filter(([, value]) => value !== undefined)
return Object.fromEntries(entries) as Partial<DesktopPrefs>
}
/** Immutably merge a partial patch over a base, ignoring undefined patch fields. */
export function mergePrefs(base: DesktopPrefs, patch: Partial<DesktopPrefs>): DesktopPrefs {
return { ...base, ...definedFieldsOf(patch) }
}