/** * 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 { 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): Partial { const entries = Object.entries(patch).filter(([, value]) => value !== undefined) return Object.fromEntries(entries) as Partial } /** Immutably merge a partial patch over a base, ignoring undefined patch fields. */ export function mergePrefs(base: DesktopPrefs, patch: Partial): DesktopPrefs { return { ...base, ...definedFieldsOf(patch) } }