ReviewOS

also looking at this

stacks/uplink

fix(app): a settings.json this build cannot read no longer loops the app

#46
Merged glennmichael123 wants to merge fix/settings-fallbacks into main
4 files +130 -22
app/Uplink/settings.tsmodified+45-8
Changes to app/Uplink/settings.ts
@@ -47,20 +47,57 @@ export const DEFAULT_SETTINGS: Settings = {
4747 paused: false,
4848}
4949
50/**
51 * One test per field, `undefined` meaning "not a value this build can use".
52 *
53 * Both directions go through these: reading a file written by a hand, an older
54 * build or a half-finished save, and taking a field from the popover. Writing
55 * the checks twice is how the two drift, and it is the read path that cannot
56 * afford to be wrong: it runs before the menubar exists.
57 */
58const FIELDS: { [K in keyof Settings]: (value: unknown) => Settings[K] | undefined } = {
59 allowed: value => Array.isArray(value) ? value.map(String).map(handle => handle.trim()).filter(Boolean) : undefined,
60 engine: value => typeof value === 'string' && isEngineId(value) ? value : undefined,
61 model: value => value === null || (typeof value === 'string' && value !== '') ? (value as string | null) : undefined,
62 workdir: value => typeof value === 'string' && value !== '' ? value : undefined,
63 openAtLogin: value => typeof value === 'boolean' ? value : undefined,
64 paused: value => typeof value === 'boolean' ? value : undefined,
65}
66
67/**
68 * Every field of `input` this build can use, and `base` for every field it
69 * cannot. Never throws, whatever `input` is.
70 *
71 * `base` is what distinguishes the two callers: the defaults when reading a
72 * file, which must always produce usable settings, and the current settings
73 * when taking a partial update from the popover.
74 */
75export function coerceSettings(input: unknown, base: Settings = DEFAULT_SETTINGS): Settings {
76 const fields = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
77 const settings = { ...base }
78 for (const key of Object.keys(FIELDS) as Array<keyof Settings>) {
79 const value = FIELDS[key](fields[key])
80 if (value !== undefined)
81 Object.assign(settings, { [key]: value })
82 }
83 return settings
84}
85
86/**
87 * Always usable settings, because this is read at the desktop agent's first
88 * statements: a throw here is a launchd restart loop, and the menubar that
89 * would have explained it never starts. Only `engine` used to be checked, so
90 * an `allowed` that was not an array reached `settingsEnv` and took the app
91 * down on every launch, invisibly.
92 */
5093export function readSettings(path: string = SETTINGS_PATH): Settings {
5194 if (!existsSync(path))
5295 return { ...DEFAULT_SETTINGS }
5396 try {
54 const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<Settings>
55 const settings = { ...DEFAULT_SETTINGS, ...parsed }
56 // A hand-edited or downgraded file can name an engine this build does not
57 // have. Falling back beats refusing to start.
58 if (!isEngineId(String(settings.engine)))
59 settings.engine = DEFAULT_SETTINGS.engine
60 return settings
97 return coerceSettings(JSON.parse(readFileSync(path, 'utf8')))
6198 }
6299 catch {
63 // A hand-edited file with a typo must not stop the app from starting.
100 // Not JSON at all.
64101 return { ...DEFAULT_SETTINGS }
65102 }
66103}