156 lines
4.7 KiB
TypeScript
156 lines
4.7 KiB
TypeScript
import {
|
|
closeSync,
|
|
fsyncSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readFileSync,
|
|
renameSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname } from "node:path";
|
|
import type { AppConfig } from "../config.js";
|
|
|
|
export interface Settings {
|
|
workspace?: string;
|
|
/** Legacy input fields are ignored when loading/evaluating installation settings. */
|
|
provider?: string;
|
|
model?: string;
|
|
thinking?: string;
|
|
}
|
|
|
|
export interface SettingsDurability {
|
|
syncDirectory(directory: string): void;
|
|
}
|
|
|
|
export interface SettingsSnapshot {
|
|
exists: boolean;
|
|
rawBase64: string;
|
|
}
|
|
|
|
/**
|
|
* Settings files are installation defaults only. Personal workspace/model/thinking choices
|
|
* belong to the browser and must never be written back here by request handlers.
|
|
*/
|
|
|
|
/** Read settings from cfg.settingsFile. Returns {} if missing or invalid. */
|
|
export function loadSettings(cfg: AppConfig): Settings {
|
|
try {
|
|
const raw = readFileSync(cfg.settingsFile, "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === "object") {
|
|
const value = parsed as Record<string, unknown>;
|
|
return {
|
|
...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}),
|
|
...(typeof value.thinking === "string" ? { thinking: value.thinking } : {}),
|
|
};
|
|
}
|
|
return {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Capture exact file existence and bytes so host-side configuration can compensate losslessly. */
|
|
export function captureSettingsSnapshot(cfg: AppConfig): SettingsSnapshot {
|
|
try {
|
|
return { exists: true, rawBase64: readFileSync(cfg.settingsFile).toString("base64") };
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return { exists: false, rawBase64: "" };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Restore a previously captured settings file exactly, including the clean absent state. */
|
|
export function restoreSettingsSnapshot(
|
|
cfg: AppConfig,
|
|
snapshot: SettingsSnapshot,
|
|
durability: SettingsDurability = defaultDurability,
|
|
): void {
|
|
if (typeof snapshot.exists !== "boolean" || typeof snapshot.rawBase64 !== "string") {
|
|
throw new Error("invalid settings snapshot");
|
|
}
|
|
const raw = Buffer.from(snapshot.rawBase64, "base64");
|
|
if (raw.toString("base64") !== snapshot.rawBase64 || (!snapshot.exists && raw.length !== 0)) {
|
|
throw new Error("invalid settings snapshot");
|
|
}
|
|
const directory = dirname(cfg.settingsFile);
|
|
mkdirSync(directory, { recursive: true });
|
|
if (snapshot.exists) {
|
|
replaceSettingsFile(cfg.settingsFile, raw);
|
|
} else {
|
|
try {
|
|
unlinkSync(cfg.settingsFile);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
durability.syncDirectory(directory);
|
|
}
|
|
|
|
/** Persist settings (pretty JSON). Creates the parent directory if needed. */
|
|
export function saveSettings(
|
|
cfg: AppConfig,
|
|
s: Settings,
|
|
durability: SettingsDurability = defaultDurability,
|
|
): Settings {
|
|
mkdirSync(dirname(cfg.settingsFile), { recursive: true });
|
|
const directory = dirname(cfg.settingsFile);
|
|
let previous: Buffer | undefined;
|
|
try {
|
|
previous = readFileSync(cfg.settingsFile);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
|
|
replaceSettingsFile(cfg.settingsFile, Buffer.from(JSON.stringify(s, null, 2) + "\n", "utf8"));
|
|
try {
|
|
durability.syncDirectory(directory);
|
|
} catch (durabilityError) {
|
|
try {
|
|
if (previous === undefined) unlinkSync(cfg.settingsFile);
|
|
else replaceSettingsFile(cfg.settingsFile, previous);
|
|
durability.syncDirectory(directory);
|
|
} catch {
|
|
throw new Error("settings durability failed and previous settings could not be restored", {
|
|
cause: durabilityError,
|
|
});
|
|
}
|
|
throw durabilityError;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
let temporarySequence = 0;
|
|
|
|
function replaceSettingsFile(path: string, contents: Buffer): void {
|
|
const temporary = `${path}.tmp-${process.pid}-${Date.now()}-${temporarySequence++}`;
|
|
const fd = openSync(temporary, "wx", 0o600);
|
|
try {
|
|
writeFileSync(fd, contents);
|
|
fsyncSync(fd);
|
|
} catch (error) {
|
|
try { closeSync(fd); } catch { /* preserve the write error */ }
|
|
try { unlinkSync(temporary); } catch { /* best effort */ }
|
|
throw error;
|
|
}
|
|
closeSync(fd);
|
|
try {
|
|
renameSync(temporary, path);
|
|
} catch (error) {
|
|
try { unlinkSync(temporary); } catch { /* best effort */ }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const defaultDurability: SettingsDurability = {
|
|
syncDirectory(directory: string): void {
|
|
// The core image runs Linux. Windows durability is owned by the host-side Go executable.
|
|
if (process.platform === "win32") return;
|
|
const dirFd = openSync(directory, "r");
|
|
try { fsyncSync(dirFd); } finally { closeSync(dirFd); }
|
|
},
|
|
};
|