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:
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) }
|
||||
}
|
||||
Reference in New Issue
Block a user