| 1 | import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 2 | import { dirname, join } from "node:path"; |
| 3 | import type { BrowserControlState, BrowserControlWarning } from "../shared/ipc.js"; |
| 4 | |
| 5 | export interface BrowserControlBootstrap { |
| 6 | state: BrowserControlState; |
| 7 | configPath: string; |
| 8 | } |
| 9 | |
| 10 | export interface BrowserControlPatch { |
| 11 | controlEnabled?: boolean; |
| 12 | ignoreCertificateErrors?: boolean; |
| 13 | } |
| 14 | |
| 15 | type Stored = { version: 1; controlEnabled: boolean; ignoreCertificateErrors: boolean }; |
| 16 | |
| 17 | // Cache-only clearing keeps cookies and local storage, so a signed-in site stays |
| 18 | // signed in; "all" additionally drops every storage type Electron knows. |
| 19 | export const BROWSER_CACHE_STORAGES: readonly string[] = ["shadercache", "cachestorage", "serviceworkers"]; |
| 20 | |
| 21 | // The subset of Electron's Session the browser policy needs. Structural so the |
| 22 | // unit tests can drive it with a fake instead of a live shell. |
| 23 | export interface BrowserSession { |
| 24 | setCertificateVerifyProc(proc: ((request: unknown, callback: (result: number) => void) => void) | null): void; |
| 25 | clearCache(): Promise<void>; |
| 26 | clearStorageData(options?: { storages?: string[] }): Promise<void>; |
| 27 | } |
| 28 | |
| 29 | function parseStored(path: string): { value: Stored | null; warning: BrowserControlWarning | null } { |
| 30 | if (!existsSync(path)) return { value: null, warning: null }; |
| 31 | try { |
| 32 | const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>; |
| 33 | if (parsed.version !== 1) return { value: null, warning: "unsupported-version" }; |
| 34 | if (typeof parsed.controlEnabled !== "boolean" || typeof parsed.ignoreCertificateErrors !== "boolean") { |
| 35 | return { value: null, warning: "invalid-config" }; |
| 36 | } |
| 37 | return { value: parsed as Stored, warning: null }; |
| 38 | } catch { |
| 39 | return { value: null, warning: "unreadable-config" }; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | function defaultStored(): Stored { |
| 44 | // Control defaults on: the desktop browser is already the agent's capability, |
| 45 | // so hiding it behind an opt-in would change existing behaviour. |
| 46 | return { version: 1, controlEnabled: true, ignoreCertificateErrors: false }; |
| 47 | } |
| 48 | |
| 49 | export function loadBrowserControlBootstrap(dataHome: string): BrowserControlBootstrap { |
| 50 | const configPath = join(dataHome, "browser-control.json"); |
| 51 | const parsed = parseStored(configPath); |
| 52 | const stored = parsed.value ?? defaultStored(); |
| 53 | return { |
| 54 | configPath, |
| 55 | state: { |
| 56 | controlEnabled: stored.controlEnabled, |
| 57 | ignoreCertificateErrors: stored.ignoreCertificateErrors, |
| 58 | writable: parsed.warning !== "unsupported-version", |
| 59 | warning: parsed.warning, |
| 60 | }, |
| 61 | }; |
| 62 | } |
| 63 | |
| 64 | export class BrowserControlStore { |
| 65 | private stored: Stored; |
| 66 | private state: BrowserControlState; |
| 67 | private writeQueue: Promise<void> = Promise.resolve(); |
| 68 | |
| 69 | constructor(private readonly path: string, bootstrap: BrowserControlBootstrap) { |
| 70 | this.stored = parseStored(path).value ?? defaultStored(); |
| 71 | this.state = bootstrap.state; |
| 72 | } |
| 73 | |
| 74 | get current(): BrowserControlState { |
| 75 | return this.state; |
| 76 | } |
| 77 | |
| 78 | setControlEnabled(enabled: boolean): Promise<BrowserControlState> { |
| 79 | return this.patch({ controlEnabled: enabled }); |
| 80 | } |
| 81 | |
| 82 | setIgnoreCertificateErrors(enabled: boolean): Promise<BrowserControlState> { |
| 83 | return this.patch({ ignoreCertificateErrors: enabled }); |
| 84 | } |
| 85 | |
| 86 | private async patch(patch: BrowserControlPatch): Promise<BrowserControlState> { |
| 87 | for (const [key, value] of Object.entries(patch)) { |
| 88 | if (typeof value !== "boolean") throw new Error(`${key} must be boolean`); |
| 89 | } |
| 90 | if (!this.state.writable) throw new Error("browser control settings are read-only"); |
| 91 | const next: Stored = { ...this.stored, ...patch, version: 1 }; |
| 92 | const write = this.writeQueue.catch(() => undefined).then(() => { |
| 93 | mkdirSync(dirname(this.path), { recursive: true }); |
| 94 | const existing = parseStored(this.path); |
| 95 | if (existing.warning) { |
| 96 | let backup = `${this.path}.invalid`; |
| 97 | let suffix = 1; |
| 98 | while (existsSync(backup)) backup = `${this.path}.invalid.${suffix++}`; |
| 99 | renameSync(this.path, backup); |
| 100 | } |
| 101 | const temp = `${this.path}.tmp`; |
| 102 | writeFileSync(temp, `${JSON.stringify(next)}\n`, "utf8"); |
| 103 | renameSync(temp, this.path); |
| 104 | }); |
| 105 | this.writeQueue = write.catch(() => undefined); |
| 106 | await write; |
| 107 | this.stored = next; |
| 108 | this.state = { ...this.state, ...patch, warning: null }; |
| 109 | return this.state; |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // Certificate policy belongs to the browser partition alone; the app window and |
| 114 | // every other Electron session keep Chromium's default verification. |
| 115 | export function applyBrowserSessionPolicy(target: BrowserSession, ignoreCertificateErrors: boolean): void { |
| 116 | if (!ignoreCertificateErrors) { |
| 117 | target.setCertificateVerifyProc(null); |
| 118 | return; |
| 119 | } |
| 120 | target.setCertificateVerifyProc((_request, callback) => callback(0)); |
| 121 | } |
| 122 | |
| 123 | export async function clearBrowserCache(target: BrowserSession): Promise<void> { |
| 124 | await target.clearCache(); |
| 125 | await target.clearStorageData({ storages: [...BROWSER_CACHE_STORAGES] }); |
| 126 | } |
| 127 | |
| 128 | export async function clearBrowserData(target: BrowserSession): Promise<void> { |
| 129 | await target.clearCache(); |
| 130 | await target.clearStorageData(); |
| 131 | } |
| 132 |