/** * Config-file persistence: atomic reads/writes of `~/.pi/rinco-pi-hud.json`. * * `mutateConfig` is the single choke point every `save*Patch` function in * `persist.ts` goes through: it loads the current on-disk record (refusing * to touch a corrupt file), applies a synchronous mutation, writes the * result via a temp-file + fsync + rename (so a crash mid-write cannot * leave a truncated/corrupt config on disk), and returns the freshly merged * `PolishedTuiConfig`. */ import { randomUUID } from "node:crypto"; import { closeSync, existsSync, fchmodSync, fsyncSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { basename, dirname, join } from "node:path"; import { isRecord, mergeConfig } from "./normalize.js"; import { configPath, type PolishedTuiConfig } from "./schema.js"; export type ConfigRecord = Record; type ConfigFileState = | { kind: "missing"; record: ConfigRecord; writePath: string } | { kind: "valid"; record: ConfigRecord; writePath: string; mode: number } | { kind: "corrupt"; error: unknown }; function errorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? String(error.code) : undefined; } function readConfigFileState(path: string): ConfigFileState { let writePath = path; try { const pathStat = lstatSync(path); if (pathStat.isSymbolicLink()) writePath = realpathSync(path); const targetStat = statSync(writePath); const parsed = JSON.parse(readFileSync(writePath, "utf8")); return isRecord(parsed) ? { kind: "valid", record: parsed, writePath, mode: targetStat.mode & 0o7777 } : { kind: "corrupt", error: new Error("top-level value must be a JSON object") }; } catch (error) { if (errorCode(error) === "ENOENT") { try { lstatSync(path); } catch (pathError) { if (errorCode(pathError) === "ENOENT") return { kind: "missing", record: {}, writePath: path }; } } return { kind: "corrupt", error }; } } function writeConfigAtomically(path: string, record: ConfigRecord, mode?: number): void { const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); let file: number | undefined; try { file = openSync(tempPath, "wx", mode ?? 0o666); if (mode !== undefined) fchmodSync(file, mode); writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, "utf8"); fsyncSync(file); closeSync(file); file = undefined; renameSync(tempPath, path); } catch (error) { if (file !== undefined) { try { closeSync(file); } catch {} } try { unlinkSync(tempPath); } catch (cleanupError) { if (errorCode(cleanupError) !== "ENOENT") { // Preserve the persistence failure; the best-effort cleanup error is secondary. } } throw error; } } export function mutateConfig( path: string, mutate: (record: ConfigRecord) => void, ): PolishedTuiConfig { const state = readConfigFileState(path); if (state.kind === "corrupt") { const detail = state.error instanceof Error ? ` (${state.error.message})` : ""; throw new Error( `Refusing to save Zentui config because ${path} is corrupt or unreadable; fix or remove it first.${detail}`, ); } mutate(state.record); writeConfigAtomically( state.writePath, state.record, state.kind === "valid" ? state.mode : undefined, ); return mergeConfig(state.record); } export function ensureConfigExists(): void { // Intentionally left as a no-op. Zentui config is user-owned and // compatibility-sensitive: runtime defaults come from `mergeConfig({})`, and // the extension should not persist opinionated defaults unless the user // explicitly changes a setting. } export function loadConfig(): PolishedTuiConfig { try { if (!existsSync(configPath)) return mergeConfig({}); return mergeConfig(JSON.parse(readFileSync(configPath, "utf8"))); } catch { return mergeConfig({}); } }