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).
105 lines
4.2 KiB
TypeScript
105 lines
4.2 KiB
TypeScript
/**
|
|
* 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) }
|
|
}
|