also looking at this
fix(app): a settings.json this build cannot read no longer loops the app
#46
4 files
+130
-22
| @@ -47,20 +47,57 @@ export const DEFAULT_SETTINGS: Settings = { | ||
| 47 | 47 | paused: false, |
| 48 | 48 | } |
| 49 | 49 | |
| 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 | */ | |
| 58 | const 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 | */ | |
| 75 | export 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 | */ | |
| 50 | 93 | export function readSettings(path: string = SETTINGS_PATH): Settings { |
| 51 | 94 | if (!existsSync(path)) |
| 52 | 95 | return { ...DEFAULT_SETTINGS } |
| 53 | 96 | 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'))) | |
| 61 | 98 | } |
| 62 | 99 | catch { |
| 63 | // A hand-edited file with a typo must not stop the app from starting. | |
| 100 | // Not JSON at all. | |
| 64 | 101 | return { ...DEFAULT_SETTINGS } |
| 65 | 102 | } |
| 66 | 103 | } |