diff options
Diffstat (limited to 'src/cdp/target-store.ts')
| -rw-r--r-- | src/cdp/target-store.ts | 87 |
1 files changed, 87 insertions, 0 deletions
diff --git a/src/cdp/target-store.ts b/src/cdp/target-store.ts new file mode 100644 index 0000000..3a35f0a --- /dev/null +++ b/src/cdp/target-store.ts @@ -0,0 +1,87 @@ +/** + * FORK: durable persistence for the chosen CDP target. + * + * `/browser-target` has to outlive the pi session that ran it, because the + * browser daemon is machine-wide and shared by every session — so the choice + * cannot live in pi's session entries. Same reasoning, and the same atomic + * write, as the profile pin in src/profile/store.ts; a separate file so a + * corrupt target cannot cost you your profile selection. + * + * The stored value is the raw string a user would have put in BU_CDP_HTTP + * ("local", "10.88.0.25:9223", …). It is deliberately NOT pre-parsed: keeping + * it in the same vocabulary as the environment variable means one resolution + * path in src/cdp/endpoint.ts, and a stored value can never mean something the + * variable could not. + * + * Every read failure degrades to "nothing stored" rather than throwing — a + * corrupt file must never break browser control, it should just fall back to + * the built-in default. + */ + +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { dirname } from "node:path"; +import { type Result, err, ok } from "../util/result"; +import { isRecord } from "../util/guards"; +import { targetFilePath } from "../profile/paths"; + +const CURRENT_VERSION = 1; + +type TargetFile = { + readonly version: 1; + /** Raw BU_CDP_HTTP-style value, or null when cleared. */ + readonly target: string | null; + readonly savedAt: string; +}; + +/** + * The persisted target, or null when nothing is stored, the file is missing, or + * it cannot be parsed. A file written by a newer version is treated as "nothing + * stored" rather than guessed at. + */ +export const readTarget = async (): Promise<string | null> => { + let raw: string; + try { + raw = await readFile(targetFilePath(), "utf8"); + } catch { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) return null; + const file = parsed; + if (file["version"] !== CURRENT_VERSION) return null; + const target = file["target"]; + if (typeof target !== "string") return null; + const trimmed = target.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +}; + +/** + * Replace the target atomically: a sibling temp file plus rename, so a crash + * mid-write cannot leave a half-written target behind — which would otherwise + * silently point the agent at the wrong browser. + */ +const writeTargetFile = async (target: string | null): Promise<Result<void, string>> => { + const path = targetFilePath(); + const tmp = `${path}.${randomUUID()}.tmp`; + const payload: TargetFile = { version: CURRENT_VERSION, target, savedAt: new Date().toISOString() }; + try { + await mkdir(dirname(path), { recursive: true }); + await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await rename(tmp, path); + return ok(undefined); + } catch (e) { + await unlink(tmp).catch(() => {}); + return err(`could not save browser target to ${path}: ${e instanceof Error ? e.message : String(e)}`); + } +}; + +/** Persist the chosen target. */ +export const writeTarget = (target: string): Promise<Result<void, string>> => writeTargetFile(target); + +/** Clear the choice, restoring the built-in default. */ +export const clearTarget = (): Promise<Result<void, string>> => writeTargetFile(null); |
