diff --git a/README.md b/README.md index f68d5e1..8d1931e 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ chmod +x T4-Code-0.1.30-linux-x86_64.AppImage - **Sessions.** Browse sessions grouped by their working folder, create new ones, and switch between them. Rename, terminate a stuck runtime, archive, restore, or permanently delete a session from its menu. Recently used sessions stay warm, so switching back is instant and nothing is replayed twice. - **Composer.** Send prompts, use slash commands (`/model`, `/compact`, `/retry`, `/review`, `/terminal`, and more), and change the session's model, thinking level, or fast mode inline. - **Panes.** Watch subagents (and cancel them), apply reviews, browse and preview files on the host, and attach to live terminals with real keyboard input and resize. -- **Browser preview.** Open session-linked browser previews to inspect page layouts, follow live navigations, and interact with the page via coordinate-mapped clicks and keyboard input. Previews use pluggable authority gates, lease-based concurrency locks, and strict opt-in security boundaries. +- **Browser (desktop).** The built-in native Browser workspace is separate from host-backed Browser Preview. It manages stable native surfaces with their own URL, title, lifecycle, bounds, and visibility state. New tabs use the credential-isolated `isolated-session` profile. An authenticated profile is never auto-selected: use requires the exact profile explicitly chosen by the user with opt-in. Browser automation is limited to the native surface contract; touch input currently reports unsupported. +- **Browser preview.** Open session-linked host previews to inspect page layouts, follow live navigations, and interact with the page via coordinate-mapped clicks and keyboard input. Preview control remains subject to the host's advertised authority and capability gates. - **Settings.** Edit host settings over the wire, with an explicit host selector when several hosts are connected; each host keeps its own drafts. Edits stage locally and only apply when the host confirms; a dropped connection never silently writes anything. - **Hosts & usage.** Run one local appserver per OMP profile, pair remote machines, and read each connected host's account usage and broker status. Everything shown is redacted host truth. - **Keyboard.** `Ctrl/Cmd+K` search, `Ctrl/Cmd+B` sidebar, `Ctrl/Cmd+1..9` session switch, `Ctrl/Cmd+,` settings. Every workflow is keyboard-operable. diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 3f55f46..fe6177c 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,17 +1,131 @@ import { spawn } from "node:child_process"; import { createRequire } from "node:module"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; -const require = createRequire(import.meta.url); -const electron = process.env.ELECTRON_BIN ?? require("electron"); -const cwd = join(import.meta.dirname, ".."); -const child = spawn(electron, [join(cwd, "dist-electron", "main.cjs")], { - cwd, - env: { ...process.env, ELECTRON_RUN_AS_NODE: "0" }, - stdio: "inherit", - shell: false, -}); -child.on("exit", (code, signal) => { - if (signal !== null) process.kill(process.pid, signal); - else process.exit(code ?? 1); -}); +const rendererStartupTimeoutMs = 30_000; +const rendererPollIntervalMs = 100; + +export function sanitizeEnvironment(environment = process.env) { + const sanitized = { ...environment }; + delete sanitized.ELECTRON_RUN_AS_NODE; + return sanitized; +} + +export function validateLoopbackRendererUrl(value) { + if (value === undefined) return undefined; + + let url; + try { + url = new URL(value); + } catch { + throw new Error("OMP_DESKTOP_RENDERER_URL must be a loopback HTTP URL"); + } + + const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]"; + if ((url.protocol !== "http:" && url.protocol !== "https:") || !loopback) { + throw new Error("OMP_DESKTOP_RENDERER_URL must be a loopback HTTP URL"); + } + + return url; +} + +export async function waitForRenderer(value, { + fetchImpl = globalThis.fetch, + now = Date.now, + sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + timeoutMs = rendererStartupTimeoutMs, + intervalMs = rendererPollIntervalMs, +} = {}) { + const url = validateLoopbackRendererUrl(value); + if (url === undefined) return; + + const deadline = now() + timeoutMs; + while (true) { + try { + const response = await fetchImpl(url, { method: "HEAD" }); + if (response.ok) return; + } catch { + // The renderer process is still starting. + } + + const remaining = deadline - now(); + if (remaining <= 0) break; + await sleep(Math.min(intervalMs, remaining)); + } + + throw new Error(`Renderer did not become ready at ${url.origin}`); +} + +export function startElectron({ + cwd = join(import.meta.dirname, ".."), + electron, + environment = process.env, + spawnProcess = spawn, + processRef = process, +} = {}) { + const require = createRequire(import.meta.url); + const executable = electron ?? environment.ELECTRON_BIN ?? require("electron"); + const child = spawnProcess(executable, [join(cwd, "dist-electron", "main.cjs")], { + cwd, + env: sanitizeEnvironment(environment), + stdio: "inherit", + shell: false, + }); + + return new Promise((resolve, reject) => { + let settled = false; + let forwarded = false; + + const terminate = (signal) => { + if (!settled && !child.killed) child.kill(signal); + }; + const forwardSignal = (signal) => { + if (forwarded) return; + forwarded = true; + terminate(signal); + }; + const onSigint = () => forwardSignal("SIGINT"); + const onSigterm = () => forwardSignal("SIGTERM"); + const onProcessExit = () => terminate("SIGTERM"); + const cleanup = () => { + processRef.removeListener("SIGINT", onSigint); + processRef.removeListener("SIGTERM", onSigterm); + processRef.removeListener("exit", onProcessExit); + }; + const settle = (callback) => { + if (settled) return; + settled = true; + cleanup(); + callback(); + }; + + processRef.once("SIGINT", onSigint); + processRef.once("SIGTERM", onSigterm); + processRef.once("exit", onProcessExit); + child.once("error", (error) => settle(() => reject(error))); + child.once("exit", (code, signal) => settle(() => resolve({ code, signal }))); + }); +} + +export async function main(options = {}) { + const environment = sanitizeEnvironment(options.environment); + await waitForRenderer(environment.OMP_DESKTOP_RENDERER_URL, options); + return startElectron({ ...options, environment }); +} + +function isExecutedDirectly() { + return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; +} + +if (isExecutedDirectly()) { + main().then( + ({ code }) => { + process.exitCode = code ?? 1; + }, + (error) => { + console.error(error); + process.exitCode = 1; + }, + ); +} diff --git a/apps/desktop/src/browser-auth.ts b/apps/desktop/src/browser-auth.ts new file mode 100644 index 0000000..a9e3b45 --- /dev/null +++ b/apps/desktop/src/browser-auth.ts @@ -0,0 +1,163 @@ +import type { AuthInfo, WebContents } from "electron"; + +type BrowserCancelableEvent = { readonly preventDefault: () => void }; + +const MAX_QUEUE = 32; +const MAX_TEXT_BYTES = 512; +const MAX_URL_BYTES = 8_192; +const DEFAULT_TIMEOUT_MS = 30_000; + +export interface BrowserAuthChallenge { + readonly url: string; + readonly host: string; + readonly port: number; + readonly realm: string; + readonly scheme: string; + readonly isProxy: boolean; + readonly retry: boolean; +} + +export interface BrowserAuthCredentials { + readonly username: string; + readonly password: string; +} + +export interface BrowserAuthControllerOptions { + readonly resolve: (challenge: BrowserAuthChallenge) => Promise; + readonly maxQueue?: number; + readonly timeoutMs?: number; +} + +export interface BrowserAuthController { + readonly handleLogin: BrowserAuthLoginHandler; + clear(): void; + dispose(): void; +} + +export type BrowserAuthLoginHandler = ( + event: BrowserCancelableEvent, + webContents: WebContents, + details: { readonly url: string }, + authInfo: AuthInfo, + callback: (username?: string, password?: string) => void, +) => void; + +type Pending = { + readonly challenge: BrowserAuthChallenge; + readonly callback: (username?: string, password?: string) => void; +}; + +function bytes(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function boundedText(value: string, max = MAX_TEXT_BYTES): string | null { + return typeof value === "string" && value.length > 0 && bytes(value) <= max ? value : null; +} + +function challengeFrom(details: { readonly url: string }, info: AuthInfo): BrowserAuthChallenge | null { + const url = boundedText(details.url, MAX_URL_BYTES); + const host = boundedText(info.host); + const realm = typeof info.realm === "string" && bytes(info.realm) <= MAX_TEXT_BYTES ? info.realm : ""; + const scheme = boundedText(info.scheme, 64); + if (!url || !host || !scheme || !Number.isInteger(info.port) || info.port < 0 || info.port > 65_535) return null; + return { + url, + host: host.toLowerCase(), + port: info.port, + realm, + scheme: scheme.toLowerCase(), + isProxy: info.isProxy === true, + retry: false, + }; +} + +function safeCallback(callback: (username?: string, password?: string) => void, credentials?: BrowserAuthCredentials): void { + try { + if (!credentials) { + callback(); + return; + } + const username = boundedText(credentials.username); + const password = boundedText(credentials.password); + if (!username || !password) callback(); + else callback(username, password); + } catch { + try { callback(); } catch { /* Electron callbacks can be invalid after teardown. */ } + } +} + +/** + * Serializes HTTP Basic challenges so a renderer cannot create an unbounded set + * of credential prompts. The resolver is the only component that sees secrets; + * this module never logs, emits, or persists credentials. + */ +export function createBrowserAuthController(options: BrowserAuthControllerOptions): BrowserAuthController { + const requestedQueue = Number.isFinite(options.maxQueue) ? Math.trunc(options.maxQueue as number) : MAX_QUEUE; + const requestedTimeout = Number.isFinite(options.timeoutMs) ? Math.trunc(options.timeoutMs as number) : DEFAULT_TIMEOUT_MS; + const maxQueue = Math.max(1, Math.min(MAX_QUEUE, requestedQueue)); + const timeoutMs = Math.max(1_000, Math.min(120_000, requestedTimeout)); + const queue: Pending[] = []; + const seen = new Set(); + let running = false; + let disposed = false; + + const drain = (): void => { + if (running || disposed) return; + const pending = queue.shift(); + if (!pending) return; + running = true; + let settled = false; + const finish = (credentials?: BrowserAuthCredentials | null): void => { + if (settled) return; + settled = true; + safeCallback(pending.callback, disposed ? undefined : credentials ?? undefined); + running = false; + drain(); + }; + const timer = setTimeout(() => finish(), timeoutMs); + void Promise.resolve() + .then(() => options.resolve(pending.challenge)) + .then((credentials) => { + clearTimeout(timer); + finish(credentials); + }, () => { + clearTimeout(timer); + finish(); + }); + }; + + const handleLogin: BrowserAuthLoginHandler = (event, _webContents, details, authInfo, callback) => { + event.preventDefault(); + if (disposed) { + safeCallback(callback); + return; + } + const challenge = challengeFrom(details, authInfo); + if (!challenge || queue.length >= maxQueue || (running && queue.length >= maxQueue - 1)) { + safeCallback(callback); + return; + } + const key = `${challenge.isProxy ? "proxy" : "server"}|${challenge.host}|${challenge.port}|${challenge.realm}|${challenge.scheme}`; + const retry = seen.has(key); + if (seen.size < MAX_QUEUE * 2) seen.add(key); + queue.push({ challenge: { ...challenge, retry }, callback }); + drain(); + }; + return { + handleLogin, + clear(): void { + while (queue.length) safeCallback(queue.shift()!.callback); + seen.clear(); + }, + dispose(): void { + if (disposed) return; + disposed = true; + while (queue.length) safeCallback(queue.shift()!.callback); + seen.clear(); + }, + }; +} + + +export const createBrowserBasicAuthController = createBrowserAuthController; diff --git a/apps/desktop/src/browser-automation.ts b/apps/desktop/src/browser-automation.ts new file mode 100644 index 0000000..440aeb1 --- /dev/null +++ b/apps/desktop/src/browser-automation.ts @@ -0,0 +1,688 @@ +import { randomUUID } from "node:crypto"; +import { ipcMain as electronIpcMain, type IpcMain, type IpcMainEvent, type Session, type WebContents } from "electron"; +import type { + BrowserCall, + BrowserCallResult, + BrowserConsoleLevel, + BrowserConsoleMessage, + BrowserErrorCode, + BrowserEvent, + BrowserJsonValue, + BrowserMethod, + BrowserRuntimeError, + SurfaceId, +} from "@t4-code/protocol/browser-ipc"; +import { BROWSER_CONTENT_REQUEST_CHANNEL, BROWSER_CONTENT_RESPONSE_CHANNEL } from "./browser-content-channels.ts"; + +const MAX_PENDING = 128; +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_TIMEOUT_MS = 120_000; +const MAX_RING_ITEMS = 256; +const MAX_RESULT_BYTES = 1024 * 1024; +const MAX_STRING_BYTES = 16 * 1024; +const MAX_ARRAY_ITEMS = 256; +const MAX_OBJECT_KEYS = 64; +const MAX_DEPTH = 8; +const MAX_COOKIE_ITEMS = 256; +const MAX_DOWNLOAD_ID_BYTES = 256; +const MAX_SCRIPT_BYTES = 256 * 1024; + +export interface BrowserAutomationSurface { + readonly surfaceId: SurfaceId | string; + readonly webContents: WebContents; + readonly browserSession: Session; + readonly waitForContentReady: (timeoutMs: number) => Promise; +} + +export interface BrowserAutomationDownloads { + readonly wait: (downloadId: string, timeoutMs?: number) => Promise; + readonly owns?: (downloadId: string) => boolean; + readonly list?: (surfaceId?: SurfaceId) => readonly { readonly downloadId?: unknown }[]; +} + +export interface BrowserAutomationOptions { + readonly ipcMain?: Pick; + readonly resolveSurface: (surfaceId?: string) => BrowserAutomationSurface | undefined; + readonly downloads?: BrowserAutomationDownloads; + readonly emit?: (event: BrowserEvent) => void; +} + + +interface ContentEvent { + readonly requestId: null; + readonly event: { readonly type?: unknown; readonly payload?: unknown }; +} + + +interface PendingCall { + readonly surface: BrowserAutomationSurface; + readonly contents: WebContents; + readonly resolve: (value: unknown) => void; + readonly reject: (reason: unknown) => void; + readonly timer: ReturnType; +} + +interface CookieLike { + readonly name?: unknown; + readonly value?: unknown; + readonly domain?: unknown; + readonly path?: unknown; + readonly secure?: unknown; + readonly httpOnly?: unknown; + readonly sameSite?: unknown; + readonly expirationDate?: unknown; + readonly session?: unknown; + readonly url?: unknown; +} + +class BrowserAutomationError extends Error { + readonly code: BrowserErrorCode; + readonly method: BrowserMethod | undefined; + + constructor(code: BrowserErrorCode, message: string, method?: BrowserMethod) { + super(boundString(message, 4_096)); + this.name = "BrowserAutomationError"; + this.code = code; + this.method = method; + } +} + +const CONTENT_METHODS = new Set([ + "browser.navigate", "browser.back", "browser.forward", "browser.reload", + "browser.snapshot", "browser.eval", "browser.wait", "browser.screenshot", + "browser.click", "browser.dblclick", "browser.hover", "browser.focus", + "browser.type", "browser.fill", "browser.press", "browser.keydown", "browser.keyup", + "browser.check", "browser.uncheck", "browser.select", "browser.scroll", "browser.scroll_into_view", + "browser.get.text", "browser.get.html", "browser.get.value", "browser.get.attr", "browser.get.count", + "browser.get.box", "browser.get.styles", "browser.get.title", "browser.is.visible", + "browser.is.enabled", "browser.is.checked", "browser.find.role", "browser.find.text", + "browser.find.label", "browser.find.placeholder", "browser.find.testid", "browser.find.first", + "browser.find.last", "browser.find.nth", "browser.highlight", "browser.frame.select", "browser.frame.main", + "browser.storage.get", "browser.storage.set", "browser.storage.clear", +]); + +const SPECIAL_METHODS = new Set([ + "browser.cookies.get", "browser.cookies.set", "browser.cookies.clear", + "browser.console.list", "browser.console.clear", "browser.console.show", "browser.errors.list", + "browser.state.save", "browser.state.load", "browser.addinitscript", "browser.addscript", "browser.addstyle", + "browser.download.wait", +]); + +const MUTATING_METHODS = new Set([ + "browser.navigate", "browser.back", "browser.forward", "browser.reload", "browser.click", "browser.dblclick", + "browser.hover", "browser.focus", "browser.type", "browser.fill", "browser.press", "browser.keydown", + "browser.keyup", "browser.check", "browser.uncheck", "browser.select", "browser.scroll", "browser.scroll_into_view", + "browser.highlight", "browser.frame.select", "browser.frame.main", "browser.storage.set", "browser.storage.clear", + "browser.cookies.set", "browser.cookies.clear", "browser.state.load", "browser.addinitscript", "browser.addscript", + "browser.addstyle", +]); + +function isRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function boundString(value: string, maxBytes = MAX_STRING_BYTES): string { + if (byteLength(value) <= maxBytes) return value; + let end = value.length; + while (end > 0 && byteLength(value.slice(0, end)) > maxBytes) end -= 1; + return value.slice(0, end); +} + +function boundValue(value: unknown, depth = 0, seen = new WeakSet()): BrowserJsonValue { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") return boundString(value); + if (typeof value === "number") return Number.isFinite(value) ? value : String(value); + if (typeof value === "bigint") return boundString(value.toString()); + if (typeof value !== "object") return boundString(String(value)); + if (depth >= MAX_DEPTH || seen.has(value)) return "[redacted]"; + seen.add(value); + try { + if (Array.isArray(value)) return value.slice(0, MAX_ARRAY_ITEMS).map((entry) => boundValue(entry, depth + 1, seen)); + const result: Record = {}; + for (const key of Object.keys(value).slice(0, MAX_OBJECT_KEYS)) result[boundString(key)] = boundValue((value as Record)[key], depth + 1, seen); + return result; + } finally { + seen.delete(value); + } +} + +function boundEventValue(value: unknown, depth = 0, seen = new WeakSet()): BrowserJsonValue { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") return boundString(value); + if (typeof value === "number") return Number.isFinite(value) ? value : String(value); + if (typeof value !== "object") return boundString(String(value)); + if (depth >= MAX_DEPTH || seen.has(value)) return "[redacted]"; + seen.add(value); + try { + if (Array.isArray(value)) return value.slice(0, MAX_ARRAY_ITEMS).map((entry) => boundEventValue(entry, depth + 1, seen)); + const result: Record = {}; + for (const key of Object.keys(value).slice(0, MAX_OBJECT_KEYS)) { + const boundedKey = boundString(key); + result[boundedKey] = /cookie/iu.test(key) ? "[redacted]" : boundEventValue((value as Record)[key], depth + 1, seen); + } + return result; + } finally { + seen.delete(value); + } +} + +function boundedResult(value: unknown): unknown { + const result = boundValue(value); + try { + if (byteLength(JSON.stringify(result)) <= MAX_RESULT_BYTES) return result; + } catch { + // Use the deterministic bounded value below. + } + return { truncated: true }; +} + +function requestRecord(call: BrowserCall): Record { + if (!isRecord(call.request)) throw new BrowserAutomationError("invalid_params", "Browser request must be an object", call.method); + return call.request; +} + +function surfaceIdFrom(request: Record): string | undefined { + return typeof request.surfaceId === "string" && request.surfaceId.length > 0 ? boundString(request.surfaceId, 256) : undefined; +} + +function errorCode(value: unknown): BrowserErrorCode { + if (value === "invalid_params" || value === "not_found" || value === "invalid_state" || value === "not_supported" || value === "timeout" || value === "security" || value === "internal") return value; + return "internal"; +} + +function payloadMessage(value: unknown): string { + if (typeof value === "string") return boundString(value); + if (isRecord(value)) { + if (typeof value.message === "string") return boundString(value.message); + if (typeof value.reason === "string") return boundString(value.reason); + } + try { return boundString(JSON.stringify(boundValue(value)) ?? ""); } catch { return ""; } +} + +function level(value: unknown): BrowserConsoleLevel { + return value === "debug" || value === "info" || value === "warn" || value === "error" ? value : "log"; +} + +function secretCookieName(name: string): boolean { + return /(?:token|secret|password|credential|authorization|session|cookie|key)/iu.test(name); +} + +function safeCookie(cookie: CookieLike, surfaceId: SurfaceId): Record { + const name = typeof cookie.name === "string" ? boundString(cookie.name, 512) : ""; + const value = typeof cookie.value === "string" ? (secretCookieName(name) ? "[redacted]" : boundString(cookie.value)) : ""; + const output: Record = { name, value }; + for (const key of ["domain", "path", "sameSite", "url"] as const) { + const candidate = cookie[key]; + if (typeof candidate === "string") output[key] = boundString(candidate, 2_048); + } + for (const key of ["secure", "httpOnly", "session"] as const) { + if (typeof cookie[key] === "boolean") output[key] = cookie[key]; + } + if (typeof cookie.expirationDate === "number" && Number.isFinite(cookie.expirationDate)) output.expirationDate = cookie.expirationDate; + output.surfaceId = surfaceId; + return output; +} + +function stripSurface(request: Record): Record { + const { surfaceId: _surfaceId, snapshotAfter: _snapshotAfter, ...params } = request; + return params; +} + +function timeoutValue(request: Record): number { + const value = request.timeoutMs; + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_TIMEOUT_MS; + return Math.max(0, Math.min(MAX_TIMEOUT_MS, Math.trunc(value))); +} +function evaluationExpression(request: Record, method: BrowserMethod): string { + const expression = request.expression; + if (typeof expression !== "string" || byteLength(expression) > 64 * 1024) { + throw new BrowserAutomationError("invalid_params", "Expression must be bounded text", method); + } + if (/\b(?:require|process|ipcRenderer|electron|module|exports|__dirname|__filename)\b/u.test(expression)) { + throw new BrowserAutomationError("security", "Node and Electron objects are unavailable", method); + } + return expression; +} + + +function evaluationScript(expression: string, args: string, useArguments: boolean, statement = false): string { + const bound = `const bound=(value,depth=0,seen=new WeakSet())=>{if(value===null||typeof value==="boolean")return value;if(typeof value==="string")return value.length>32768?value.slice(0,32768):value;if(typeof value==="number")return Number.isFinite(value)?value:null;if(typeof value==="undefined")return null;if(typeof value!=="object")return String(value).slice(0,32768);if(depth>=8||seen.has(value))return "[unavailable]";seen.add(value);try{if(Array.isArray(value))return value.slice(0,512).map((entry)=>bound(entry,depth+1,seen));const output={};for(const key of Object.keys(value).slice(0,128)){const boundedKey=String(key).slice(0,256);try{output[boundedKey]=bound(value[key],depth+1,seen)}catch{output[boundedKey]="[unavailable]"}}return output}catch{return "[unavailable]"}finally{seen.delete(value)}};`; + const invoke = statement + ? `await (async()=>{${expression}})()` + : useArguments ? `((${expression})).apply(null,${args})` : `(${expression})`; + return `(async()=>{${bound}try{return {ok:true,value:bound(await ${invoke})}}catch{return {ok:false,error:"Evaluation failed"}}})()`; +} + +export function canHandleBrowserAutomationMethod(method: string): method is BrowserMethod { + return CONTENT_METHODS.has(method) || SPECIAL_METHODS.has(method); +} + +/** Coordinates browser worker automation with a surface-scoped preload bridge. */ +export class BrowserAutomationCoordinator { + private readonly ipc: Pick; + private readonly resolveSurface: BrowserAutomationOptions["resolveSurface"]; + private readonly downloads: BrowserAutomationDownloads | undefined; + private readonly emitEvent: ((event: BrowserEvent) => void) | undefined; + private readonly pending = new Map(); + private readonly knownSurfaces = new Map(); + private readonly consoles = new Map(); + private readonly errors = new Map(); + private disposed = false; + + public constructor(options: BrowserAutomationOptions) { + this.ipc = options.ipcMain ?? electronIpcMain; + this.resolveSurface = options.resolveSurface; + this.downloads = options.downloads; + this.emitEvent = options.emit; + this.ipc.on(BROWSER_CONTENT_RESPONSE_CHANNEL, this.onContentResponse); + } + + public async call(call: BrowserCall): Promise { + if (this.disposed) throw new BrowserAutomationError("invalid_state", "Browser automation is disposed", call.method); + if (!canHandleBrowserAutomationMethod(call.method)) throw new BrowserAutomationError("not_supported", `Browser method ${call.method} is not supported`, call.method); + const request = requestRecord(call); + const requestedSurfaceId = surfaceIdFrom(request); + const surface = call.method === "browser.download.wait" && requestedSurfaceId === undefined + ? undefined + : this.surfaceFor(request, call.method); + const mutation = MUTATING_METHODS.has(call.method); + try { + let result: unknown; + switch (call.method) { + case "browser.cookies.get": result = await this.cookiesGet(surface as BrowserAutomationSurface, request); break; + case "browser.cookies.set": result = await this.cookiesSet(surface as BrowserAutomationSurface, request); break; + case "browser.cookies.clear": result = await this.cookiesClear(surface as BrowserAutomationSurface, request); break; + case "browser.console.list": result = this.consoleList((surface as BrowserAutomationSurface).surfaceId, request); break; + case "browser.console.show": result = this.consoleList((surface as BrowserAutomationSurface).surfaceId, request); break; + case "browser.console.clear": this.consoles.delete(String((surface as BrowserAutomationSurface).surfaceId)); result = { cleared: true }; break; + case "browser.errors.list": result = { errors: [...(this.errors.get(String((surface as BrowserAutomationSurface).surfaceId)) ?? [])] }; break; + case "browser.state.save": result = await this.stateSave(surface as BrowserAutomationSurface, request); break; + case "browser.state.load": result = await this.stateLoad(surface as BrowserAutomationSurface, request); break; + case "browser.addinitscript": result = await this.addInitScript(surface as BrowserAutomationSurface, request); break; + case "browser.addscript": result = await this.addScript(surface as BrowserAutomationSurface, request); break; + case "browser.addstyle": result = await this.addStyle(surface as BrowserAutomationSurface, request); break; + case "browser.download.wait": result = await this.downloadWait(request); break; + case "browser.eval": result = await this.evaluate(surface as BrowserAutomationSurface, request); break; + case "browser.wait": + result = (request.kind === "function" || (request.kind === undefined && request.type === "function")) + ? await this.waitForFunction(surface as BrowserAutomationSurface, request) + : await this.contentCall(surface as BrowserAutomationSurface, call.method, stripSurface(request)); + break; + default: result = await this.contentCall(surface as BrowserAutomationSurface, call.method, stripSurface(request)); + } + if (mutation && request.snapshotAfter === true) { + if (isRecord(result) && "postActionSnapshot" in result) return boundedResult(result) as BrowserCallResult; + const postActionSnapshot = await this.contentCall(surface as BrowserAutomationSurface, "browser.snapshot", {}); + if (isRecord(result)) return { ...result, postActionSnapshot: boundedResult(postActionSnapshot) } as BrowserCallResult; + return { result: boundedResult(result), postActionSnapshot: boundedResult(postActionSnapshot) } as BrowserCallResult; + } + return boundedResult(result) as BrowserCallResult; + } catch (error) { + if (error instanceof BrowserAutomationError) throw error; + throw new BrowserAutomationError("internal", payloadMessage(error), call.method); + } + } + + public dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.ipc.removeListener(BROWSER_CONTENT_RESPONSE_CHANNEL, this.onContentResponse); + for (const [requestId, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(new BrowserAutomationError("invalid_state", "Browser automation is disposed")); + this.pending.delete(requestId); + } + this.knownSurfaces.clear(); + } + + private surfaceFor(request: Record, method: BrowserMethod): BrowserAutomationSurface { + const surfaceId = surfaceIdFrom(request); + const surface = this.resolveSurface(surfaceId); + if (!surface) throw new BrowserAutomationError("not_found", "Browser surface is unavailable", method); + this.knownSurfaces.set(String(surface.surfaceId), surface); + return surface; + } + + private async contentCall(surface: BrowserAutomationSurface, method: string, params: Record): Promise { + const deadline = Date.now() + DEFAULT_TIMEOUT_MS; + if (this.disposed) throw new BrowserAutomationError("invalid_state", "Browser automation is disposed"); + if (this.pending.size >= MAX_PENDING) throw new BrowserAutomationError("invalid_state", "Too many pending browser requests"); + const contents = surface.webContents; + + try { + await surface.waitForContentReady(Math.max(0, deadline - Date.now())); + } catch (error) { + if (error instanceof BrowserAutomationError) throw error; + const candidate = typeof error === "object" && error !== null + ? error as { readonly code?: unknown; readonly message?: unknown } + : undefined; + const message = typeof candidate?.message === "string" ? candidate.message : payloadMessage(error); + throw new BrowserAutomationError(errorCode(candidate?.code), message, method as BrowserMethod); + } + + if (this.disposed) throw new BrowserAutomationError("invalid_state", "Browser automation is disposed"); + if (surface.webContents !== contents) throw new BrowserAutomationError("invalid_state", "Browser surface changed while preparing content request", method as BrowserMethod); + if (this.pending.size >= MAX_PENDING) throw new BrowserAutomationError("invalid_state", "Too many pending browser requests"); + const remainingTimeout = deadline - Date.now(); + if (remainingTimeout <= 0) throw new BrowserAutomationError("timeout", "Browser content request timed out"); + + const requestId = randomUUID(); + const boundedParams = boundedResult(params); + const payload = { requestId, method: boundString(method, 128), params: isRecord(boundedParams) ? boundedParams : {} }; + const { promise, resolve, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + this.pending.delete(requestId); + reject(new BrowserAutomationError("timeout", "Browser content request timed out")); + }, remainingTimeout); + this.pending.set(requestId, { surface, contents, resolve, reject, timer }); + try { + contents.send(BROWSER_CONTENT_REQUEST_CHANNEL, payload); + } catch (error) { + clearTimeout(timer); + this.pending.delete(requestId); + reject(new BrowserAutomationError("internal", payloadMessage(error))); + } + return promise; + } + private async evaluate(surface: BrowserAutomationSurface, request: Record): Promise<{ value: BrowserJsonValue }> { + const expression = evaluationExpression(request, "browser.eval"); + const args = Array.isArray(request.args) ? request.args : []; + return { value: await this.evaluateOnSurface(surface, expression, args, Date.now() + timeoutValue(request), "browser.eval") }; + } + + private async waitForFunction(surface: BrowserAutomationSurface, request: Record): Promise<{ matched: true }> { + const expression = evaluationExpression({ expression: request.value ?? request.selector }, "browser.wait"); + const args = Array.isArray(request.args) ? request.args : []; + const deadline = Date.now() + timeoutValue(request); + while (Date.now() <= deadline) { + if (await this.evaluateOnSurface(surface, expression, args, deadline, "browser.wait")) return { matched: true }; + const delay = Math.min(25, Math.max(0, deadline - Date.now())); + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + } + throw new BrowserAutomationError("timeout", "Wait timed out", "browser.wait"); + } + + private async evaluateOnSurface(surface: BrowserAutomationSurface, expression: string, args: unknown[], deadline: number, method: BrowserMethod): Promise { + if (this.disposed) throw new BrowserAutomationError("invalid_state", "Browser automation is disposed", method); + const contents = surface.webContents; + if (contents.isDestroyed()) throw new BrowserAutomationError("invalid_state", "Browser surface is unavailable", method); + const readyTimeout = deadline - Date.now(); + if (readyTimeout <= 0) throw new BrowserAutomationError("timeout", "Browser evaluation timed out", method); + try { + await surface.waitForContentReady(readyTimeout); + } catch (error) { + if (error instanceof BrowserAutomationError) throw error; + const candidate = typeof error === "object" && error !== null ? error as { readonly code?: unknown; readonly message?: unknown } : undefined; + throw new BrowserAutomationError(errorCode(candidate?.code), typeof candidate?.message === "string" ? candidate.message : payloadMessage(error), method); + } + if (this.disposed) throw new BrowserAutomationError("invalid_state", "Browser automation is disposed", method); + if (surface.webContents !== contents || contents.isDestroyed()) throw new BrowserAutomationError("invalid_state", "Browser surface changed while preparing evaluation", method); + const serializedArgs = JSON.stringify(args.slice(0, MAX_ARRAY_ITEMS).map((value) => boundValue(value))); + + let result: unknown; + try { + result = await this.runEvaluationScript(surface, contents, evaluationScript(expression, serializedArgs, args.length > 0), deadline, method); + } catch (error) { + if (error instanceof BrowserAutomationError) throw error; + if (surface.webContents !== contents || contents.isDestroyed()) throw new BrowserAutomationError("invalid_state", "Browser surface changed while evaluating", method); + try { + result = await this.runEvaluationScript(surface, contents, evaluationScript(expression, serializedArgs, false, true), deadline, method); + } catch (statementError) { + if (statementError instanceof BrowserAutomationError) throw statementError; + throw new BrowserAutomationError("internal", "Evaluation failed", method); + } + } + if (!isRecord(result) || typeof result.ok !== "boolean") throw new BrowserAutomationError("internal", "Evaluation failed", method); + if (result.ok !== true) throw new BrowserAutomationError("internal", "Evaluation failed", method); + return boundValue(result.value); + } + + private async runEvaluationScript(surface: BrowserAutomationSurface, contents: WebContents, script: string, deadline: number, method: BrowserMethod): Promise { + const timeout = deadline - Date.now(); + if (timeout <= 0) throw new BrowserAutomationError("timeout", "Browser evaluation timed out", method); + let timer: NodeJS.Timeout | undefined; + let result: unknown; + let failure: unknown; + let failed = false; + try { + result = await Promise.race([ + contents.executeJavaScript(script, true), + new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new BrowserAutomationError("timeout", "Browser evaluation timed out", method)), timeout); }), + ]); + } catch (error) { + failure = error; + failed = true; + } finally { + clearTimeout(timer); + } + if (surface.webContents !== contents || contents.isDestroyed()) throw new BrowserAutomationError("invalid_state", "Browser surface changed while evaluating", method); + if (failed) throw failure; + return result; + } + + private readonly onContentResponse = (event: IpcMainEvent, value: unknown): void => { + if (this.disposed || !isRecord(value)) return; + const requestId = value.requestId; + if (requestId === null && isRecord(value.event)) { + this.onContentEvent(event.sender, value as unknown as ContentEvent); + return; + } + if (typeof requestId !== "string" || byteLength(requestId) > 128) return; + const pending = this.pending.get(requestId); + if (!pending || event.sender !== pending.contents) return; + this.pending.delete(requestId); + clearTimeout(pending.timer); + if (value.ok === true) { + pending.resolve(boundedResult(value.result)); + return; + } + const error = isRecord(value.error) ? value.error : {}; + pending.reject(new BrowserAutomationError(errorCode(error.code), typeof error.message === "string" ? error.message : "Browser content request failed")); + }; + + private onContentEvent(sender: WebContents, response: ContentEvent): void { + let surface: BrowserAutomationSurface | undefined; + for (const candidate of this.knownSurfaces.values()) if (candidate.webContents === sender) { surface = candidate; break; } + if (!surface) { + const candidate = this.resolveSurface(); + if (candidate?.webContents === sender) { + surface = candidate; + this.knownSurfaces.set(String(candidate.surfaceId), candidate); + } + } + if (!surface || !isRecord(response.event)) return; + const type = response.event.type; + if (type === "console") this.recordConsole(surface, response.event.payload); + else if (type === "error") this.recordError(surface, response.event.payload); + } + + private recordConsole(surface: BrowserAutomationSurface, payload: unknown): void { + const object = isRecord(payload) ? payload : {}; + const args = Array.isArray(object.args) ? object.args.slice(0, 32).map((arg) => boundEventValue(arg)) : []; + const message: BrowserConsoleMessage = { + level: level(object.level), + message: boundString(payloadMessage(object.message ?? (args.length > 0 ? args.map((arg) => payloadMessage(arg)).join(" ") : ""))), + args, + ...(typeof object.source === "string" ? { source: boundString(object.source, 2_048) } : {}), + ...(typeof object.url === "string" ? { url: boundString(object.url, 2_048) } : {}), + ...(typeof object.lineno === "number" && Number.isFinite(object.lineno) ? { line: Math.max(0, Math.trunc(object.lineno)) } : {}), + ...(typeof object.colno === "number" && Number.isFinite(object.colno) ? { column: Math.max(0, Math.trunc(object.colno)) } : {}), + timestamp: Date.now(), + surfaceId: surface.surfaceId as SurfaceId, + }; + const ring = this.consoles.get(String(surface.surfaceId)) ?? []; + ring.push(message); + if (ring.length > MAX_RING_ITEMS) ring.splice(0, ring.length - MAX_RING_ITEMS); + this.consoles.set(String(surface.surfaceId), ring); + try { this.emitEvent?.({ type: "console", console: message }); } catch { /* event listeners cannot interrupt the bridge */ } + } + + private recordError(surface: BrowserAutomationSurface, payload: unknown): void { + const object = isRecord(payload) ? payload : {}; + const error: BrowserRuntimeError = { + surfaceId: surface.surfaceId as SurfaceId, + kind: "page", + code: typeof object.kind === "string" ? boundString(object.kind, 128) : "page", + message: boundString(payloadMessage(object.message ?? object.reason ?? payload)), + ...(typeof object.filename === "string" ? { url: boundString(object.filename, 2_048) } : {}), + fatal: false, + timestamp: Date.now(), + }; + const ring = this.errors.get(String(surface.surfaceId)) ?? []; + ring.push(error); + if (ring.length > MAX_RING_ITEMS) ring.splice(0, ring.length - MAX_RING_ITEMS); + this.errors.set(String(surface.surfaceId), ring); + try { this.emitEvent?.({ type: "error", error }); } catch { /* event listeners cannot interrupt the bridge */ } + } + + private consoleList(surfaceId: SurfaceId | string, request: Record): { messages: readonly BrowserConsoleMessage[] } { + const levels = Array.isArray(request.levels) ? new Set(request.levels.filter((item): item is BrowserConsoleLevel => item === "debug" || item === "info" || item === "log" || item === "warn" || item === "error")) : undefined; + const messages = (this.consoles.get(String(surfaceId)) ?? []).filter((message) => levels === undefined || levels.has(message.level)); + return { messages: messages.slice(-MAX_RING_ITEMS) }; + } + + private async cookiesGet(surface: BrowserAutomationSurface, request: Record): Promise { + const url = typeof request.url === "string" ? boundString(request.url, 2_048) : undefined; + const cookiesApi = surface.browserSession.cookies as unknown as { get: (filter: Record) => Promise }; + const cookies = await cookiesApi.get(url === undefined ? {} : { url }); + return { cookies: cookies.slice(0, MAX_COOKIE_ITEMS).map((cookie) => safeCookie(cookie, surface.surfaceId as SurfaceId)), count: Math.min(cookies.length, MAX_COOKIE_ITEMS) }; + } + + private async cookiesSet(surface: BrowserAutomationSurface, request: Record): Promise { + const input = isRecord(request.cookie) ? request.cookie : request; + const name = typeof input.name === "string" ? boundString(input.name, 512) : ""; + const value = typeof input.value === "string" ? boundString(input.value) : ""; + const url = typeof input.url === "string" ? boundString(input.url, 2_048) : undefined; + if (!name || !url) throw new BrowserAutomationError("invalid_params", "Cookie name and url are required", "browser.cookies.set"); + const details: Record = { url, name, value }; + for (const key of ["domain", "path", "sameSite"] as const) if (typeof input[key] === "string") details[key] = boundString(input[key] as string, 2_048); + for (const key of ["secure", "httpOnly"] as const) if (typeof input[key] === "boolean") details[key] = input[key]; + if (typeof input.expirationDate === "number" && Number.isFinite(input.expirationDate)) details.expirationDate = input.expirationDate; + const cookiesApi = surface.browserSession.cookies as unknown as { set: (details: Record) => Promise }; + await cookiesApi.set(details); + return { count: 1 }; + } + + private async cookiesClear(surface: BrowserAutomationSurface, request: Record): Promise { + const url = typeof request.url === "string" ? boundString(request.url, 2_048) : undefined; + const cookiesApi = surface.browserSession.cookies as unknown as { get: (filter: Record) => Promise; remove: (url: string, name: string) => Promise }; + const cookies = await cookiesApi.get(url === undefined ? {} : { url }); + let count = 0; + for (const cookie of cookies.slice(0, MAX_COOKIE_ITEMS)) { + if (typeof cookie.name !== "string") continue; + const cookieUrl = typeof cookie.url === "string" ? cookie.url : url; + if (!cookieUrl) continue; + await cookiesApi.remove(boundString(cookieUrl, 2_048), boundString(cookie.name, 512)); + count += 1; + } + return { count }; + } + + private async stateSave(surface: BrowserAutomationSurface, request: Record): Promise { + const content = await this.contentCall(surface, "browser.state.save", stripSurface(request)); + const contentState = isRecord(content) && isRecord(content.state) ? content.state : isRecord(content) ? content : {}; + const currentUrl = (() => { + try { return boundString(surface.webContents.getURL(), 2_048); } catch { return ""; } + })(); + const state: Record = { + version: 1, + url: currentUrl, + localStorage: isRecord(contentState.localStorage) ? boundValue(contentState.localStorage) : {}, + sessionStorage: isRecord(contentState.sessionStorage) ? boundValue(contentState.sessionStorage) : {}, + }; + if (request.includeCookies === true || request.allowCookies === true) { + const cookieResult = await this.cookiesGet(surface, request); + if (isRecord(cookieResult) && Array.isArray(cookieResult.cookies)) state.cookies = cookieResult.cookies.slice(0, MAX_COOKIE_ITEMS); + } + return boundedResult(state); + } + + private async stateLoad(surface: BrowserAutomationSurface, request: Record): Promise { + const state = isRecord(request.state) ? request.state : stripSurface(request); + if (state.version !== 1) throw new BrowserAutomationError("invalid_params", "Browser state version must be 1", "browser.state.load"); + const currentUrl = (() => { + try { return surface.webContents.getURL(); } catch { return ""; } + })(); + const includeCookies = request.includeCookies === true || request.allowCookies === true; + if (Array.isArray(state.cookies)) { + if (!includeCookies) throw new BrowserAutomationError("security", "Loading cookies requires explicit opt-in", "browser.state.load"); + const hostname = (() => { + try { return new URL(currentUrl).hostname.toLowerCase(); } catch { return ""; } + })(); + const cookiesApi = surface.browserSession.cookies as unknown as { set: (details: Record) => Promise }; + for (const cookie of state.cookies.slice(0, MAX_COOKIE_ITEMS)) { + if (!isRecord(cookie) || typeof cookie.name !== "string" || typeof cookie.value !== "string") { + throw new BrowserAutomationError("invalid_params", "Invalid state cookie", "browser.state.load"); + } + const domain = typeof cookie.domain === "string" ? cookie.domain.replace(/^\./u, "").toLowerCase() : ""; + if (!hostname || !domain || (hostname !== domain && !hostname.endsWith(`.${domain}`))) { + throw new BrowserAutomationError("security", "State cookie domain does not match the current surface", "browser.state.load"); + } + const cookieUrl = typeof cookie.url === "string" ? boundString(cookie.url, 2_048) : currentUrl; + if (!cookieUrl) throw new BrowserAutomationError("invalid_params", "State cookie URL is required", "browser.state.load"); + await cookiesApi.set({ + url: cookieUrl, + name: boundString(cookie.name, 512), + value: boundString(cookie.value), + domain, + ...(typeof cookie.path === "string" ? { path: boundString(cookie.path, 2_048) } : {}), + ...(typeof cookie.secure === "boolean" ? { secure: cookie.secure } : {}), + ...(typeof cookie.httpOnly === "boolean" ? { httpOnly: cookie.httpOnly } : {}), + ...(typeof cookie.sameSite === "string" ? { sameSite: boundString(cookie.sameSite, 32) } : {}), + ...(typeof cookie.expirationDate === "number" && Number.isFinite(cookie.expirationDate) ? { expirationDate: cookie.expirationDate } : {}), + }); + } + } + const contentState: Record = { + version: 1, + ...(typeof state.url === "string" ? { url: boundString(state.url, 2_048) } : {}), + localStorage: isRecord(state.localStorage) ? boundValue(state.localStorage) : {}, + sessionStorage: isRecord(state.sessionStorage) ? boundValue(state.sessionStorage) : {}, + }; + return boundedResult(await this.contentCall(surface, "browser.state.load", contentState)); + } + + private async addInitScript(surface: BrowserAutomationSurface, request: Record): Promise { + const script = typeof request.script === "string" ? request.script : typeof request.source === "string" ? request.source : ""; + if (!script || byteLength(script) > MAX_SCRIPT_BYTES) throw new BrowserAutomationError("invalid_params", "Script is required and bounded", "browser.addinitscript"); + const contents = surface.webContents as WebContents & { addInitScript?: (script: string) => Promise | void }; + if (typeof contents.addInitScript !== "function") return this.contentCall(surface, "browser.addinitscript", { script: boundString(script, MAX_SCRIPT_BYTES) }); + await contents.addInitScript(script); + return { added: true }; + } + + private async addScript(surface: BrowserAutomationSurface, request: Record): Promise { + const script = typeof request.script === "string" ? request.script : typeof request.source === "string" ? request.source : ""; + if (!script || byteLength(script) > MAX_SCRIPT_BYTES) throw new BrowserAutomationError("invalid_params", "Script is required and bounded", "browser.addscript"); + const result = await surface.webContents.executeJavaScript(script, true); + return { value: boundedResult(result) }; + } + + private async addStyle(surface: BrowserAutomationSurface, request: Record): Promise { + const css = typeof request.css === "string" ? request.css : typeof request.style === "string" ? request.style : ""; + if (!css || byteLength(css) > MAX_SCRIPT_BYTES) throw new BrowserAutomationError("invalid_params", "Style is required and bounded", "browser.addstyle"); + const contents = surface.webContents as WebContents & { insertCSS?: (css: string) => Promise }; + if (typeof contents.insertCSS !== "function") throw new BrowserAutomationError("not_supported", "Style injection is not supported", "browser.addstyle"); + return { key: boundString(await contents.insertCSS(css), 256) }; + } + + private async downloadWait(request: Record): Promise { + if (!this.downloads) throw new BrowserAutomationError("not_supported", "Download waiting is not configured", "browser.download.wait"); + const downloadId = typeof request.downloadId === "string" ? boundString(request.downloadId, MAX_DOWNLOAD_ID_BYTES) : ""; + if (!downloadId) throw new BrowserAutomationError("invalid_params", "downloadId is required", "browser.download.wait"); + const downloadOwned = this.downloads.owns?.(downloadId) === true + || this.downloads.list?.().some((entry) => entry.downloadId === downloadId) === true; + if (surfaceIdFrom(request) === undefined && !downloadOwned) { + throw new BrowserAutomationError("not_found", "Download is not owned by this browser", "browser.download.wait"); + } + const result = await this.downloads.wait(downloadId, timeoutValue(request)); + if (result === undefined) throw new BrowserAutomationError("timeout", "Download wait timed out", "browser.download.wait"); + return boundedResult(result); + } +} diff --git a/apps/desktop/src/browser-capture.ts b/apps/desktop/src/browser-capture.ts new file mode 100644 index 0000000..ff9cbd0 --- /dev/null +++ b/apps/desktop/src/browser-capture.ts @@ -0,0 +1,458 @@ +import { contentTracing, type Rectangle } from "electron"; +import type { Parameters as ElectronDeviceEmulationParameters } from "electron"; +import type { BrowserErrorCode, BrowserJsonValue } from "@t4-code/protocol/browser-ipc"; + +const MAX_VIEWPORT = 4_096; +const DEFAULT_VIEWPORT = { width: 1_280, height: 720 } as const; +const MAX_CAPTURE_BYTES = 8 * 1024 * 1024; +const MAX_FRAME_BYTES = 4 * 1024 * 1024; +const MAX_FRAME_COUNT = 120; +const MAX_TRACE_CATEGORIES = 64; +const MAX_TRACE_CATEGORY_LENGTH = 128; +const MAX_TRACE_PATH_LENGTH = 4_096; + +export interface BrowserNativeImageLike { + toPNG(): Uint8Array; + getSize?(): { readonly width: number; readonly height: number }; +} + +export interface BrowserCaptureContents { + capturePage(rect?: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }): Promise | BrowserNativeImageLike; + enableDeviceEmulation?(parameters: ElectronDeviceEmulationParameters): void; + disableDeviceEmulation?(): void; + setZoomFactor?(factor: number): void; + getZoomFactor?(): number; + focus?(): void; + isFocused?(): boolean; + beginFrameSubscription?: { + (onlyDirty: boolean, callback: (image: BrowserNativeImageLike, dirtyRect: Rectangle) => void): void; + (callback: (image: BrowserNativeImageLike, dirtyRect: Rectangle) => void): void; + }; + endFrameSubscription?(): void; +} + +export interface BrowserCaptureSurface { + readonly webContents?: BrowserCaptureContents | null; + readonly surfaceId?: string; + readonly state?: unknown; + readonly snapshot?: () => unknown | Promise; + readonly getSnapshot?: () => unknown | Promise; +} + +export interface BrowserTraceController { + startRecording(options: Record): Promise; + stopRecording(path?: string): Promise; +} + +export interface BrowserCaptureCoordinatorOptions { + readonly maxCaptureBytes?: number; + readonly emit?: (event: BrowserScreencastFrameEvent) => void; + readonly contentTracing?: BrowserTraceController; + /** The host must explicitly identify an exclusive tracing owner. */ + readonly traceOwnership?: boolean | (() => boolean); +} + +export interface BrowserViewport { + readonly width: number; + readonly height: number; +} + +export interface BrowserScreenshotResult { + readonly supported: true; + readonly mimeType: "image/png"; + readonly width: number; + readonly height: number; + readonly data: string; +} + +export interface BrowserCapabilityResult { + readonly supported: false; + readonly code: "not_supported"; + readonly message: string; +} + +export interface BrowserScreencastFrameEvent { + readonly type: "browser.screencast.frame"; + readonly surfaceId?: string; + readonly subscriptionId: string; + readonly width: number; + readonly height: number; + readonly data: string; +} + +export class BrowserCaptureError extends Error { + readonly code: BrowserErrorCode; + readonly method?: string; + readonly surfaceId?: string; + + constructor(code: BrowserErrorCode, message: string, method?: string, surfaceId?: string) { + super(message); + this.name = "BrowserCaptureError"; + this.code = code; + if (method !== undefined) this.method = method; + if (surfaceId !== undefined) this.surfaceId = surfaceId; + } +} + +interface CropRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +type SurfaceKey = object | string; + +interface ScreenshotFlight { + readonly params: Record; + readonly operation: Promise; +} + +interface ScreencastSubscription { + readonly id: string; + readonly surface: BrowserCaptureSurface | BrowserCaptureContents; + readonly contents: BrowserCaptureContents; + readonly maxFrames: number; + readonly maxFrameBytes: number; + frames: number; + stopped: boolean; +} + +let nextSubscriptionId = 1; +let traceOwner: symbol | undefined; + +const defaultTracing: BrowserTraceController = { + startRecording: (options) => contentTracing.startRecording(options as never), + stopRecording: (path) => contentTracing.stopRecording(path), +}; + +function unsupported(message: string): BrowserCapabilityResult { + return { supported: false, code: "not_supported", message }; +} + +function record(value: unknown, method: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new BrowserCaptureError("invalid_params", "params must be an object", method); + return value as Record; +} + +function finiteNumber(value: unknown, name: string, method: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) throw new BrowserCaptureError("invalid_params", `${name} must be finite`, method); + return value; +} + +function positiveInteger(value: unknown, name: string, method: string, maximum: number): number { + const number = finiteNumber(value, name, method); + if (!Number.isSafeInteger(number) || number < 1 || number > maximum) throw new BrowserCaptureError("invalid_params", `${name} must be an integer between 1 and ${maximum}`, method); + return number; +} + +function surfaceContents(surface: BrowserCaptureSurface | BrowserCaptureContents, method: string): BrowserCaptureContents { + if (typeof surface === "object" && surface !== null && "capturePage" in surface && typeof surface.capturePage === "function") return surface as BrowserCaptureContents; + const contents = (surface as BrowserCaptureSurface | undefined)?.webContents; + if (!contents || typeof contents.capturePage !== "function") throw new BrowserCaptureError("not_found", "Browser surface has no live webContents", method, (surface as BrowserCaptureSurface | undefined)?.surfaceId); + return contents; +} + +function surfaceId(surface: BrowserCaptureSurface | BrowserCaptureContents): string | undefined { + return "surfaceId" in surface && typeof surface.surfaceId === "string" ? surface.surfaceId : undefined; +} + +function surfaceKey(surface: BrowserCaptureSurface | BrowserCaptureContents): SurfaceKey { + const id = surfaceId(surface); + return id === undefined ? surface : `surface:${id}`; +} + +function sameScreenshotOptions(left: Record, right: Record): boolean { + if (left === right) return true; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && sameValue(left[key], right[key])); +} + +function sameValue(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) return false; + if (Array.isArray(left) || Array.isArray(right)) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => sameValue(value, right[index])); + } + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(rightRecord, key) && sameValue(leftRecord[key], rightRecord[key])); +} + +function snapshotRequested(params: Record, method: string): boolean { + if (!("snapshotAfter" in params)) return false; + if (params.snapshotAfter !== true && params.snapshotAfter !== false) throw new BrowserCaptureError("invalid_params", "snapshotAfter must be boolean", method); + return params.snapshotAfter === true; +} + +async function postActionSnapshot(surface: BrowserCaptureSurface | BrowserCaptureContents, requested: boolean, method: string): Promise> { + if (!requested) return {}; + if (typeof surface === "object" && surface !== null && "snapshot" in surface && typeof surface.snapshot === "function") return { postActionSnapshot: await surface.snapshot() as BrowserJsonValue }; + if (typeof surface === "object" && surface !== null && "getSnapshot" in surface && typeof surface.getSnapshot === "function") return { postActionSnapshot: await surface.getSnapshot() as BrowserJsonValue }; + if (typeof surface === "object" && surface !== null && "state" in surface) return { postActionSnapshot: (surface as BrowserCaptureSurface).state as BrowserJsonValue }; + throw new BrowserCaptureError("not_supported", "Surface snapshots are not available", method, surfaceId(surface)); +} + +function clampCrop(value: unknown, viewport: BrowserViewport, method: string): CropRect { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new BrowserCaptureError("invalid_params", "crop must be an object", method); + const input = value as Record; + const x = finiteNumber(input.x, "crop.x", method); + const y = finiteNumber(input.y, "crop.y", method); + const width = finiteNumber(input.width, "crop.width", method); + const height = finiteNumber(input.height, "crop.height", method); + if (width <= 0 || height <= 0) throw new BrowserCaptureError("invalid_params", "crop dimensions must be positive", method); + const left = Math.max(0, Math.min(viewport.width, Math.floor(x))); + const top = Math.max(0, Math.min(viewport.height, Math.floor(y))); + const right = Math.min(viewport.width, Math.ceil(x + width)); + const bottom = Math.min(viewport.height, Math.ceil(y + height)); + if (right <= left || bottom <= top) throw new BrowserCaptureError("invalid_params", "crop does not intersect the viewport", method); + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +function boundedString(value: unknown, name: string, maximum: number, method: string): string { + if (typeof value !== "string" || value.length > maximum) throw new BrowserCaptureError("invalid_params", `${name} must be a string of at most ${maximum} characters`, method); + return value; +} + +function boundedCategories(value: unknown, name: string, method: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > MAX_TRACE_CATEGORIES) throw new BrowserCaptureError("invalid_params", `${name} must contain at most ${MAX_TRACE_CATEGORIES} categories`, method); + return value.map((entry) => boundedString(entry, `${name} entry`, MAX_TRACE_CATEGORY_LENGTH, method)); +} + +/** Native capture, viewport, screencast, and exclusive tracing coordinator. */ +export class BrowserCaptureCoordinator { + private readonly maxCaptureBytes: number; + private readonly emitFrame: ((event: BrowserScreencastFrameEvent) => void) | undefined; + private readonly tracing: BrowserTraceController; + private readonly traceOwnership: boolean | (() => boolean); + private readonly viewports = new Map(); + private readonly zooms = new Map(); + private readonly subscriptions = new Map(); + private readonly captureFlights = new Map(); + private traceActive = false; + private disposed = false; + + constructor(options: BrowserCaptureCoordinatorOptions = {}) { + const configuredMaxBytes = options.maxCaptureBytes; + this.maxCaptureBytes = typeof configuredMaxBytes === "number" && Number.isFinite(configuredMaxBytes) ? Math.max(1, Math.min(MAX_CAPTURE_BYTES, Math.floor(configuredMaxBytes))) : MAX_CAPTURE_BYTES; + this.emitFrame = options.emit; + this.tracing = options.contentTracing ?? defaultTracing; + this.traceOwnership = options.traceOwnership ?? false; + } + + private viewport(surface: BrowserCaptureSurface | BrowserCaptureContents): BrowserViewport { + return this.viewports.get(surfaceKey(surface)) ?? DEFAULT_VIEWPORT; + } + + private ensureLive(method: string, surface: BrowserCaptureSurface | BrowserCaptureContents): BrowserCaptureContents { + if (this.disposed) throw new BrowserCaptureError("invalid_state", "Capture coordinator is disposed", method, surfaceId(surface)); + return surfaceContents(surface, method); + } + + private async screenshot(params: Record, surface: BrowserCaptureSurface | BrowserCaptureContents, method: string): Promise { + const contents = this.ensureLive(method, surface); + const viewport = this.viewport(surface); + const crop = params.crop === undefined ? (params.bounds === undefined ? { x: 0, y: 0, width: viewport.width, height: viewport.height } : clampCrop(params.bounds, viewport, method)) : clampCrop(params.crop, viewport, method); + if (params.format !== undefined && params.format !== "png") throw new BrowserCaptureError("not_supported", "Only PNG screenshots are supported", method, surfaceId(surface)); + const maxBytes = params.maxBytes === undefined ? this.maxCaptureBytes : positiveInteger(params.maxBytes, "maxBytes", method, this.maxCaptureBytes); + const key = surfaceKey(surface); + const flight = this.captureFlights.get(key); + if (flight && sameScreenshotOptions(flight.params, params)) return flight.operation; + const operation = (async (): Promise => { + try { + const image = await contents.capturePage(crop); + const data = image.toPNG(); + const encoded = Buffer.from(data).toString("base64"); + if (data.byteLength > maxBytes || data.byteLength > this.maxCaptureBytes || Buffer.byteLength(encoded, "ascii") > maxBytes) throw new BrowserCaptureError("internal", "Screenshot exceeds the configured byte limit", method, surfaceId(surface)); + const size = image.getSize?.(); + if (size !== undefined && (!Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1 || size.width > MAX_VIEWPORT || size.height > MAX_VIEWPORT)) throw new BrowserCaptureError("internal", "Screenshot dimensions exceed the configured limit", method, surfaceId(surface)); + const width = size?.width ?? crop.width; + const height = size?.height ?? crop.height; + return { supported: true, mimeType: "image/png", width, height, data: encoded }; + } catch (error) { + if (error instanceof BrowserCaptureError) throw error; + throw new BrowserCaptureError("internal", error instanceof Error ? error.message.slice(0, 512) : "Screenshot capture failed", method, surfaceId(surface)); + } + })(); + this.captureFlights.set(key, { params, operation }); + try { return await operation; } finally { + if (this.captureFlights.get(key)?.operation === operation) this.captureFlights.delete(key); + } + } + + private setViewport(params: Record, surface: BrowserCaptureSurface | BrowserCaptureContents, method: string): Record | BrowserCapabilityResult { + const contents = this.ensureLive(method, surface); + if (params.reset === true) { + if (typeof contents.disableDeviceEmulation !== "function") return unsupported("Viewport emulation is unavailable"); + this.viewports.delete(surfaceKey(surface)); + contents.disableDeviceEmulation(); + return { supported: true, viewport: DEFAULT_VIEWPORT }; + } + if (params.reset !== undefined && params.reset !== false) throw new BrowserCaptureError("invalid_params", "reset must be boolean", method); + if (typeof contents.enableDeviceEmulation !== "function") return unsupported("Viewport emulation is unavailable"); + const width = positiveInteger(params.width, "width", method, MAX_VIEWPORT); + const height = positiveInteger(params.height, "height", method, MAX_VIEWPORT); + const viewport = { width, height } as const; + this.viewports.set(surfaceKey(surface), viewport); + contents.enableDeviceEmulation({ screenPosition: "desktop", screenSize: viewport, viewPosition: { x: 0, y: 0 }, viewSize: viewport, deviceScaleFactor: 1, scale: 1 }); + return { supported: true, viewport }; + } + + private setZoom(params: Record, surface: BrowserCaptureSurface | BrowserCaptureContents, method: string): Record | BrowserCapabilityResult { + const contents = this.ensureLive(method, surface); + if (typeof contents.setZoomFactor !== "function") return unsupported("Zoom control is unavailable"); + const zoom = finiteNumber(params.zoom ?? params.zoomFactor, "zoom", method); + if (zoom < 0.25 || zoom > 5) throw new BrowserCaptureError("invalid_params", "zoom must be between 0.25 and 5", method, surfaceId(surface)); + contents.setZoomFactor(zoom); + this.zooms.set(surfaceKey(surface), zoom); + return { supported: true, zoom }; + } + + private focus(surface: BrowserCaptureSurface | BrowserCaptureContents, method: string): Record | BrowserCapabilityResult { + const contents = this.ensureLive(method, surface); + if (typeof contents.focus !== "function") return unsupported("WebContents focus is unavailable"); + contents.focus(); + return { supported: true, focused: true }; + } + + private async startScreencast(params: Record, surface: BrowserCaptureSurface | BrowserCaptureContents, method: string): Promise | BrowserCapabilityResult> { + const contents = this.ensureLive(method, surface); + if (typeof contents.beginFrameSubscription !== "function" || typeof contents.endFrameSubscription !== "function") return unsupported("Screencast frame subscription is unavailable"); + if ([...this.subscriptions.values()].some((entry) => entry.contents === contents && !entry.stopped)) throw new BrowserCaptureError("invalid_state", "A screencast is already active for this surface", method, surfaceId(surface)); + const maxFrames = params.maxFrames === undefined ? MAX_FRAME_COUNT : positiveInteger(params.maxFrames, "maxFrames", method, MAX_FRAME_COUNT); + const maxFrameBytes = params.maxFrameBytes === undefined ? MAX_FRAME_BYTES : positiveInteger(params.maxFrameBytes, "maxFrameBytes", method, MAX_FRAME_BYTES); + const subscription: ScreencastSubscription = { id: `screencast:${nextSubscriptionId++}`, surface, contents, maxFrames, maxFrameBytes, frames: 0, stopped: false }; + this.subscriptions.set(subscription.id, subscription); + try { + contents.beginFrameSubscription(false, (image, dirtyRect) => { + if (subscription.stopped || subscription.frames >= subscription.maxFrames) return; + subscription.frames += 1; + const png = image.toPNG(); + if (png.byteLength <= subscription.maxFrameBytes) { + const data = Buffer.from(png).toString("base64"); + if (Buffer.byteLength(data, "ascii") <= subscription.maxFrameBytes) { + const candidate = dirtyRect && typeof dirtyRect === "object" && "width" in dirtyRect && "height" in dirtyRect && typeof dirtyRect.width === "number" && typeof dirtyRect.height === "number" ? { width: dirtyRect.width, height: dirtyRect.height } : this.viewport(surface); + const width = Number.isFinite(candidate.width) ? Math.min(MAX_VIEWPORT, Math.max(1, Math.floor(candidate.width))) : this.viewport(surface).width; + const height = Number.isFinite(candidate.height) ? Math.min(MAX_VIEWPORT, Math.max(1, Math.floor(candidate.height))) : this.viewport(surface).height; + const frameSurfaceId = surfaceId(surface); + const event: BrowserScreencastFrameEvent = { type: "browser.screencast.frame", subscriptionId: subscription.id, width, height, data, ...(frameSurfaceId === undefined ? {} : { surfaceId: frameSurfaceId }) }; + this.emitFrame?.(event); + } + } + if (subscription.frames >= subscription.maxFrames) this.stopScreencast(subscription.id); + }); + } catch (error) { + this.subscriptions.delete(subscription.id); + throw new BrowserCaptureError("internal", error instanceof Error ? error.message.slice(0, 512) : "Unable to start screencast", method, surfaceId(surface)); + } + return { supported: true, subscriptionId: subscription.id, maxFrames, maxFrameBytes }; + } + + private stopScreencast(subscriptionId: string): Record { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) return { supported: true, stopped: false, subscriptionId }; + if (!subscription.stopped) { + subscription.stopped = true; + try { subscription.contents.endFrameSubscription?.(); } catch { /* disposal is best effort */ } + } + this.subscriptions.delete(subscriptionId); + return { supported: true, stopped: true, subscriptionId, frames: subscription.frames }; + } + + private ownsTrace(params: Record): boolean { + if (params.exclusive !== true && params.exclusiveOwnership !== true) return false; + const ownership = typeof this.traceOwnership === "function" ? this.traceOwnership() : this.traceOwnership; + return ownership === true; + } + + private async startTrace(params: Record, method: string): Promise | BrowserCapabilityResult> { + if (!this.ownsTrace(params)) return unsupported("Tracing requires exclusive ownership"); + if (this.traceActive) throw new BrowserCaptureError("invalid_state", "Tracing is already active", method); + if (traceOwner !== undefined && traceOwner !== this.traceToken) return unsupported("Another owner is recording a trace"); + const included = boundedCategories(params.includedCategories ?? params.included_categories, "includedCategories", method); + const excluded = boundedCategories(params.excludedCategories ?? params.excluded_categories, "excludedCategories", method); + try { + traceOwner = this.traceToken; + await this.tracing.startRecording({ included_categories: included, excluded_categories: excluded, record_mode: "record-until-full" }); + this.traceActive = true; + return { supported: true, recording: true }; + } catch (error) { + if (traceOwner === this.traceToken) traceOwner = undefined; + throw new BrowserCaptureError("internal", error instanceof Error ? error.message.slice(0, 512) : "Unable to start tracing", method); + } + } + + private readonly traceToken = Symbol("browser-trace-owner"); + + private async stopTrace(params: Record, method: string): Promise | BrowserCapabilityResult> { + if (!this.traceActive) return { supported: true, recording: false, stopped: false }; + if (!this.ownsTrace(params)) return unsupported("Tracing requires exclusive ownership"); + const path = params.path === undefined ? undefined : boundedString(params.path, "path", MAX_TRACE_PATH_LENGTH, method); + try { + const result = await this.tracing.stopRecording(path); + this.traceActive = false; + if (traceOwner === this.traceToken) traceOwner = undefined; + return { supported: true, recording: false, stopped: true, path: typeof result === "string" ? result.slice(0, MAX_TRACE_PATH_LENGTH) : undefined }; + } catch (error) { + throw new BrowserCaptureError("internal", error instanceof Error ? error.message.slice(0, 512) : "Unable to stop tracing", method); + } + } + + async call(method: string, params: unknown, surface: BrowserCaptureSurface | BrowserCaptureContents): Promise | BrowserScreenshotResult | BrowserCapabilityResult> { + const input = record(params, method); + switch (method) { + case "surface.screenshot": + case "browser.screenshot": + return this.screenshot(input, surface, method); + case "browser.viewport.set": { + const requested = snapshotRequested(input, method); + const result = this.setViewport(input, surface, method); + return { ...result, ...(await postActionSnapshot(surface, requested, method)) }; + } + case "browser.zoom.set": { + const requested = snapshotRequested(input, method); + const result = this.setZoom(input, surface, method); + return { ...result, ...(await postActionSnapshot(surface, requested, method)) }; + } + case "browser.focus_webview": + case "surface.focusWebView": { + const requested = snapshotRequested(input, method); + const result = this.focus(surface, method); + return { ...result, ...(await postActionSnapshot(surface, requested, method)) }; + } + case "browser.is_webview_focused": { + const contents = this.ensureLive(method, surface); + return { supported: true, focused: contents.isFocused?.() === true }; + } + case "browser.screencast.start": + return this.startScreencast(input, surface, method); + case "browser.screencast.stop": + return this.stopScreencast(typeof input.subscriptionId === "string" ? input.subscriptionId : ""); + case "browser.trace.start": + return this.startTrace(input, method); + case "browser.trace.stop": + return this.stopTrace(input, method); + default: + return unsupported(`Capture capability ${method} is not supported`); + } + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const id of this.subscriptions.keys()) this.stopScreencast(id); + if (this.traceActive && traceOwner === this.traceToken) { + void this.tracing.stopRecording().catch(() => undefined); + traceOwner = undefined; + this.traceActive = false; + } + this.viewports.clear(); + this.zooms.clear(); + this.captureFlights.clear(); + } +} diff --git a/apps/desktop/src/browser-content-channels.ts b/apps/desktop/src/browser-content-channels.ts new file mode 100644 index 0000000..7d34f67 --- /dev/null +++ b/apps/desktop/src/browser-content-channels.ts @@ -0,0 +1,2 @@ +export const BROWSER_CONTENT_REQUEST_CHANNEL = "t4-browser:content:request" as const; +export const BROWSER_CONTENT_RESPONSE_CHANNEL = "t4-browser:content:response" as const; diff --git a/apps/desktop/src/browser-content-preload.ts b/apps/desktop/src/browser-content-preload.ts new file mode 100644 index 0000000..8f91cbd --- /dev/null +++ b/apps/desktop/src/browser-content-preload.ts @@ -0,0 +1,241 @@ +import { ipcRenderer } from "electron"; +import { BrowserDomAutomationError, executeBrowserDomAutomation, resetBrowserDomAutomation } from "./browser-dom-automation.ts"; +import { BROWSER_CONTENT_REQUEST_CHANNEL, BROWSER_CONTENT_RESPONSE_CHANNEL } from "./browser-content-channels.ts"; + +const MAX_ID_BYTES = 128; +const MAX_METHOD_BYTES = 128; +const MAX_PARAMS_BYTES = 256 * 1024; +const MAX_PARAMS_DEPTH = 8; +const MAX_EVENT_BYTES = 32 * 1024; +const MAX_STRING_BYTES = 8 * 1024; +const MAX_EVENT_ITEMS = 32; +const MAX_EVENT_KEYS = 64; + +type ErrorCode = "invalid_params" | "not_found" | "invalid_state" | "not_supported" | "timeout" | "security" | "internal"; +type JsonPrimitive = boolean | number | string | null; +type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; + +interface ContentRequest { + readonly requestId: string; + readonly method: string; + readonly params: Record; +} + +interface ContentSuccess { + readonly requestId: string; + readonly ok: true; + readonly result: JsonValue; +} + +interface ContentFailure { + readonly requestId: string | null; + readonly ok: false; + readonly error: { readonly code: ErrorCode; readonly message: string }; +} + +interface ContentEvent { + readonly requestId: null; + readonly event: { readonly type: "console" | "error"; readonly payload: JsonValue }; +} + +const textEncoder = new TextEncoder(); + +function byteLength(value: string): number { + return textEncoder.encode(value).byteLength; +} + +function boundedString(value: string, maxBytes = MAX_STRING_BYTES): string { + if (byteLength(value) <= maxBytes) return value; + let end = value.length; + while (end > 0 && byteLength(value.slice(0, end)) > maxBytes) end -= 1; + return value.slice(0, end); +} + +function isRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isJsonValue(value: unknown, depth: number, seen: WeakSet): value is JsonValue { + if (depth > MAX_PARAMS_DEPTH) return false; + if (value === null || typeof value === "boolean" || typeof value === "string") return true; + if (typeof value === "number") return Number.isFinite(value); + if (typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null && !Array.isArray(value)) return false; + if (seen.has(value)) return false; + seen.add(value); + try { + if (Array.isArray(value)) return value.every((item) => isJsonValue(item, depth + 1, seen)); + const record = value as Record; + return Object.keys(record).every((key) => isJsonValue(record[key], depth + 1, seen)); + } finally { + seen.delete(value); + } +} + +function serializeParams(params: Record): string | undefined { + if (!isJsonValue(params, 0, new WeakSet())) return undefined; + try { + const serialized = JSON.stringify(params); + return serialized !== undefined && byteLength(serialized) <= MAX_PARAMS_BYTES ? serialized : undefined; + } catch { + return undefined; + } +} + +function requestIdFrom(value: unknown): string | null { + return typeof value === "string" && byteLength(value) <= MAX_ID_BYTES ? value : null; +} + +function validateRequest(value: unknown): ContentRequest | ContentFailure { + if (!isRecord(value)) return invalidRequest(null); + const requestId = requestIdFrom(value.requestId); + const method = value.method; + const params = value.params; + if (requestId === null) return invalidRequest(null); + if (typeof method !== "string" || byteLength(method) > MAX_METHOD_BYTES) return invalidRequest(requestId); + if (!isRecord(params) || serializeParams(params) === undefined) return invalidRequest(requestId); + const keys = Object.keys(value); + if (keys.length !== 3 || !keys.every((key) => key === "requestId" || key === "method" || key === "params")) return invalidRequest(requestId); + return { requestId, method, params }; +} + +function invalidRequest(requestId: string | null): ContentFailure { + return { requestId, ok: false, error: { code: "invalid_params", message: "Invalid content request envelope" } }; +} + +function safeJson(value: unknown, depth = 0, seen = new WeakSet()): JsonValue { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") return boundedString(value); + if (typeof value === "number") return Number.isFinite(value) ? value : String(value); + if (typeof value === "bigint") return boundedString(value.toString()); + if (typeof value === "undefined") return null; + if (typeof value === "function" || typeof value === "symbol") return boundedString(String(value)); + if (depth >= 8) return "[depth limit]"; + if (seen.has(value)) return "[circular]"; + seen.add(value); + try { + if (value instanceof Error) { + return { + name: boundedString(value.name), + message: boundedString(value.message), + ...(value.stack === undefined ? {} : { stack: boundedString(value.stack) }), + }; + } + if (Array.isArray(value)) return value.slice(0, MAX_EVENT_ITEMS).map((item) => safeJson(item, depth + 1, seen)); + const output: Record = {}; + const record = value as Record; + for (const key of Object.keys(record).slice(0, MAX_EVENT_KEYS)) { + try { + output[boundedString(key)] = safeJson(record[key], depth + 1, seen); + } catch { + output[boundedString(key)] = "[unavailable]"; + } + } + return output; + } catch { + return "[unserializable]"; + } finally { + seen.delete(value); + } +} + +function boundedEventPayload(value: unknown): JsonValue { + const payload = safeJson(value); + try { + if (byteLength(JSON.stringify(payload)) <= MAX_EVENT_BYTES) return payload; + } catch { + // Fall through to a deterministic bounded value. + } + return { truncated: true }; +} + +function send(value: ContentSuccess | ContentFailure | ContentEvent): void { + try { + ipcRenderer.send(BROWSER_CONTENT_RESPONSE_CHANNEL, value); + } catch { + // The receiving WebContents can disappear while a page event is in flight. + } +} + +function metadata(): JsonValue { + return { + title: boundedString(document.title), + url: boundedString(document.URL), + readyState: document.readyState, + charset: boundedString(document.characterSet), + }; +} + +async function dispatch(request: ContentRequest): Promise { + if (request.method === "content.ping") { + return { requestId: request.requestId, ok: true, result: { pong: true } }; + } + if (request.method === "content.document_metadata") { + return { requestId: request.requestId, ok: true, result: metadata() }; + } + try { + const result = await executeBrowserDomAutomation(request.method, request.params); + return { requestId: request.requestId, ok: true, result: safeJson(result) }; + } catch (error) { + if (error instanceof BrowserDomAutomationError) { + return { requestId: request.requestId, ok: false, error: { code: error.code, message: boundedString(error.message, MAX_STRING_BYTES) } }; + } + return { requestId: request.requestId, ok: false, error: { code: "internal", message: "Content request failed" } }; + } +} + +async function handleRequest(value: unknown): Promise { + let request: ContentRequest | ContentFailure; + try { + request = validateRequest(value); + } catch { + send(invalidRequest(null)); + return; + } + if ("method" in request) { + send(await dispatch(request)); + } else { + send(request); + } +} + + +function emitEvent(type: "console" | "error", payload: unknown): void { + send({ requestId: null, event: { type, payload: boundedEventPayload(payload) } }); +} + +function installPageObservers(): void { + if (typeof window === "undefined") return; + + window.addEventListener("error", (event) => { + emitEvent("error", { + kind: "error", + message: boundedString(event.message), + filename: boundedString(event.filename), + lineno: event.lineno, + colno: event.colno, + ...(event.error === undefined ? {} : { error: safeJson(event.error) }), + }); + }); + window.addEventListener("unhandledrejection", (event) => { + emitEvent("error", { kind: "unhandledrejection", reason: safeJson(event.reason) }); + }); + + for (const level of ["debug", "info", "log", "warn", "error"] as const) { + const original = console[level]; + console[level] = (...args: unknown[]) => { + emitEvent("console", { level, args: args.slice(0, MAX_EVENT_ITEMS).map((arg) => safeJson(arg)) }); + original.apply(console, args); + }; + } +} + +ipcRenderer.on(BROWSER_CONTENT_REQUEST_CHANNEL, (_event, value: unknown) => { void handleRequest(value); }); +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => resetBrowserDomAutomation(), { once: true }); + window.addEventListener("pageshow", () => resetBrowserDomAutomation()); +} +installPageObservers(); diff --git a/apps/desktop/src/browser-dialogs.ts b/apps/desktop/src/browser-dialogs.ts new file mode 100644 index 0000000..af0ef3b --- /dev/null +++ b/apps/desktop/src/browser-dialogs.ts @@ -0,0 +1,289 @@ +import { dialog, type BrowserWindow, type MessageBoxOptions, type OpenDialogOptions, type Session, type WebContents } from "electron"; +import { realpath, stat } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; +import type { SurfaceId } from "@t4-code/protocol/browser-ipc"; + +const MAX_QUEUE = 32; +const DEFAULT_PROJECT_ROOT = process.cwd(); + +export interface BrowserDialogControllerOptions { + readonly window?: BrowserWindow; + readonly session?: Session; + readonly projectRoot?: string; + readonly maxQueue?: number; + readonly nativeDialog?: NativeBrowserDialog; +} + +export type NativeBrowserDialog = Pick; + +export interface BrowserFileChooserOptions { + readonly multiple?: boolean; + readonly directory?: boolean; + readonly projectRoot?: string; + readonly allowOutsideProject?: boolean; +} + +export interface BrowserJavaScriptDialogResult { + readonly accepted: boolean; + readonly value?: string; +} + +export interface BrowserJavaScriptDialogOptions { + readonly kind: "alert" | "confirm" | "prompt"; + readonly message: string; + readonly defaultValue?: string; + readonly title?: string; +} + +interface HookableWebContents { + on(event: string, listener: (...args: unknown[]) => void): this; + removeListener(event: string, listener: (...args: unknown[]) => void): this; +} + +interface DialogJob { + readonly run: () => Promise; + readonly resolve: (value: unknown) => void; + readonly fallback: unknown; + settled: boolean; +} + +interface SurfaceQueue { + readonly webContents: WebContents; + readonly jobs: DialogJob[]; + readonly listeners: Array<{ event: string; listener: (...args: unknown[]) => void }>; + active: DialogJob | undefined; + disposed: boolean; + draining: boolean; +} + +const defaultNativeDialog: NativeBrowserDialog = dialog; + +/** Coordinates native file and JavaScript/permission dialogs per browser surface. */ +export class BrowserDialogController { + private readonly window: BrowserWindow | undefined; + private readonly projectRoot: string; + private readonly maxQueue: number; + private readonly nativeDialog: NativeBrowserDialog; + private readonly surfaces = new Map(); + private disposed = false; + + public constructor(options: BrowserDialogControllerOptions) { + this.window = options.window; + this.projectRoot = resolve(options.projectRoot ?? DEFAULT_PROJECT_ROOT); + this.maxQueue = Math.max(1, Math.min(options.maxQueue ?? MAX_QUEUE, MAX_QUEUE)); + this.nativeDialog = options.nativeDialog ?? defaultNativeDialog; + } + + /** Install event hooks when an embedding WebContents supports them. */ + public install(webContents: WebContents, surfaceId: SurfaceId): void { + if (this.disposed) return; + this.disposeSurface(surfaceId); + const queue: SurfaceQueue = { webContents, jobs: [], listeners: [], active: undefined, disposed: false, draining: false }; + this.surfaces.set(surfaceId, queue); + const hooks = webContents as unknown as HookableWebContents; + + const fileListener = (...args: unknown[]): void => { + const event = args[0]; + if (isPreventable(event)) event.preventDefault(); + const request = isRecord(args[1]) ? args[1] : isRecord(args[0]) ? args[0] : {}; + const callback = readCallback(args[1]) ?? readCallback(args[2]) ?? readCallback(request.callback); + void this.handleFileChooser(surfaceId, request as BrowserFileChooserOptions).then((paths) => callback?.(paths)); + }; + const dialogListener = (...args: unknown[]): void => { + const event = args[0]; + if (isPreventable(event)) event.preventDefault(); + const request = isRecord(args[1]) ? args[1] : {}; + const callback = readCallback(args[2]) ?? readCallback(request.callback); + const kind = request.kind === "confirm" || request.kind === "prompt" ? request.kind : "alert"; + void this.handleJavaScriptDialog(surfaceId, { + kind, + message: typeof request.message === "string" ? request.message : "", + ...(typeof request.defaultValue === "string" ? { defaultValue: request.defaultValue } : {}), + ...(typeof request.title === "string" ? { title: request.title } : {}), + }).then((result) => callback?.(result)); + }; + const permissionListener = (...args: unknown[]): void => { + const callback = readCallback(args[2]) ?? readCallback(args[3]); + const permission = typeof args[1] === "string" ? args[1] : "unknown"; + void this.handlePermissionRequest(surfaceId, permission).then((allowed) => callback?.(allowed)); + }; + + for (const [event, listener] of [["select-file", fileListener], ["javascript-dialog", dialogListener], ["permission-request", permissionListener]] as const) { + hooks.on(event, listener); + queue.listeners.push({ event, listener }); + } + } + + public async handleFileChooser(surfaceId: SurfaceId, options: BrowserFileChooserOptions = {}): Promise { + const queue = this.surfaces.get(surfaceId); + if (queue === undefined || queue.disposed || this.disposed) return []; + return this.enqueue(queue, async () => { + const directory = options.directory === true; + const properties: NonNullable = directory + ? ["openDirectory", "createDirectory"] + : ["openFile", ...(options.multiple === true ? ["multiSelections" as const] : [])]; + const openDialogOptions: OpenDialogOptions = { + title: directory ? "Choose a directory" : "Choose files to upload", + defaultPath: options.projectRoot ?? this.projectRoot, + properties, + }; + const selected = this.window === undefined + ? await this.nativeDialog.showOpenDialog(openDialogOptions) + : await this.nativeDialog.showOpenDialog(this.window, openDialogOptions); + if (selected.canceled) return []; + const root = resolve(options.projectRoot ?? this.projectRoot); + const paths: string[] = []; + for (const selectedPath of selected.filePaths.slice(0, options.multiple === true && !directory ? 64 : 1)) { + const checked = await this.confinedPath(selectedPath, root, directory, options.allowOutsideProject === true); + if (checked !== undefined) paths.push(checked); + } + return paths; + }, []); + } + + public handleJavaScriptDialog(surfaceId: SurfaceId, options: BrowserJavaScriptDialogOptions): Promise { + const queue = this.surfaces.get(surfaceId); + if (queue === undefined || queue.disposed || this.disposed) return Promise.resolve({ accepted: false }); + return this.enqueue(queue, async () => { + const message = options.message.slice(0, 16_384); + const buttons = options.kind === "alert" ? ["OK"] : ["OK", "Cancel"]; + const box: MessageBoxOptions = { + type: options.kind === "alert" ? "info" : "question", + title: options.title?.slice(0, 512) ?? "Browser dialog", + message, + buttons, + defaultId: 0, + cancelId: options.kind === "alert" ? 0 : 1, + ...(options.kind === "prompt" && options.defaultValue !== undefined ? { detail: `Default value: ${options.defaultValue.slice(0, 4_096)}` } : {}), + }; + const result = this.window === undefined + ? await this.nativeDialog.showMessageBox(box) + : await this.nativeDialog.showMessageBox(this.window, box); + const accepted = result.response === 0; + return options.kind === "prompt" ? { accepted, ...(accepted ? { value: options.defaultValue ?? "" } : {}) } : { accepted }; + }, { accepted: false }); + } + + public handlePermissionRequest(surfaceId: SurfaceId, permission: string, origin?: string): Promise { + const queue = this.surfaces.get(surfaceId); + if (queue === undefined || queue.disposed || this.disposed) return Promise.resolve(false); + return this.enqueue(queue, async () => { + const result = this.window === undefined + ? await this.nativeDialog.showMessageBox({ + type: "question", + title: "Browser permission request", + message: `Allow ${permission} permission?`, + ...(origin === undefined ? {} : { detail: origin.slice(0, 2_048) }), + buttons: ["Allow", "Deny"], + defaultId: 1, + cancelId: 1, + }) + : await this.nativeDialog.showMessageBox(this.window, { + type: "question", + title: "Browser permission request", + message: `Allow ${permission} permission?`, + ...(origin === undefined ? {} : { detail: origin.slice(0, 2_048) }), + buttons: ["Allow", "Deny"], + defaultId: 1, + cancelId: 1, + }); + return result.response === 0; + }, false); + } + + public disposeSurface(surfaceId: SurfaceId): void { + const queue = this.surfaces.get(surfaceId); + if (queue === undefined) return; + queue.disposed = true; + const hooks = queue.webContents as unknown as HookableWebContents; + for (const { event, listener } of queue.listeners) hooks.removeListener(event, listener); + queue.listeners.length = 0; + for (const job of queue.jobs.splice(0)) { + job.settled = true; + job.resolve(job.fallback); + } + if (queue.active !== undefined) { + queue.active.settled = true; + queue.active.resolve(queue.active.fallback); + } + this.surfaces.delete(surfaceId); + } + + public dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const surfaceId of this.surfaces.keys()) this.disposeSurface(surfaceId); + } + + private enqueue(queue: SurfaceQueue, run: () => Promise, fallback: T): Promise { + if (queue.disposed || this.disposed || queue.jobs.length >= this.maxQueue) return Promise.resolve(fallback); + const { promise, resolve } = Promise.withResolvers(); + const job: DialogJob = { + run: async () => run(), + resolve: (value) => resolve(value as T), + fallback, + settled: false, + }; + queue.jobs.push(job); + void this.drain(queue); + return promise; + } + + private async drain(queue: SurfaceQueue): Promise { + if (queue.draining) return; + queue.draining = true; + try { + while (!queue.disposed) { + const job = queue.jobs.shift(); + if (job === undefined) break; + queue.active = job; + try { + const value = await job.run(); + if (!job.settled) { + job.settled = true; + job.resolve(value); + } + } catch { + if (!job.settled) { + job.settled = true; + job.resolve(job.fallback); + } + } finally { + queue.active = undefined; + } + } + } finally { + queue.draining = false; + } + } + + private async confinedPath(value: string, root: string, directory: boolean, allowOutside: boolean): Promise { + const candidate = resolve(value); + if (!isAbsolute(candidate)) return undefined; + try { + const canonical = await realpath(candidate); + const info = await stat(canonical); + if (directory ? !info.isDirectory() : !info.isFile()) return undefined; + if (allowOutside) return canonical; + const rootCanonical = await realpath(root); + const distance = relative(rootCanonical, canonical); + if (distance === "" || (!distance.startsWith("..") && !isAbsolute(distance))) return canonical; + } catch { + return undefined; + } + return undefined; + } +} + + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isPreventable(value: unknown): value is { preventDefault(): void } { + return isRecord(value) && typeof value.preventDefault === "function"; +} + +function readCallback(value: unknown): ((value: unknown) => void) | undefined { + return typeof value === "function" ? value as (value: unknown) => void : undefined; +} diff --git a/apps/desktop/src/browser-dom-automation.ts b/apps/desktop/src/browser-dom-automation.ts new file mode 100644 index 0000000..24390b8 --- /dev/null +++ b/apps/desktop/src/browser-dom-automation.ts @@ -0,0 +1,338 @@ +const MAX_STRING_BYTES = 32 * 1024; +const MAX_PROMPT_BYTES = 4_000; +const MAX_ELEMENTS = 512; +const MAX_SNAPSHOT_ELEMENTS = 256; +const MAX_DEPTH = 12; +const MAX_TIMEOUT_MS = 30_000; +const MAX_SCRIPT_BYTES = 64 * 1024; + +type ErrorCode = "invalid_params" | "not_found" | "invalid_state" | "not_supported" | "timeout" | "security" | "internal"; + +export class BrowserDomAutomationError extends Error { + readonly code: ErrorCode; + constructor(code: ErrorCode, message: string) { + super(message); + this.name = "BrowserDomAutomationError"; + this.code = code; + } +} + +type JsonValue = null | boolean | number | string | JsonValue[] | { readonly [key: string]: JsonValue }; +interface RefEntry { readonly ref: string; readonly element: Element; } +interface SnapshotElement { + readonly ref: string; + readonly role: string; + readonly name: string; + readonly text?: string; + readonly value?: string; + readonly bounds?: { x: number; y: number; width: number; height: number }; + readonly disabled?: boolean; + readonly checked?: boolean; + readonly expanded?: boolean; + readonly children?: readonly SnapshotElement[]; +} + +const refs = new Map(); +const elements = new WeakMap(); +let nextRef = 1; +let activeDocument: Document | null = null; +let designMode = false; +let designPrompt = ""; +let designOverlay: HTMLElement | null = null; +let savedState: JsonValue | null = null; +const dialogQueue: Array<{ type: "alert" | "confirm" | "prompt"; message: string; defaultValue?: string }> = []; +let dialogInstalled = false; + +const encoder = new TextEncoder(); +function bytes(value: string): number { return encoder.encode(value).byteLength; } +function bound(value: string, limit = MAX_STRING_BYTES): string { + if (bytes(value) <= limit) return value; + let end = value.length; + while (end > 0 && bytes(value.slice(0, end)) > limit) end -= 1; + return value.slice(0, end); +} +function text(value: unknown, name: string, limit = MAX_STRING_BYTES): string { + if (typeof value !== "string" || bytes(value) > limit) throw new BrowserDomAutomationError("invalid_params", `${name} must be bounded text`); + return value; +} +function record(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new BrowserDomAutomationError("invalid_params", "params must be an object"); + return value as Record; +} +function finiteNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? Math.max(-32768, Math.min(32768, value)) : fallback; +} +function documentRoot(): Document { + if (typeof document === "undefined") throw new BrowserDomAutomationError("invalid_state", "Document is unavailable"); + return activeDocument ?? document; +} +function rootElement(): Element { return documentRoot().documentElement; } +function roleFor(element: Element): string { + const explicit = element.getAttribute("role"); + if (explicit) return bound(explicit, 128); + const tag = element.tagName.toLowerCase(); + if (tag === "a" && element.hasAttribute("href")) return "link"; + if (tag === "button") return "button"; + if (tag === "textarea") return "textbox"; + if (tag === "select") return "combobox"; + if (tag === "img") return "img"; + if (tag === "input") return (element as HTMLInputElement).type === "checkbox" ? "checkbox" : (element as HTMLInputElement).type === "radio" ? "radio" : "textbox"; + if (/^h[1-6]$/u.test(tag)) return "heading"; + if (tag === "nav") return "navigation"; + if (tag === "main") return "main"; + if (tag === "form") return "form"; + if (tag === "ul" || tag === "ol") return "list"; + if (tag === "li") return "listitem"; + return tag === "body" ? "document" : "generic"; +} +function accessibleName(element: Element): string { + const aria = element.getAttribute("aria-label"); + if (aria) return bound(aria); + const labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy) return bound(labelledBy.split(/\s+/u).map((id) => documentRoot().getElementById(id)?.textContent ?? "").join(" ").trim()); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) { + if (element.id) { + const label = documentRoot().querySelector(`label[for="${CSS.escape(element.id)}"]`); + if (label) return bound(label.textContent?.trim() ?? ""); + } + const placeholder = element.getAttribute("placeholder"); + if (placeholder) return bound(placeholder); + } + if (element instanceof HTMLImageElement && element.alt) return bound(element.alt); + return bound((element.textContent ?? "").replace(/\s+/gu, " ").trim(), 8_192); +} +function elementRef(element: Element): string { + const old = elements.get(element); + if (old && refs.get(old)?.element === element) return old; + const ref = `@e${nextRef++}`; + elements.set(element, ref); + refs.set(ref, { ref, element }); + return ref; +} +function checkRef(ref: unknown): Element { + if (typeof ref !== "string" || !/^@e[1-9][0-9]{0,8}$/u.test(ref)) throw new BrowserDomAutomationError("not_found", "Element reference was not found"); + const entry = refs.get(ref); + if (!entry || !entry.element.isConnected || (activeDocument && entry.element.ownerDocument !== activeDocument)) throw new BrowserDomAutomationError("not_found", "Element reference was not found"); + return entry.element; +} +function target(params: Record, required = true): Element | null { + if (params.ref !== undefined) return checkRef(params.ref); + if (params.selector !== undefined) { + const selector = text(params.selector, "selector", 4_096); + try { + const found = documentRoot().querySelector(selector); + if (!found) throw new BrowserDomAutomationError("not_found", "Selector did not match an element"); + return found; + } catch (error) { + if (error instanceof BrowserDomAutomationError) throw error; + throw new BrowserDomAutomationError("invalid_params", "Invalid selector"); + } + } + if (required) throw new BrowserDomAutomationError("invalid_params", "ref or selector is required"); + return null; +} +function all(selector: string): Element[] { + try { return Array.from(documentRoot().querySelectorAll(selector)).slice(0, MAX_ELEMENTS); } + catch { throw new BrowserDomAutomationError("invalid_params", "Invalid selector"); } +} +function boundsOf(element: Element): { x: number; y: number; width: number; height: number } { + const rect = element.getBoundingClientRect(); + return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }; +} +function isVisible(element: Element): boolean { + if (!element.isConnected) return false; + const style = (element.ownerDocument.defaultView ?? window).getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0; +} +function isDisabled(element: Element): boolean { + return (element as HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).disabled === true || element.getAttribute("aria-disabled") === "true" || element.closest("fieldset[disabled]") !== null; +} +function snapshotNode(element: Element, depth: number, budget: { count: number }): SnapshotElement { + if (budget.count >= MAX_SNAPSHOT_ELEMENTS) return { ref: elementRef(element), role: roleFor(element), name: accessibleName(element) }; + budget.count += 1; + const result: SnapshotElement = { + ref: elementRef(element), role: roleFor(element), name: accessibleName(element), + ...(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement ? { value: bound(element.value) } : {}), + ...(element.children.length === 0 && element.textContent?.trim() ? { text: bound(element.textContent.trim(), 8_192) } : {}), + ...(isVisible(element) ? { bounds: boundsOf(element) } : {}), + ...(isDisabled(element) ? { disabled: true } : {}), + ...(element instanceof HTMLInputElement && (element.type === "checkbox" || element.type === "radio") ? { checked: element.checked } : {}), + ...(element.getAttribute("aria-expanded") !== null ? { expanded: element.getAttribute("aria-expanded") === "true" } : {}), + }; + if (depth >= MAX_DEPTH || budget.count >= MAX_SNAPSHOT_ELEMENTS) return result; + const children: SnapshotElement[] = []; + for (const child of Array.from(element.children).slice(0, MAX_SNAPSHOT_ELEMENTS)) { + if (budget.count >= MAX_SNAPSHOT_ELEMENTS) break; + children.push(snapshotNode(child, depth + 1, budget)); + } + return children.length ? { ...result, children } : result; +} +function snapshot(): JsonValue { + const doc = documentRoot(); + const body = doc.body ?? rootElement(); + const flat: SnapshotElement[] = []; + const visit = (element: Element): void => { + if (flat.length >= MAX_SNAPSHOT_ELEMENTS) return; + flat.push(snapshotNode(element, MAX_DEPTH, { count: 0 })); + for (const child of Array.from(element.children)) visit(child); + }; + visit(body); + return json({ + url: bound(doc.URL), title: bound(doc.title), readyState: doc.readyState, + viewport: { x: 0, y: 0, width: Math.max(0, window.innerWidth), height: Math.max(0, window.innerHeight) }, + tree: snapshotNode(body, 0, { count: 0 }), elements: flat, capturedAt: Date.now(), truncated: flat.length >= MAX_SNAPSHOT_ELEMENTS, + }); +} +function postAction(params: Record): Record { + return params.snapshotAfter === true ? { postActionSnapshot: snapshot() } : {}; +} +function dispatchInput(element: Element): void { + element.dispatchEvent(new Event("input", { bubbles: true, composed: true })); + element.dispatchEvent(new Event("change", { bubbles: true, composed: true })); +} +function focus(element: Element): void { + if (typeof (element as HTMLElement).focus !== "function") throw new BrowserDomAutomationError("not_supported", "Element cannot receive focus"); + (element as HTMLElement).focus(); +} +function setText(element: Element, value: string): void { + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + const setter = Object.getOwnPropertyDescriptor(element.constructor.prototype, "value")?.set; + if (setter) setter.call(element, value); else element.value = value; + dispatchInput(element); return; + } + if (element instanceof HTMLElement && element.isContentEditable) { element.textContent = value; dispatchInput(element); return; } + throw new BrowserDomAutomationError("not_supported", "Element is not editable"); +} +function keyEvent(type: "keydown" | "keyup", key: string, params: Record): boolean { + const modifiers = Array.isArray(params.modifiers) ? params.modifiers : []; + const event = new KeyboardEvent(type, { key, code: key, bubbles: true, cancelable: true, composed: true, altKey: modifiers.includes("Alt"), ctrlKey: modifiers.includes("Control"), metaKey: modifiers.includes("Meta"), shiftKey: modifiers.includes("Shift") }); + return documentRoot().activeElement?.dispatchEvent(event) ?? false; +} +function json(value: unknown, depth = 0, seen = new WeakSet()): JsonValue { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") return bound(value); + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "undefined") return null; + if (typeof value !== "object") return bound(String(value)); + if (depth >= 8 || seen.has(value)) return "[unavailable]"; + seen.add(value); + try { + if (Array.isArray(value)) return value.slice(0, MAX_ELEMENTS).map((item) => json(item, depth + 1, seen)); + const out: Record = {}; + for (const key of Object.keys(value as object).slice(0, 128)) out[bound(key, 256)] = json((value as Record)[key], depth + 1, seen); + return out; + } finally { seen.delete(value); } +} +function timeout(params: Record): number { return Math.max(0, Math.min(MAX_TIMEOUT_MS, typeof params.timeoutMs === "number" ? params.timeoutMs : 5_000)); } +async function waitFor(params: Record): Promise { + const kind = typeof params.kind === "string" ? params.kind : params.type; + const expected = typeof params.value === "string" ? params.value : params.selector; + const deadline = Date.now() + timeout(params); + const check = (): boolean => { + if (kind === "load") return documentRoot().readyState === "complete"; + if (kind === "url") return typeof expected === "string" && documentRoot().URL.includes(expected); + if (kind === "text") return typeof expected === "string" && (documentRoot().body?.innerText ?? "").includes(expected); + if (kind === "function") throw new BrowserDomAutomationError("not_supported", "Function waits must run through native browser automation"); + if (typeof expected === "string") return documentRoot().querySelector(expected) !== null; + return false; + }; + while (Date.now() <= deadline) { + if (check()) return { matched: true }; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new BrowserDomAutomationError("timeout", "Wait timed out"); +} +function findBy(kind: string, value: string): Element[] { + const needle = value.toLocaleLowerCase(); + const candidates = Array.from(documentRoot().querySelectorAll("*")); + return candidates.filter((element) => { + if (kind === "role") return roleFor(element).toLocaleLowerCase() === needle; + if (kind === "text") return (element.textContent ?? "").toLocaleLowerCase().includes(needle); + if (kind === "label") return accessibleName(element).toLocaleLowerCase().includes(needle); + if (kind === "placeholder") return (element.getAttribute("placeholder") ?? "").toLocaleLowerCase().includes(needle); + if (kind === "alt") return (element.getAttribute("alt") ?? "").toLocaleLowerCase().includes(needle); + if (kind === "title") return (element.getAttribute("title") ?? "").toLocaleLowerCase().includes(needle); + if (kind === "testid") return (element.getAttribute("data-testid") ?? "").toLocaleLowerCase() === needle; + return false; + }).slice(0, MAX_ELEMENTS); +} +function resultElements(found: Element[]): JsonValue { return json({ elements: found.slice(0, MAX_ELEMENTS).map((element) => ({ ref: elementRef(element), role: roleFor(element), name: accessibleName(element), ...(isVisible(element) ? { bounds: boundsOf(element) } : {}) })) }); } +function styleResult(element: Element): JsonValue { + const style = (element.ownerDocument.defaultView ?? window).getComputedStyle(element); + const keys = ["display", "visibility", "opacity", "color", "backgroundColor", "fontSize", "fontFamily", "position", "width", "height"]; + const output: Record = {}; for (const key of keys) output[key] = bound(style.getPropertyValue(key), 512); return output; +} +function installDialogs(): void { + if (dialogInstalled || typeof window === "undefined") return; + dialogInstalled = true; + const original = { alert: window.alert, confirm: window.confirm, prompt: window.prompt }; + window.alert = (message?: unknown) => { dialogQueue.push({ type: "alert", message: bound(String(message ?? "")) }); }; + window.confirm = (message?: unknown) => { dialogQueue.push({ type: "confirm", message: bound(String(message ?? "")) }); return false; }; + window.prompt = (message?: unknown, defaultValue?: string) => { dialogQueue.push({ type: "prompt", message: bound(String(message ?? "")), defaultValue: bound(defaultValue ?? "") }); return null; }; + void original; +} +function designModeStatus(): JsonValue { return { enabled: designMode, prompt: bound(designPrompt, MAX_PROMPT_BYTES), selection: bound(window.getSelection()?.toString() ?? "", 4_000) }; } +function setDesignMode(params: Record): JsonValue { + designMode = params.enabled === true; + designPrompt = typeof params.prompt === "string" ? bound(params.prompt, MAX_PROMPT_BYTES) : ""; + documentRoot().designMode = designMode ? "on" : "off"; + documentRoot().body?.setAttribute("contenteditable", designMode ? "true" : "false"); + if (designMode && !designOverlay) { designOverlay = documentRoot().createElement("div"); designOverlay.setAttribute("data-t4-design-overlay", "true"); designOverlay.textContent = designPrompt; Object.assign(designOverlay.style, { position: "fixed", top: "4px", right: "4px", zIndex: "2147483647", maxWidth: "300px", padding: "4px", background: "#222", color: "#fff", font: "12px sans-serif", pointerEvents: "none" }); documentRoot().body?.append(designOverlay); } + if (!designMode && designOverlay) { designOverlay.remove(); designOverlay = null; } + return designModeStatus(); +} +function storage(area: "local" | "session"): Storage { try { return area === "local" ? window.localStorage : window.sessionStorage; } catch { throw new BrowserDomAutomationError("security", "Storage is unavailable"); } } + +export function resetBrowserDomAutomation(): void { + refs.clear(); nextRef = 1; activeDocument = null; + if (designOverlay) { designOverlay.remove(); designOverlay = null; } + designMode = false; designPrompt = ""; savedState = null; dialogQueue.length = 0; +} + +export async function executeBrowserDomAutomation(method: string, rawParams: Record): Promise { + installDialogs(); + const params = record(rawParams); + const name = method.replace(/^browser\./u, "").replaceAll("-", "_"); + if (name === "snapshot") return { snapshot: snapshot() }; + if (name === "eval") throw new BrowserDomAutomationError("not_supported", "Evaluation must run through native browser automation"); + if (name === "wait") return waitFor(params); + if (name === "design_mode.status") return designModeStatus(); + if (name === "design_mode.set") return setDesignMode(params); + if (name === "frame.main") { activeDocument = null; refs.clear(); return { ok: true }; } + if (name === "frame.select") { + const frame = target(params, true); + if (!(frame instanceof HTMLIFrameElement || frame instanceof HTMLFrameElement)) throw new BrowserDomAutomationError("not_found", "Frame was not found"); + try { if (!frame.contentDocument) throw new Error(); activeDocument = frame.contentDocument; refs.clear(); return { ok: true, url: bound(activeDocument.URL) }; } catch { throw new BrowserDomAutomationError("security", "Frame is not same-origin"); } + } + if (name === "state.save") { savedState = json({ url: documentRoot().URL, scrollX: window.scrollX, scrollY: window.scrollY, values: Array.from(documentRoot().querySelectorAll("input,textarea,select")).slice(0, MAX_ELEMENTS).map((element) => ({ id: element.id, value: (element as HTMLInputElement).value })) }); return { saved: true }; } + if (name === "state.load") { if (savedState && typeof savedState === "object" && !Array.isArray(savedState)) { const values = (savedState as Record).values; if (Array.isArray(values)) for (const value of values) if (value && typeof value === "object" && !Array.isArray(value) && typeof value.id === "string" && typeof value.value === "string") { const element = documentRoot().getElementById(value.id); if (element) setText(element, value.value); } } return { loaded: savedState !== null }; } + if (name === "addinitscript" || name === "addscript") throw new BrowserDomAutomationError("not_supported", "Scripts must run through native browser automation"); + if (name === "addstyle") { const source = text(params.style ?? params.css, "style", MAX_SCRIPT_BYTES); const style = documentRoot().createElement("style"); style.textContent = source; (documentRoot().head ?? documentRoot().documentElement).append(style); return { added: true }; } + if (name === "dialog.accept" || name === "dialog.dismiss") { const dialog = dialogQueue.shift(); if (!dialog) throw new BrowserDomAutomationError("not_found", "No queued dialog"); return { type: dialog.type, message: dialog.message, accepted: name.endsWith("accept"), ...(dialog.type === "prompt" ? { value: name.endsWith("accept") ? dialog.defaultValue ?? "" : null } : {}) }; } + if (name === "highlight") { const element = target(params); if (!element) throw new BrowserDomAutomationError("not_found", "Element was not found"); element.scrollIntoView({ block: "center", inline: "nearest" }); element.setAttribute("data-t4-highlight", "true"); return { highlighted: true, ref: elementRef(element) }; } + if (name.startsWith("find.")) { const kind = name.slice(5); if (["first", "last", "nth"].includes(kind)) { const list = params.selector ? all(text(params.selector, "selector", 4_096)) : [target(params) as Element]; const index = kind === "first" ? 0 : kind === "last" ? list.length - 1 : Math.max(0, Math.min(list.length - 1, Number(params.index ?? params.n ?? 0))); if (!list[index]) throw new BrowserDomAutomationError("not_found", "Element was not found"); return resultElements([list[index]]); } return resultElements(findBy(kind, text(params.query ?? params.text ?? params.value ?? params.role ?? params.label ?? params.placeholder ?? params.testid ?? params.alt ?? params.title, "query", 8_192))); } + if (name === "get.title") return { title: bound(documentRoot().title), url: bound(documentRoot().URL) }; + if (name === "get.count") { const selector = text(params.selector, "selector", 4_096); return { count: all(selector).length }; } + const element = target(params, !["press", "keydown", "keyup", "scroll", "storage.get", "storage.set", "storage.clear"].includes(name)); + if (name === "get.text") return { text: bound(element?.textContent?.trim() ?? "", 32_768) }; + if (name === "get.html") return { html: bound(element?.outerHTML ?? documentRoot().documentElement.outerHTML) }; + if (name === "get.value") return { value: bound(element && "value" in element ? String((element as HTMLInputElement).value) : "", 32_768) }; + if (name === "get.attr") { const attr = text(params.name ?? params.attr, "name", 256); return { value: element?.getAttribute(attr) === null ? null : bound(element?.getAttribute(attr) ?? "") }; } + if (name === "get.box") return { box: element ? boundsOf(element) : boundsOf(rootElement()) }; + if (name === "get.styles") return styleResult(element ?? rootElement()); + if (name === "is.visible") return { value: element ? isVisible(element) : false }; + if (name === "is.enabled") return { value: element ? !isDisabled(element) : false }; + if (name === "is.checked") return { value: element instanceof HTMLInputElement ? element.checked : element?.getAttribute("aria-checked") === "true" }; + if (name === "click" || name === "dblclick") { if (!element) throw new BrowserDomAutomationError("not_found", "Element was not found"); if (isDisabled(element)) throw new BrowserDomAutomationError("invalid_state", "Element is disabled"); focus(element); element.dispatchEvent(new MouseEvent(name === "click" ? "click" : "dblclick", { bubbles: true, cancelable: true, view: window, detail: name === "click" ? Math.max(1, Number(params.clickCount ?? 1)) : 2 })); return { ok: true, ...postAction(params) }; } + if (name === "hover") { if (!element) throw new BrowserDomAutomationError("not_found", "Element was not found"); element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, view: window })); element.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true, view: window })); return { ok: true, ...postAction(params) }; } + if (name === "focus") { if (!element) throw new BrowserDomAutomationError("not_found", "Element was not found"); focus(element); return { ok: true, ...postAction(params) }; } + if (name === "fill") { if (!element) throw new BrowserDomAutomationError("not_found", "Element was not found"); setText(element, text(params.value, "value", 16_384)); return { ok: true, ...postAction(params) }; } + if (name === "type") { if (!element) throw new BrowserDomAutomationError("not_found", "Element was not found"); focus(element); const value = text(params.text, "text", 16_384); for (const character of value) { setText(element, `${element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent ?? ""}${character}`); if (typeof params.intervalMs === "number" && params.intervalMs > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(10_000, params.intervalMs as number))); } return { ok: true, ...postAction(params) }; } + if (name === "press" || name === "keydown" || name === "keyup") { const key = text(params.key, "key", 128); if (name === "press") { keyEvent("keydown", key, params); keyEvent("keyup", key, params); } else keyEvent(name, key, params); return { ok: true, ...postAction(params) }; } + if (name === "check" || name === "uncheck") { if (!(element instanceof HTMLInputElement) || (element.type !== "checkbox" && element.type !== "radio")) throw new BrowserDomAutomationError("not_supported", "Element is not a checkbox"); element.checked = name === "check"; dispatchInput(element); return { ok: true, ...postAction(params) }; } + if (name === "select") { if (!(element instanceof HTMLSelectElement)) throw new BrowserDomAutomationError("not_supported", "Element is not a select"); const values = Array.isArray(params.values) ? params.values.filter((value): value is string => typeof value === "string").slice(0, 64) : [text(params.value, "value", 1_024)]; for (const option of Array.from(element.options)) option.selected = values.includes(option.value) || values.includes(option.text); dispatchInput(element); return { ok: true, ...postAction(params) }; } + if (name === "scroll" || name === "scroll_into_view") { if (name === "scroll_into_view" && element) element.scrollIntoView({ block: "center", inline: "nearest" }); else if (element) (element as HTMLElement).scrollBy(finiteNumber(params.x), finiteNumber(params.y)); else window.scrollBy(finiteNumber(params.x), finiteNumber(params.y)); return { ok: true, ...postAction(params) }; } + if (name === "storage.get" || name === "storage.set" || name === "storage.clear") { const area = params.storageArea === "session" ? "session" : "local"; const store = storage(area); if (name === "storage.clear") { store.clear(); return { entries: {} }; } if (name === "storage.set") { const key = text(params.key, "key", 2_048); const value = text(params.value, "value", 16_384); store.setItem(key, value); return { entries: { [key]: value } }; } const entries: Record = {}; const key = params.key === undefined ? undefined : text(params.key, "key", 2_048); if (key !== undefined) { const value = store.getItem(key); if (value !== null) entries[key] = bound(value, 16_384); } else for (let index = 0; index < Math.min(store.length, MAX_ELEMENTS); index += 1) { const itemKey = store.key(index); if (itemKey) entries[bound(itemKey, 2_048)] = bound(store.getItem(itemKey) ?? "", 16_384); } return { entries }; } + throw new BrowserDomAutomationError("not_supported", `Unsupported browser DOM method: ${bound(method, 128)}`); +} diff --git a/apps/desktop/src/browser-downloads.ts b/apps/desktop/src/browser-downloads.ts new file mode 100644 index 0000000..11c0adb --- /dev/null +++ b/apps/desktop/src/browser-downloads.ts @@ -0,0 +1,385 @@ +import { app, type DownloadItem, type Session, type WebContents } from "electron"; +import { mkdir, link, readdir, unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { BrowserDownload, BrowserEvent, SurfaceId } from "@t4-code/protocol/browser-ipc"; + +const MAX_DOWNLOAD_BYTES = Number.MAX_SAFE_INTEGER; +const MAX_FILENAME_BYTES = 255; +const DEFAULT_WAIT_MS = 120_000; + +export interface BrowserDownloadControllerOptions { + readonly emit: (event: BrowserEvent) => void; + /** Override Electron's Downloads directory in tests or an embedding host. */ + readonly downloadsPath?: string; +} + +interface DownloadItemLike { + getURL(): string; + getSuggestedFilename(): string; + getMimeType?(): string; + getTotalBytes?(): number; + getReceivedBytes?(): number; + setSavePath(path: string): void; + cancel?(): void; + on(event: "updated", listener: (event: unknown, state: string) => void): this; + once(event: "done", listener: (event: unknown, state: string) => void): this; +} + +interface Waiter { + readonly resolve: (download: BrowserDownload | undefined) => void; + readonly timer: NodeJS.Timeout; +} + +interface AttachedSurface { + readonly session: Session; + readonly surfaceId: SurfaceId; +} + +type DownloadListener = (event: Electron.Event, item: DownloadItem, webContents: WebContents) => void; + +const TERMINAL_STATES = new Set(["completed", "cancelled", "failed"]); +function stripAsciiControlCharacters(value: string): string { + let result = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + result += codePoint !== undefined && (codePoint <= 0x1F || codePoint === 0x7F) ? "" : character; + } + return result; +} + +/** + * Returns a bounded, filesystem-safe filename. A path-like suggestion is not + * normalized: it is rejected and replaced so a hostile suggestion can never + * escape Downloads. + */ +export function safeDownloadFilename(suggested: unknown, mimeType?: unknown, sourceUrl?: unknown): string { + const candidate = typeof suggested === "string" ? suggested.normalize("NFKC").trim() : ""; + const pathLike = candidate.length === 0 || candidate === "." || candidate === ".." || candidate.includes("/") || candidate.includes("\\") || candidate.includes("\0") || candidate.split(/[\\/]/u).some((part) => part === "..") || /^[.]{2}(?:$|[.])/u.test(candidate); + let filename = pathLike ? "download" : candidate; + filename = stripAsciiControlCharacters(filename).replace(/[<>:"|?*]/gu, "_").trim(); + if (filename.length === 0 || filename === "." || filename === "..") filename = "download"; + + const mime = typeof mimeType === "string" ? (mimeType.split(";", 1)[0] ?? "").trim().toLowerCase() : ""; + const urlExtension = extensionFromUrl(sourceUrl); + const mimeExtension = mimeExtensionFor(mime); + if (mime === "application/pdf") { + filename = `${withoutExtension(filename)}.pdf`; + } else if (!hasExtension(filename)) { + const extension = mimeExtension ?? urlExtension; + if (extension !== undefined) filename += extension; + } + + const bytes = Buffer.byteLength(filename, "utf8"); + if (bytes > MAX_FILENAME_BYTES) { + const extension = extensionOf(filename) ?? ""; + const suffixBytes = Buffer.byteLength(extension, "utf8"); + const room = Math.max(1, MAX_FILENAME_BYTES - suffixBytes); + filename = Buffer.from(filename, "utf8").subarray(0, room).toString("utf8").replace(/[\uDC00-\uDFFF]/gu, "") + extension; + } + return filename || "download"; +} + +function extensionOf(value: string): string | undefined { + const index = value.lastIndexOf("."); + return index > 0 ? value.slice(index) : undefined; +} + +function withoutExtension(value: string): string { + const extension = extensionOf(value); + return extension === undefined ? value : value.slice(0, -extension.length); +} + +function hasExtension(value: string): boolean { + return extensionOf(value) !== undefined; +} + +function extensionFromUrl(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + try { + const path = new URL(value).pathname; + const leaf = path.slice(path.lastIndexOf("/") + 1); + const extension = extensionOf(leaf); + return extension !== undefined && /^[.][a-z0-9]{1,12}$/iu.test(extension) ? extension.toLowerCase() : undefined; + } catch { + return undefined; + } +} + +function mimeExtensionFor(mime: string): string | undefined { + const extensions: Record = { + "application/gzip": ".gz", + "application/json": ".json", + "application/zip": ".zip", + "audio/mpeg": ".mp3", + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "text/css": ".css", + "text/csv": ".csv", + "text/html": ".html", + "text/plain": ".txt", + "video/mp4": ".mp4", + }; + return extensions[mime]; +} + +function boundedBytes(value: unknown, fallback?: number): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return fallback; + return Math.min(Math.floor(value), MAX_DOWNLOAD_BYTES); +} + +function errorText(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message.slice(0, 1_024); + return "Download failed"; +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = error.code; + return typeof code === "string" ? code : undefined; +} + +function isAlreadyExists(error: unknown): boolean { + return errorCode(error) === "EEXIST"; +} + +/** Owns Electron will-download listeners and the files they produce. */ +export class BrowserDownloadController { + private readonly emitEvent: (event: BrowserEvent) => void; + private readonly downloadsPath: string | undefined; + private readonly surfaces = new Map(); + private readonly sessionListeners = new Map(); + private readonly records = new Map(); + private readonly items = new Map(); + private readonly waiters = new Map>(); + private disposed = false; + + public constructor(options: BrowserDownloadControllerOptions) { + this.emitEvent = options.emit; + this.downloadsPath = options.downloadsPath; + } + + public attach(webContents: WebContents, surfaceId: SurfaceId, session: Session): void { + if (this.disposed) return; + this.surfaces.set(webContents, { session, surfaceId }); + if (this.sessionListeners.has(session)) return; + const listener: DownloadListener = (event, item, contents) => this.onWillDownload(session, event, item, contents); + session.on("will-download", listener); + this.sessionListeners.set(session, listener); + } + + public list(surfaceId?: SurfaceId): readonly BrowserDownload[] { + const values = [...this.records.values()].filter((record) => surfaceId === undefined || record.surfaceId === surfaceId); + return Object.freeze(values); + } + + public wait(downloadId: string, timeoutMs = DEFAULT_WAIT_MS): Promise { + const current = this.records.get(downloadId); + if (current !== undefined && TERMINAL_STATES.has(current.state)) return Promise.resolve(current); + if (this.disposed) return Promise.resolve(undefined); + const { promise, resolve } = Promise.withResolvers(); + const waiter: Waiter = { + resolve, + timer: setTimeout(() => { + const pending = this.waiters.get(downloadId); + pending?.delete(waiter); + if (pending?.size === 0) this.waiters.delete(downloadId); + resolve(undefined); + }, Math.max(0, Math.min(timeoutMs, DEFAULT_WAIT_MS))), + }; + const pending = this.waiters.get(downloadId) ?? new Set(); + pending.add(waiter); + this.waiters.set(downloadId, pending); + return promise; + } + + public cancel(downloadId: string): boolean { + const item = this.items.get(downloadId); + if (item === undefined) return false; + try { + item.cancel?.(); + } catch { + // The terminal done event still performs cleanup and records failure. + } + return true; + } + + public disposeSurface(surfaceId: SurfaceId): void { + for (const [webContents, attached] of this.surfaces) { + if (attached.surfaceId === surfaceId) this.surfaces.delete(webContents); + } + for (const [downloadId, record] of this.records) { + if (record.surfaceId === surfaceId && !TERMINAL_STATES.has(record.state)) this.cancel(downloadId); + } + } + + public async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + for (const [session, listener] of this.sessionListeners) session.removeListener("will-download", listener); + this.sessionListeners.clear(); + for (const downloadId of this.items.keys()) this.cancel(downloadId); + this.surfaces.clear(); + for (const waiters of this.waiters.values()) { + for (const waiter of waiters) { + clearTimeout(waiter.timer); + waiter.resolve(undefined); + } + } + this.waiters.clear(); + await Promise.all([...this.records.values()].filter((record) => !TERMINAL_STATES.has(record.state)).map(async (record) => { + await this.removeTemporaryFile(record.downloadId); + })); + } + + private onWillDownload(session: Session, event: Electron.Event, item: DownloadItem, webContents: WebContents): void { + const attached = this.surfaces.get(webContents); + if (attached === undefined || attached.session !== session || this.disposed) { + event.preventDefault(); + return; + } + event.preventDefault(); + void this.startDownload(item as unknown as DownloadItemLike, attached.surfaceId); + } + + private async startDownload(item: DownloadItemLike, surfaceId: SurfaceId): Promise { + const downloadId = crypto.randomUUID(); + const url = item.getURL(); + const mimeType = item.getMimeType?.() || undefined; + const filename = safeDownloadFilename(item.getSuggestedFilename(), mimeType, url); + const totalBytes = boundedBytes(item.getTotalBytes?.()); + const started: BrowserDownload = Object.freeze({ downloadId, surfaceId, state: "started", url, filename, ...(mimeType === undefined ? {} : { mimeType }), ...(totalBytes === undefined ? {} : { totalBytes }), receivedBytes: 0 }); + this.records.set(downloadId, started); + this.items.set(downloadId, item); + this.publish(started); + try { + const downloadsPath = await this.resolveDownloadsPath(); + await mkdir(downloadsPath, { recursive: true }); + const temporaryPath = join(downloadsPath, `.t4-download-${downloadId}.part`); + item.setSavePath(temporaryPath); + item.on("updated", (_event, state) => this.onUpdated(downloadId, state)); + item.once("done", (_event, state) => void this.onDone(downloadId, state, temporaryPath)); + if (this.disposed) this.cancel(downloadId); + } catch (error) { + await this.failDownload(downloadId, error); + } + } + + private onUpdated(downloadId: string, _state: string): void { + const current = this.records.get(downloadId); + if (this.disposed || current === undefined || TERMINAL_STATES.has(current.state)) return; + const receivedBytes = boundedBytes(this.items.get(downloadId)?.getReceivedBytes?.(), current.receivedBytes ?? 0); + const next: BrowserDownload = Object.freeze({ ...current, state: "progress", ...(receivedBytes === undefined ? {} : { receivedBytes }) }); + this.records.set(downloadId, next); + this.publish(next); + } + + private async onDone(downloadId: string, state: string, temporaryPath: string): Promise { + if (this.disposed) { + await this.removeFile(temporaryPath); + this.items.delete(downloadId); + return; + } + const current = this.records.get(downloadId); + if (current === undefined || TERMINAL_STATES.has(current.state)) return; + const receivedBytes = boundedBytes(this.items.get(downloadId)?.getReceivedBytes?.(), current.receivedBytes ?? 0); + if (state === "completed") { + try { + const savePath = await this.commitTemporaryFile(temporaryPath, current.filename); + const completed: BrowserDownload = Object.freeze({ ...current, state: "completed", ...(receivedBytes === undefined ? {} : { receivedBytes }) }); + this.records.set(downloadId, completed); + this.items.delete(downloadId); + this.publish(completed); + this.resolveWaiters(downloadId, completed); + // Keep the path private to the controller; it must not enter BrowserEvent. + void savePath; + } catch (error) { + await this.failDownload(downloadId, error, temporaryPath); + } + return; + } + await this.removeFile(temporaryPath); + const terminalState: BrowserDownload["state"] = state === "cancelled" ? "cancelled" : "failed"; + const terminal: BrowserDownload = Object.freeze({ ...current, state: terminalState, ...(receivedBytes === undefined ? {} : { receivedBytes }), ...(terminalState === "failed" ? { failure: state || "Download failed" } : {}) }); + this.records.set(downloadId, terminal); + this.items.delete(downloadId); + this.publish(terminal); + this.resolveWaiters(downloadId, terminal); + } + + private async failDownload(downloadId: string, error: unknown, temporaryPath?: string): Promise { + if (temporaryPath !== undefined) await this.removeFile(temporaryPath); + const current = this.records.get(downloadId); + if (current === undefined || TERMINAL_STATES.has(current.state)) return; + const failed: BrowserDownload = Object.freeze({ ...current, state: "failed", failure: errorText(error) }); + this.records.set(downloadId, failed); + this.items.delete(downloadId); + this.publish(failed); + this.resolveWaiters(downloadId, failed); + } + + private publish(download: BrowserDownload): void { + try { + this.emitEvent({ type: "download", download }); + } catch { + // Event consumers must not be able to interrupt download cleanup. + } + } + + private resolveWaiters(downloadId: string, result: BrowserDownload): void { + const pending = this.waiters.get(downloadId); + if (pending === undefined) return; + this.waiters.delete(downloadId); + for (const waiter of pending) { + clearTimeout(waiter.timer); + waiter.resolve(result); + } + } + + private async resolveDownloadsPath(): Promise { + if (this.downloadsPath !== undefined) return this.downloadsPath; + try { + return app.getPath("downloads"); + } catch { + return join(app.getPath("userData"), "Downloads"); + } + } + + private async commitTemporaryFile(temporaryPath: string, filename: string): Promise { + const directory = dirname(temporaryPath); + const extension = extensionOf(filename) ?? ""; + const base = withoutExtension(filename); + for (let index = 0; index < 10_000; index += 1) { + const suffix = index === 0 ? "" : ` (${index})`; + const destination = join(directory, `${base}${suffix}${extension}`); + try { + await link(temporaryPath, destination); + await unlink(temporaryPath); + return destination; + } catch (error) { + if (isAlreadyExists(error)) continue; + throw error; + } + } + throw new Error("Unable to allocate a unique download filename"); + } + + private async removeFile(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if (errorCode(error) !== "EEXIST" && errorCode(error) !== "ENOENT") return; + } + } + + private async removeTemporaryFile(downloadId: string): Promise { + const directory = await this.resolveDownloadsPath(); + const prefix = `.t4-download-${downloadId}.part`; + try { + const names = await readdir(directory); + await Promise.all(names.filter((name) => name === prefix).map((name) => this.removeFile(join(directory, name)))); + } catch { + // Downloads may not have been created yet. + } + } +} diff --git a/apps/desktop/src/browser-input.ts b/apps/desktop/src/browser-input.ts new file mode 100644 index 0000000..4e77996 --- /dev/null +++ b/apps/desktop/src/browser-input.ts @@ -0,0 +1,197 @@ +import type { InputEvent, KeyboardInputEvent, MouseInputEvent, MouseWheelInputEvent } from "electron"; +import type { BrowserErrorCode, BrowserJsonValue } from "@t4-code/protocol/browser-ipc"; +type BrowserMouseInputEvent = MouseInputEvent; +type BrowserMouseWheelInputEvent = MouseWheelInputEvent; +type BrowserKeyboardInputEvent = KeyboardInputEvent; +type BrowserInputModifier = NonNullable[number]; + +export type BrowserInputEvent = BrowserMouseInputEvent | BrowserMouseWheelInputEvent | BrowserKeyboardInputEvent; + +const MAX_COORDINATE = 1_000_000; +const MAX_KEY_LENGTH = 64; +const MAX_MODIFIERS = 8; + +export interface BrowserInputContents { + focus?(): void; + sendInputEvent(event: BrowserInputEvent): void | Promise; +} + +export interface BrowserInputSurface { + readonly webContents?: BrowserInputContents | null; + readonly surfaceId?: string; + readonly state?: unknown; + readonly snapshot?: () => unknown | Promise; + readonly getSnapshot?: () => unknown | Promise; +} + +export interface BrowserInputCapabilityResult { + readonly supported: false; + readonly code: "not_supported"; + readonly message: string; +} + +export class BrowserInputError extends Error { + readonly code: BrowserErrorCode; + readonly method?: string; + readonly surfaceId?: string; + + constructor(code: BrowserErrorCode, message: string, method?: string, surfaceId?: string) { + super(message); + this.name = "BrowserInputError"; + this.code = code; + if (method !== undefined) this.method = method; + if (surfaceId !== undefined) this.surfaceId = surfaceId; + } +} + +const KNOWN_KEYS: Record = { + Enter: true, Escape: true, Tab: true, Backspace: true, Delete: true, Insert: true, + ArrowUp: true, ArrowDown: true, ArrowLeft: true, ArrowRight: true, + Home: true, End: true, PageUp: true, PageDown: true, + Shift: true, Control: true, Alt: true, Meta: true, Super: true, + CapsLock: true, NumLock: true, ScrollLock: true, PrintScreen: true, Pause: true, + ContextMenu: true, Clear: true, Help: true, Space: true, + Add: true, Subtract: true, Multiply: true, Divide: true, Decimal: true, +}; +for (let index = 1; index <= 24; index += 1) KNOWN_KEYS[`F${index}`] = true; + +const KNOWN_MODIFIERS: Record = { + shift: true, + control: true, + ctrl: true, + alt: true, + meta: true, + command: true, + cmd: true, + iskeypad: true, + isautorepeat: true, + leftbuttondown: true, + middlebuttondown: true, + rightbuttondown: true, + capslock: true, + numlock: true, + left: true, + right: true, +}; + +function unsupported(message: string): BrowserInputCapabilityResult { + return { supported: false, code: "not_supported", message }; +} + +function inputRecord(value: unknown, method: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new BrowserInputError("invalid_params", "params must be an object", method); + return value as Record; +} + +function contentsFor(surface: BrowserInputSurface | BrowserInputContents, method: string): BrowserInputContents { + if (typeof surface === "object" && surface !== null && "sendInputEvent" in surface && typeof surface.sendInputEvent === "function") return surface as BrowserInputContents; + const contents = (surface as BrowserInputSurface).webContents; + if (!contents || typeof contents.sendInputEvent !== "function") throw new BrowserInputError("not_found", "Browser surface has no live webContents", method, (surface as BrowserInputSurface).surfaceId); + return contents; +} + +function numeric(value: unknown, name: string, method: string, minimum = -MAX_COORDINATE, maximum = MAX_COORDINATE): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) throw new BrowserInputError("invalid_params", `${name} must be finite and between ${minimum} and ${maximum}`, method); + return value; +} + +function integer(value: unknown, name: string, method: string, minimum: number, maximum: number): number { + const number = numeric(value, name, method, minimum, maximum); + if (!Number.isSafeInteger(number)) throw new BrowserInputError("invalid_params", `${name} must be an integer`, method); + return number; +} + +function modifiers(value: unknown, method: string): BrowserInputModifier[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > MAX_MODIFIERS) throw new BrowserInputError("invalid_params", `modifiers must contain at most ${MAX_MODIFIERS} values`, method); + const result: BrowserInputModifier[] = []; + for (const modifier of value) { + if (typeof modifier !== "string" || KNOWN_MODIFIERS[modifier as BrowserInputModifier] !== true) throw new BrowserInputError("invalid_params", "Unknown keyboard modifier", method); + const typedModifier = modifier as BrowserInputModifier; + if (!result.includes(typedModifier)) result.push(typedModifier); + } + return result; +} + +function keyCode(value: unknown, method: string): string { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_KEY_LENGTH) throw new BrowserInputError("invalid_params", `keyCode must be a non-empty string of at most ${MAX_KEY_LENGTH} characters`, method); + if (value.length === 1 || KNOWN_KEYS[value] === true) return value; + throw new BrowserInputError("invalid_params", `Unknown key ${value}`, method); +} + +function snapshotRequested(params: Record, method: string): boolean { + if (!("snapshotAfter" in params)) return false; + if (params.snapshotAfter !== true && params.snapshotAfter !== false) throw new BrowserInputError("invalid_params", "snapshotAfter must be boolean", method); + return params.snapshotAfter === true; +} + +async function snapshotAfter(surface: BrowserInputSurface | BrowserInputContents, requested: boolean, method: string): Promise> { + if (!requested) return {}; + if (typeof surface === "object" && surface !== null && "snapshot" in surface && typeof surface.snapshot === "function") return { postActionSnapshot: await surface.snapshot() as BrowserJsonValue }; + if (typeof surface === "object" && surface !== null && "getSnapshot" in surface && typeof surface.getSnapshot === "function") return { postActionSnapshot: await surface.getSnapshot() as BrowserJsonValue }; + if (typeof surface === "object" && surface !== null && "state" in surface) return { postActionSnapshot: (surface as BrowserInputSurface).state as BrowserJsonValue }; + throw new BrowserInputError("not_supported", "Surface snapshots are not available", method, (surface as BrowserInputSurface).surfaceId); +} + +function mouseEvent(params: Record, method: string): BrowserMouseInputEvent | BrowserMouseWheelInputEvent { + const type = params.type; + if (type !== "mouseDown" && type !== "mouseUp" && type !== "mouseMove" && type !== "mouseWheel") throw new BrowserInputError("invalid_params", "Unknown mouse event type", method); + const x = numeric(params.x, "x", method); + const y = numeric(params.y, "y", method); + let event: BrowserMouseInputEvent | BrowserMouseWheelInputEvent = type === "mouseWheel" + ? { type, x, y, deltaX: numeric(params.deltaX ?? 0, "deltaX", method), deltaY: numeric(params.deltaY ?? 0, "deltaY", method) } + : { type, x, y }; + if (type !== "mouseWheel" && params.button !== undefined) { + if (params.button !== "left" && params.button !== "middle" && params.button !== "right") throw new BrowserInputError("invalid_params", "Unknown mouse button", method); + event.button = params.button; + } + if (params.clickCount !== undefined) event.clickCount = integer(params.clickCount, "clickCount", method, 1, 16); + if (params.modifiers !== undefined) event.modifiers = modifiers(params.modifiers, method); + return event; +} + +function keyboardEvent(params: Record, method: string): BrowserKeyboardInputEvent { + const type = params.type; + if (type !== "keyDown" && type !== "keyUp" && type !== "char") throw new BrowserInputError("invalid_params", "Unknown keyboard event type", method); + const event: BrowserKeyboardInputEvent = { type, keyCode: keyCode(params.keyCode ?? params.key, method) }; + if (params.modifiers !== undefined) event.modifiers = modifiers(params.modifiers, method); + return event; +} + +/** Validates and forwards raw native WebContents input events. */ +export class BrowserInputCoordinator { + private disposed = false; + + private ensureLive(method: string, surface: BrowserInputSurface | BrowserInputContents): BrowserInputContents { + if (this.disposed) throw new BrowserInputError("invalid_state", "Input coordinator is disposed", method, (surface as BrowserInputSurface).surfaceId); + return contentsFor(surface, method); + } + + async call(method: string, params: unknown, surface: BrowserInputSurface | BrowserInputContents): Promise | BrowserInputCapabilityResult> { + if (method === "browser.input_touch") return unsupported("Touch input is not supported by Electron WebContents.sendInputEvent"); + const input = inputRecord(params, method); + let event: BrowserInputEvent; + switch (method) { + case "browser.input_mouse": + event = mouseEvent(input, method); + break; + case "browser.input_keyboard": + event = keyboardEvent(input, method); + break; + default: + return unsupported(`Input capability ${method} is not supported`); + } + const requested = snapshotRequested(input, method); + const contents = this.ensureLive(method, surface); + try { + await contents.sendInputEvent(event); + } catch (error) { + throw new BrowserInputError("internal", error instanceof Error ? error.message.slice(0, 512) : "Unable to dispatch input event", method, (surface as BrowserInputSurface).surfaceId); + } + return { supported: true, dispatched: true, eventType: event.type, ...(await snapshotAfter(surface, requested, method)) }; + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/apps/desktop/src/browser-network.ts b/apps/desktop/src/browser-network.ts new file mode 100644 index 0000000..2142487 --- /dev/null +++ b/apps/desktop/src/browser-network.ts @@ -0,0 +1,508 @@ +import type { Session, WebContents } from "electron"; + +const MAX_TEXT_BYTES = 4_096; +const MAX_HEADER_BYTES = 2_048; +const MAX_HEADERS = 64; +const MAX_ROUTES = 64; +const MAX_ROUTE_PATTERN_BYTES = 512; +const MAX_REQUESTS = 256; +const MAX_REQUEST_URL_BYTES = 8_192; +const MAX_ROUTE_ID_BYTES = 96; +const MAX_DEVICE_KEYS = 32; +const SECRET_KEY = /(?:authorization|cookie|set-cookie|proxy-authorization|token|secret|password|passwd|credential|api[_-]?key|private[_-]?key|session)/iu; +const SENSITIVE_QUERY_KEY = /(?:authorization|auth|cookie|token|secret|password|passwd|credential|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|session|code)/iu; +const NETWORK_FILTER = ["http://*/*", "https://*/*"]; + +type NetworkErrorCode = "invalid_params" | "invalid_state" | "not_supported" | "internal"; + +export interface BrowserNetworkFailure { + readonly ok: false; + readonly code: NetworkErrorCode; + readonly message: string; + readonly reason: string; +} + +export interface BrowserNetworkSuccess { + readonly ok: true; + readonly value: T; +} + +export type BrowserNetworkResult = BrowserNetworkSuccess | BrowserNetworkFailure; + +export interface BrowserOfflineOptions { + readonly offline: boolean; + readonly latencyMs?: number; + readonly downloadThroughputBytesPerSecond?: number; + readonly uploadThroughputBytesPerSecond?: number; +} + +export interface BrowserHeaderSettings { + readonly headers: Readonly>; +} + +export interface BrowserDeviceSettings { + readonly [key: string]: unknown; +} + +export interface BrowserNetworkRoute { + readonly routeId?: string; + readonly urlPattern: string; + readonly action: "abort" | "redirect"; + readonly redirectUrl?: string; +} + +export interface BrowserNetworkRouteInfo { + readonly routeId: string; + readonly action: "abort" | "redirect"; +} + +export interface BrowserNetworkRequest { + readonly requestId: number; + readonly method: string; + readonly url: string; + readonly resourceType?: string; + readonly startedAt: number; + readonly finishedAt?: number; + readonly statusCode?: number; + readonly error?: string; +} + +export interface BrowserNetworkRequestOptions { + readonly limit?: number; +} + +export interface BrowserNetworkControllerOptions { + readonly session: Session; + readonly webContents?: WebContents; + readonly now?: () => number; +} + +interface WebRequestDetails { + readonly id: number; + readonly url: string; + readonly webContentsId?: number; + readonly method?: string; + readonly resourceType?: string; + readonly statusCode?: number; + readonly error?: string; +} + +type BeforeRequestListener = (details: WebRequestDetails, callback: (response: { readonly cancel?: boolean; readonly redirectURL?: string }) => void) => void; +type HeaderDetails = { + readonly webContentsId?: number; + readonly requestHeaders: Record; +}; +type HeaderListener = (details: HeaderDetails, callback: (response: { readonly requestHeaders: Record }) => void) => void; +type RequestDetailsListener = (details: WebRequestDetails) => void; + +interface WebRequestLike { + onBeforeRequest(filter: { readonly urls: readonly string[] }, listener: BeforeRequestListener | null): void; + onBeforeSendHeaders?(filter: { readonly urls: readonly string[] }, listener: HeaderListener | null): void; + onCompleted?(filter: { readonly urls: readonly string[] }, listener: RequestDetailsListener | null): void; + onErrorOccurred?(filter: { readonly urls: readonly string[] }, listener: RequestDetailsListener | null): void; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function byteLength(value: string): number { return new TextEncoder().encode(value).byteLength; } +function replaceControlCharacters(value: string, replacement: string): string { + let result = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + result += codePoint !== undefined && (codePoint <= 0x1F || codePoint === 0x7F || (codePoint >= 0x80 && codePoint <= 0x9F)) ? replacement : character; + } + return result; +} + +function boundedString(value: unknown, maxBytes = MAX_TEXT_BYTES): string | undefined { + if (typeof value !== "string") return undefined; + let result = replaceControlCharacters(value.normalize("NFKC"), " ").trim(); + if (result.length === 0) return undefined; + while (byteLength(result) > maxBytes) result = result.slice(0, Math.max(1, result.length - 1)); + return result.length > 0 && byteLength(result) <= maxBytes ? result : undefined; +} + +type SessionNetworkLike = Session & { + readonly webRequest?: WebRequestLike; +}; + +type WebContentsNetworkLike = WebContents & { + readonly id?: number; + enableDeviceEmulation?: (parameters: Record) => void; + disableDeviceEmulation?: () => void; + setUserAgent?: (userAgent: string) => void; + getUserAgent?: () => string; + setAudioMuted?: (muted: boolean) => void; +}; + +interface RouteEntry { + readonly info: BrowserNetworkRouteInfo; + readonly pattern: string; + readonly expression: RegExp; + readonly redirectUrl?: string; +} + +interface SessionNetworkPolicy { + readonly beforeRequest: BeforeRequestListener; + readonly beforeSendHeaders: HeaderListener; + readonly completed: RequestDetailsListener; + readonly failed: RequestDetailsListener; +} + +interface SessionNetworkState { + readonly policies: Map; + readonly webRequest: WebRequestLike; +} + +const sessionNetworkStates = new WeakMap(); + +function policyFor( + policies: ReadonlyMap, + webContentsId: number | undefined, +): SessionNetworkPolicy | undefined { + return Number.isSafeInteger(webContentsId) ? policies.get(webContentsId as number) : undefined; +} + +/** Electron keeps only the last WebRequest listener, so one listener must multiplex a shared Session. */ +function registerSessionNetworkPolicy( + session: SessionNetworkLike, + webContentsId: number, + policy: SessionNetworkPolicy, +): () => void { + const webRequest = session.webRequest; + if (webRequest === undefined) return () => {}; + let state = sessionNetworkStates.get(session); + if (state === undefined) { + const policies = new Map(); + state = { policies, webRequest }; + sessionNetworkStates.set(session, state); + webRequest.onBeforeRequest({ urls: NETWORK_FILTER }, (details, callback) => { + const selected = policyFor(policies, details.webContentsId); + if (selected === undefined) callback({}); + else selected.beforeRequest(details, callback); + }); + webRequest.onBeforeSendHeaders?.({ urls: NETWORK_FILTER }, (details, callback) => { + const selected = policyFor(policies, details.webContentsId); + if (selected === undefined) callback({ requestHeaders: { ...details.requestHeaders } }); + else selected.beforeSendHeaders(details, callback); + }); + webRequest.onCompleted?.({ urls: NETWORK_FILTER }, (details) => { + policyFor(policies, details.webContentsId)?.completed(details); + }); + webRequest.onErrorOccurred?.({ urls: NETWORK_FILTER }, (details) => { + policyFor(policies, details.webContentsId)?.failed(details); + }); + } + const registeredState = state; + registeredState.policies.set(webContentsId, policy); + + return (): void => { + if (registeredState.policies.get(webContentsId) !== policy) return; + registeredState.policies.delete(webContentsId); + if (registeredState.policies.size > 0) return; + sessionNetworkStates.delete(session); + registeredState.webRequest.onBeforeRequest({ urls: NETWORK_FILTER }, null); + registeredState.webRequest.onBeforeSendHeaders?.({ urls: NETWORK_FILTER }, null); + registeredState.webRequest.onCompleted?.({ urls: NETWORK_FILTER }, null); + registeredState.webRequest.onErrorOccurred?.({ urls: NETWORK_FILTER }, null); + }; +} + + +function failure(code: NetworkErrorCode, reason: string): BrowserNetworkFailure { + const safeReason = boundedString(reason, MAX_TEXT_BYTES) ?? "network operation failed"; + return { ok: false, code, reason: safeReason, message: safeReason }; +} + +function success(value: T): BrowserNetworkSuccess { return { ok: true, value }; } +function unsupported(reason: string): BrowserNetworkFailure { return failure("not_supported", reason); } + +function safeUrl(value: unknown, maxBytes = MAX_REQUEST_URL_BYTES): string | undefined { + const candidate = boundedString(value, maxBytes); + if (candidate === undefined) return undefined; + let parsed: URL; + try { parsed = new URL(candidate); } catch { return undefined; } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined; + if (parsed.username !== "" || parsed.password !== "") return undefined; + parsed.hash = ""; + for (const key of Array.from(parsed.searchParams.keys())) if (SENSITIVE_QUERY_KEY.test(key)) parsed.searchParams.delete(key); + const result = parsed.toString(); + return byteLength(result) <= maxBytes ? result : undefined; +} + +function safeRouteId(value: unknown): string | undefined { + const result = boundedString(value, MAX_ROUTE_ID_BYTES); + return result !== undefined && /^[a-z][a-z0-9._-]{0,95}$/u.test(result) ? result : undefined; +} + +function routeExpression(value: unknown): { readonly pattern: string; readonly expression: RegExp } | undefined { + const pattern = boundedString(value, MAX_ROUTE_PATTERN_BYTES); + if (pattern === undefined || !/^https?:\/\//iu.test(pattern) || /[\r\n<>]/u.test(pattern)) return undefined; + const authorityAndPath = pattern.slice(pattern.indexOf("://") + 3); + const slash = authorityAndPath.indexOf("/"); + const authority = slash < 0 ? authorityAndPath : authorityAndPath.slice(0, slash); + if (authority.length === 0 || authority.includes("@") || authority.includes("\\") || /[^a-z0-9.*:[\]-]/iu.test(authority)) return undefined; + if (authority.includes("*") && authority !== "*") return undefined; + let parsedPattern = pattern; + const queryStart = parsedPattern.indexOf("?"); + if (queryStart >= 0) { + const query = parsedPattern.slice(queryStart + 1); + for (const item of query.split("&")) { + const key = item.split("=", 1)[0] ?? ""; + if (SENSITIVE_QUERY_KEY.test(key)) return undefined; + } + } + const escaped = parsedPattern.replace(/[\\^$+?.()|[\]{}]/gu, "\\$&").replace(/\*/gu, ".*"); + try { return { pattern, expression: new RegExp(`^${escaped}$`, "iu") }; } catch { return undefined; } +} + +function normalizeHeaders(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const entries = Object.entries(value); + if (entries.length > MAX_HEADERS) return undefined; + const result: Record = {}; + for (const [rawKey, rawValue] of entries) { + const key = boundedString(rawKey, 256)?.toLowerCase(); + const headerValue = boundedString(rawValue, MAX_HEADER_BYTES); + if (key === undefined || headerValue === undefined || !/^[a-z][a-z0-9-]{0,127}$/u.test(key) || SECRET_KEY.test(key) || /[\r\n]/u.test(headerValue)) return undefined; + result[key] = headerValue; + } + return result; +} + +function normalizeDevice(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const input = value; + const allowed = new Set(["screenPosition", "screenSize", "viewPosition", "viewSize", "deviceScaleFactor", "scale"]); + const entries = Object.entries(input); + if (entries.length > MAX_DEVICE_KEYS || entries.some(([key]) => !allowed.has(key))) return undefined; + const result: Record = {}; + for (const [key, item] of entries) { + if (key === "screenPosition") { + if (item !== "desktop" && item !== "mobile") return undefined; + result[key] = item; + continue; + } + if (key === "deviceScaleFactor" || key === "scale") { + if (typeof item !== "number" || !Number.isFinite(item) || item <= 0 || item > 100) return undefined; + result[key] = item; + continue; + } + if (!isRecord(item)) return undefined; + const dimension = item; + const dimensionKeys = Object.keys(dimension); + if (dimensionKeys.some((dimensionKey) => dimensionKey !== "width" && dimensionKey !== "height" && dimensionKey !== "x" && dimensionKey !== "y") || dimensionKeys.length !== 2) return undefined; + const normalized: Record = {}; + for (const dimensionKey of dimensionKeys) { + const numberValue = dimension[dimensionKey]; + if (typeof numberValue !== "number" || !Number.isFinite(numberValue) || numberValue < 0 || numberValue > 100_000) return undefined; + normalized[dimensionKey] = numberValue; + } + result[key] = normalized; + } + return result; +} + +function safeResourceType(value: unknown): string | undefined { + const result = boundedString(value, 64); + return result !== undefined && /^[a-z][a-z0-9_-]{0,63}$/iu.test(result) ? result : undefined; +} + +/** + * Controls one WebContents while multiplexing Electron's Session-wide + * WebRequest hooks. It never records request bodies or headers. + */ +export class BrowserNetworkController { + private readonly targetSession: SessionNetworkLike; + private readonly targetWebContents: WebContentsNetworkLike | undefined; + private readonly now: () => number; + private readonly routes = new Map(); + private readonly requests: BrowserNetworkRequest[] = []; + private readonly beforeRequestListener: (details: WebRequestDetails, callback: (response: { readonly cancel?: boolean; readonly redirectURL?: string }) => void) => void; + private readonly beforeSendHeadersListener: (details: HeaderDetails, callback: (response: { readonly requestHeaders: Record }) => void) => void; + private readonly completedListener: (details: WebRequestDetails) => void; + private readonly errorListener: (details: WebRequestDetails) => void; + private readonly disposeNetworkPolicy: () => void; + private configuredHeaders: Readonly> = Object.freeze({}); + private originalUserAgent: string | undefined; + private userAgentChanged = false; + private deviceEmulationEnabled = false; + private disposed = false; + private nextRouteNumber = 1; + + constructor(options: BrowserNetworkControllerOptions) { + this.targetSession = options.session as SessionNetworkLike; + this.targetWebContents = options.webContents as WebContentsNetworkLike | undefined; + this.now = options.now ?? Date.now; + this.originalUserAgent = this.targetWebContents?.getUserAgent?.(); + this.beforeRequestListener = (details, callback) => { + const requestId = Number.isSafeInteger(details.id) ? details.id : 0; + const url = safeUrl(details.url); + if (url !== undefined) { + const resourceType = safeResourceType(details.resourceType); + this.requests.push({ + requestId, + method: boundedString(details.method, 32) ?? "GET", + url, + ...(resourceType === undefined ? {} : { resourceType }), + startedAt: this.now(), + }); + while (this.requests.length > MAX_REQUESTS) this.requests.shift(); + } + const route = url === undefined ? undefined : [...this.routes.values()].find((candidate) => candidate.expression.test(details.url)); + if (route?.info.action === "abort") callback({ cancel: true }); + else if (route?.info.action === "redirect" && route.redirectUrl !== undefined) callback({ redirectURL: route.redirectUrl }); + else callback({}); + }; + this.beforeSendHeadersListener = (details, callback) => { + const requestHeaders = { ...details.requestHeaders }; + for (const [key, value] of Object.entries(this.configuredHeaders)) requestHeaders[key] = value; + callback({ requestHeaders }); + }; + this.completedListener = (details) => this.finishRequest(details, false); + this.errorListener = (details) => this.finishRequest(details, true); + const webContentsId = this.targetWebContents?.id; + this.disposeNetworkPolicy = Number.isSafeInteger(webContentsId) + ? registerSessionNetworkPolicy(this.targetSession, webContentsId as number, { + beforeRequest: this.beforeRequestListener, + beforeSendHeaders: this.beforeSendHeadersListener, + completed: this.completedListener, + failed: this.errorListener, + }) + : () => {}; + } + + private finishRequest(details: WebRequestDetails, failed: boolean): void { + const request = this.requests.find((item) => item.requestId === details.id); + if (request === undefined) return; + const index = this.requests.indexOf(request); + const rawStatusCode = details.statusCode; + const statusCode = typeof rawStatusCode === "number" && Number.isInteger(rawStatusCode) && rawStatusCode >= 100 && rawStatusCode <= 599 ? rawStatusCode : undefined; + const error = failed ? boundedString(details.error, 256) : undefined; + this.requests[index] = { + ...request, + finishedAt: this.now(), + ...(statusCode === undefined ? {} : { statusCode }), + ...(error === undefined ? {} : { error }), + }; + } + + setOffline(options: BrowserOfflineOptions): BrowserNetworkResult<{ readonly offline: boolean }> { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + if (typeof options !== "object" || options === null || typeof options.offline !== "boolean") return failure("invalid_params", "offline must be a boolean"); + const latency = options.latencyMs ?? 0; + const download = options.downloadThroughputBytesPerSecond ?? -1; + const upload = options.uploadThroughputBytesPerSecond ?? -1; + if (![latency, download, upload].every((value) => typeof value === "number" && Number.isFinite(value) && value >= -1 && value <= Number.MAX_SAFE_INTEGER)) return failure("invalid_params", "network emulation values are invalid"); + return unsupported("Electron network emulation is session-wide and cannot be safely scoped to one browser surface"); + } + + setUserAgent(userAgent: string): BrowserNetworkResult<{ readonly applied: true }> { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + const value = boundedString(userAgent, MAX_TEXT_BYTES); + if (value === undefined || /[\r\n]/u.test(value)) return failure("invalid_params", "user agent is invalid"); + if (typeof this.targetWebContents?.setUserAgent !== "function") return unsupported("Electron does not expose per-surface user-agent configuration"); + try { + this.targetWebContents.setUserAgent(value); + this.userAgentChanged = true; + return success({ applied: true }); + } catch { return failure("internal", "surface user-agent could not be applied"); } + } + + setHeaders(settings: BrowserHeaderSettings): BrowserNetworkResult<{ readonly count: number }> { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + if (this.targetSession.webRequest === undefined || !Number.isSafeInteger(this.targetWebContents?.id)) return unsupported("Electron webRequest header interception is unavailable"); + const headers = normalizeHeaders(settings?.headers); + if (headers === undefined) return failure("invalid_params", "headers are invalid or contain credentials"); + this.configuredHeaders = Object.freeze({ ...headers }); + return success({ count: Object.keys(headers).length }); + } + + setDevice(settings: BrowserDeviceSettings): BrowserNetworkResult<{ readonly applied: true }> { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + const parameters = normalizeDevice(settings); + if (parameters === undefined) return failure("invalid_params", "device settings are invalid"); + if (this.targetWebContents === undefined || typeof this.targetWebContents.enableDeviceEmulation !== "function") return unsupported("Electron does not expose truthful device emulation for this surface"); + try { + this.targetWebContents.enableDeviceEmulation(parameters); + this.deviceEmulationEnabled = true; + return success({ applied: true }); + } catch { return failure("internal", "device emulation could not be applied"); } + } + + setGeolocation(_settings: unknown): BrowserNetworkResult { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + return unsupported("Electron cannot truthfully override navigator.geolocation per session"); + } + + setCredentials(_settings: unknown): BrowserNetworkResult { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + return unsupported("Electron has no safe per-session credential emulation API"); + } + + setMedia(_settings: unknown): BrowserNetworkResult { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + return unsupported("Electron cannot truthfully emulate media devices per session"); + } + + route(route: BrowserNetworkRoute): BrowserNetworkResult { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + if (this.targetSession.webRequest === undefined || !Number.isSafeInteger(this.targetWebContents?.id)) return unsupported("Electron webRequest routing is unavailable"); + if (this.routes.size >= MAX_ROUTES) return failure("invalid_params", "network route limit reached"); + const expression = routeExpression(route?.urlPattern); + if (expression === undefined) return failure("invalid_params", "network route pattern is invalid"); + if (route.action !== "abort" && route.action !== "redirect") return failure("invalid_params", "network route action is invalid"); + const routeId = safeRouteId(route.routeId) ?? `route-${this.nextRouteNumber++}`; + if (this.routes.has(routeId)) return failure("invalid_params", "network route id is already in use"); + let redirectUrl: string | undefined; + if (route.action === "redirect") { + redirectUrl = safeUrl(route.redirectUrl); + if (redirectUrl === undefined) return failure("invalid_params", "redirect URL is invalid"); + } + const info: BrowserNetworkRouteInfo = { routeId, action: route.action }; + this.routes.set(routeId, { info, pattern: expression.pattern, expression: expression.expression, ...(redirectUrl === undefined ? {} : { redirectUrl }) }); + return success(info); + } + + unroute(routeId: string): BrowserNetworkResult<{ readonly removed: boolean }> { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + const id = safeRouteId(routeId); + if (id === undefined) return failure("invalid_params", "network route id is invalid"); + return success({ removed: this.routes.delete(id) }); + } + + listRequests(options: BrowserNetworkRequestOptions = {}): BrowserNetworkResult { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + const limit = options.limit ?? MAX_REQUESTS; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_REQUESTS) return failure("invalid_params", "request limit is invalid"); + return success(this.requests.slice(-limit).map((request) => ({ ...request }))); + } + + listRoutes(): BrowserNetworkResult { + if (this.disposed) return failure("invalid_state", "network controller is disposed"); + return success([...this.routes.values()].map((route) => ({ ...route.info }))); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.routes.clear(); + this.configuredHeaders = Object.freeze({}); + this.disposeNetworkPolicy(); + if (this.deviceEmulationEnabled) { + try { this.targetWebContents?.disableDeviceEmulation?.(); } catch { /* best effort */ } + } + if (this.userAgentChanged && this.originalUserAgent !== undefined && typeof this.targetWebContents?.setUserAgent === "function") { + try { this.targetWebContents.setUserAgent(this.originalUserAgent); } catch { /* best effort */ } + } + this.requests.length = 0; + } +} + +export function createBrowserNetworkController(options: BrowserNetworkControllerOptions): BrowserNetworkController { + return new BrowserNetworkController(options); +} + +export const BrowserNetworkAutomation = BrowserNetworkController; diff --git a/apps/desktop/src/browser-profile-automation.ts b/apps/desktop/src/browser-profile-automation.ts new file mode 100644 index 0000000..4d2ee6c --- /dev/null +++ b/apps/desktop/src/browser-profile-automation.ts @@ -0,0 +1,326 @@ +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; +import type { Session } from "electron"; +import type { BrowserProfile } from "@t4-code/protocol/browser-ipc"; +import { + BrowserProfileRegistry, + browserProfileToProtocol, + type BrowserProfileCreateOptions, + type BrowserProfileMetadata, +} from "./browser-profiles.ts"; + +const MAX_FILE_PATH_BYTES = 4_096; +const MAX_IMPORT_BYTES = 4 * 1024 * 1024; +const MAX_COOKIES = 2_048; +const MAX_COOKIE_NAME_BYTES = 256; +const MAX_COOKIE_VALUE_BYTES = 16_384; +const MAX_COOKIE_DOMAIN_BYTES = 512; +const MAX_COOKIE_PATH_BYTES = 512; +const MAX_LABEL_BYTES = 128; +const MAX_PROFILE_ID_BYTES = 96; + +type ProfileErrorCode = "invalid_params" | "not_found" | "invalid_state" | "security" | "not_supported" | "internal"; + +export interface BrowserProfileAutomationFailure { + readonly ok: false; + readonly code: ProfileErrorCode; + readonly message: string; + readonly reason: string; +} + +export interface BrowserProfileAutomationSuccess { + readonly ok: true; + readonly value: T; +} + +export type BrowserProfileAutomationResult = BrowserProfileAutomationSuccess | BrowserProfileAutomationFailure; + +export interface BrowserProfileAutomationOptions { + readonly registry: BrowserProfileRegistry; + readonly readFile?: (path: string) => Promise; +} + +export interface BrowserProfileSelectionRequest { + readonly profileId: string; + readonly profile?: BrowserProfile; +} + +export interface BrowserCookieImportRequest extends BrowserProfileSelectionRequest { + /** A path explicitly selected by the user; this module never discovers browser data. */ + readonly filePath: string; +} + +export interface BrowserCookieImportResult { + readonly profileId: string; + readonly imported: number; + readonly selected: false; +} + +export interface BrowserProfileMutationResult { + readonly profile: BrowserProfileMetadata; +} + +export interface BrowserProfileClearResult { + readonly profileId: string; + readonly cleared: true; +} + +export interface BrowserProfileDeleteResult { + readonly profileId: string; + readonly deleted: true; +} + + +interface ElectronCookieDetails { + readonly url: string; + readonly name: string; + readonly value: string; + readonly domain?: string; + readonly path?: string; + readonly secure?: boolean; + readonly httpOnly?: boolean; + readonly expirationDate?: number; + readonly sameSite?: "unspecified" | "no_restriction" | "lax" | "strict"; +} + +interface CookieStoreLike { + set(details: ElectronCookieDetails): Promise; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isCookieStore(value: unknown): value is CookieStoreLike { + return value !== null && typeof value === "object" && "set" in value && typeof value.set === "function"; +} + +function byteLength(value: string): number { return new TextEncoder().encode(value).byteLength; } +function replaceControlCharacters(value: string, replacement: string): string { + let result = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + result += codePoint !== undefined && (codePoint <= 0x1F || codePoint === 0x7F || (codePoint >= 0x80 && codePoint <= 0x9F)) ? replacement : character; + } + return result; +} + +function boundedString(value: unknown, maxBytes: number): string | undefined { + if (typeof value !== "string") return undefined; + let result = replaceControlCharacters(value.normalize("NFKC"), " ").trim(); + if (result.length === 0) return undefined; + while (byteLength(result) > maxBytes) result = result.slice(0, Math.max(1, result.length - 1)); + return byteLength(result) <= maxBytes ? result : undefined; +} + +function failure(code: ProfileErrorCode, reason: string): BrowserProfileAutomationFailure { + const safeReason = boundedString(reason, 2_048) ?? "profile operation failed"; + return { ok: false, code, reason: safeReason, message: safeReason }; +} + +function success(value: T): BrowserProfileAutomationSuccess { return { ok: true, value }; } + +function profileId(value: unknown): string | undefined { + const result = boundedString(value, MAX_PROFILE_ID_BYTES); + return result !== undefined && /^[a-z][a-z0-9._-]{0,63}$/u.test(result) ? result : undefined; +} + +function profileLabel(value: unknown): string | undefined { + return boundedString(value, MAX_LABEL_BYTES); +} + +function selectedProfile(value: unknown): { readonly profileId: string; readonly profile?: BrowserProfile } | BrowserProfileAutomationFailure { + if (!isRecord(value)) return failure("security", "an exact browser profile selection is required"); + const id = profileId(value.profileId); + if (id === undefined) return failure("security", "an exact browser profile selection is required"); + const profile = value.profile; + if (id === "isolated-session") { + if (profile === undefined) return { profileId: id }; + if (!isRecord(profile) || profile.kind !== "isolated-session" || profile.profileId !== "isolated-session" || Object.keys(profile).some((key) => key !== "kind" && key !== "profileId")) { + return failure("security", "browser profile selection was not exact"); + } + return { profileId: id, profile: { kind: "isolated-session", profileId: "isolated-session" } }; + } + if (!isRecord(profile) || profile.kind !== "authenticated-profile" || profile.profileId !== id || profile.explicitOptIn !== true || Object.keys(profile).some((key) => key !== "kind" && key !== "profileId" && key !== "explicitOptIn")) { + return failure("security", "authenticated browser profiles require exact explicit selection"); + } + return { profileId: id, profile: { kind: "authenticated-profile", profileId: id, explicitOptIn: true } }; +} + +function safeCookieDomain(value: unknown): string | undefined { + const domain = boundedString(value, MAX_COOKIE_DOMAIN_BYTES)?.toLowerCase(); + if (domain === undefined || domain.length > 253 || domain.includes("/") || domain.includes("\\") || domain.includes(":") || domain.includes("@") || domain.startsWith(".")) { + if (domain === undefined || domain.length === 0 || !domain.startsWith(".")) return undefined; + } + const host = domain.startsWith(".") ? domain.slice(1) : domain; + if (host.length === 0 || host.length > 253 || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(host) || host.includes("..")) return undefined; + return domain; +} + +function safeCookiePath(value: unknown): string | undefined { + const path = boundedString(value, MAX_COOKIE_PATH_BYTES) ?? "/"; + if (!path.startsWith("/") || path.includes("\\") || /[\r\n]/u.test(path)) return undefined; + return path; +} + +function cookieUrl(value: unknown, secure: boolean, domain: string, path: string): string | undefined { + if (value !== undefined) { + const selected = boundedString(value, 2_048); + if (selected === undefined) return undefined; + try { + const url = new URL(selected); + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; + if (url.username !== "" || url.password !== "" || url.hash !== "") return undefined; + if (url.hostname.toLowerCase() !== domain.replace(/^\./u, "").toLowerCase()) return undefined; + return url.toString(); + } catch { return undefined; } + } + return `${secure ? "https" : "http"}://${domain.replace(/^\./u, "")}${path}`; +} + +function normalizedCookie(value: unknown): ElectronCookieDetails | undefined { + if (!isRecord(value)) return undefined; + const input = value; + const allowed = new Set(["name", "value", "domain", "path", "secure", "httpOnly", "expirationDate", "expires", "sameSite", "url", "hostOnly", "session", "storeId"]); + if (Object.keys(input).some((key) => !allowed.has(key))) return undefined; + const name = boundedString(input.name, MAX_COOKIE_NAME_BYTES); + const cookieValue = boundedString(input.value, MAX_COOKIE_VALUE_BYTES); + const domain = safeCookieDomain(input.domain); + const path = safeCookiePath(input.path); + if (name === undefined || cookieValue === undefined || domain === undefined || path === undefined) return undefined; + if (input.secure !== undefined && typeof input.secure !== "boolean") return undefined; + if (input.httpOnly !== undefined && typeof input.httpOnly !== "boolean") return undefined; + const expiryValue = input.expirationDate ?? input.expires; + if (expiryValue !== undefined && (typeof expiryValue !== "number" || !Number.isFinite(expiryValue) || expiryValue < 0 || expiryValue > 4_102_444_800)) return undefined; + let sameSite: ElectronCookieDetails["sameSite"]; + if (input.sameSite !== undefined) { + if (typeof input.sameSite !== "string") return undefined; + const normalized = input.sameSite.toLowerCase(); + if (normalized === "strict") sameSite = "strict"; + else if (normalized === "lax") sameSite = "lax"; + else if (normalized === "none" || normalized === "no_restriction") sameSite = "no_restriction"; + else if (normalized === "unspecified") sameSite = "unspecified"; + else return undefined; + } + const url = cookieUrl(input.url, input.secure === true, domain, path); + if (url === undefined) return undefined; + return { + url, + name, + value: cookieValue, + domain, + path, + ...(input.secure === undefined ? {} : { secure: input.secure }), + ...(input.httpOnly === undefined ? {} : { httpOnly: input.httpOnly }), + ...(expiryValue === undefined ? {} : { expirationDate: expiryValue }), + ...(sameSite === undefined ? {} : { sameSite }), + }; +} + +function parseCookieExport(text: string): ElectronCookieDetails[] | undefined { + if (byteLength(text) > MAX_IMPORT_BYTES) return undefined; + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { return undefined; } + const parsedObject = isRecord(parsed) ? parsed : undefined; + const rows = Array.isArray(parsed) ? parsed : parsedObject !== undefined && Array.isArray(parsedObject.cookies) ? parsedObject.cookies : undefined; + if (!Array.isArray(rows) || rows.length > MAX_COOKIES) return undefined; + const cookies: ElectronCookieDetails[] = []; + for (const row of rows) { + const cookie = normalizedCookie(row); + if (cookie === undefined) return undefined; + cookies.push(cookie); + } + return cookies; +} + +function profileMutation(metadata: BrowserProfileMetadata): BrowserProfileAutomationSuccess { + return success({ profile: metadata }); +} + +/** Profile lifecycle plus explicit, JSON-only Chromium cookie import. */ +export class BrowserProfileAutomation { + private readonly registry: BrowserProfileRegistry; + private readonly readSelectedFile: (path: string) => Promise; + private disposed = false; + + constructor(options: BrowserProfileAutomationOptions) { + this.registry = options.registry; + this.readSelectedFile = options.readFile ?? (async (path) => readFile(path, "utf8")); + } + + list(): BrowserProfileAutomationResult { + if (this.disposed) return failure("invalid_state", "profile automation is disposed"); + const profiles = this.registry.list().slice(0, 64).map((metadata) => ({ ...metadata })); + return success(profiles); + } + + create(options: BrowserProfileCreateOptions = {}): BrowserProfileAutomationResult { + if (this.disposed) return failure("invalid_state", "profile automation is disposed"); + const requestedId = options.profileId === undefined ? undefined : profileId(options.profileId); + const requestedLabel = options.label === undefined ? undefined : profileLabel(options.label); + if (options.profileId !== undefined && requestedId === undefined) return failure("invalid_params", "profile id is invalid"); + if (options.label !== undefined && requestedLabel === undefined) return failure("invalid_params", "profile label is invalid"); + try { return profileMutation(this.registry.create({ ...(requestedId === undefined ? {} : { profileId: requestedId }), ...(requestedLabel === undefined ? {} : { label: requestedLabel }) })); } catch { return failure("internal", "browser profile could not be created"); } + } + + rename(profileIdValue: string, label: string): BrowserProfileAutomationResult { + if (this.disposed) return failure("invalid_state", "profile automation is disposed"); + const id = profileId(profileIdValue); + const nextLabel = profileLabel(label); + if (id === undefined || nextLabel === undefined || id === "isolated-session") return failure("invalid_params", "an authenticated profile id and label are required"); + try { return profileMutation(this.registry.rename(id, nextLabel)); } catch { return failure("not_found", "authenticated browser profile was not found"); } + } + + async clear(selection: BrowserProfileSelectionRequest): Promise> { + if (this.disposed) return failure("invalid_state", "profile automation is disposed"); + const selected = selectedProfile(selection); + if ("ok" in selected) return selected; + if (selected.profileId === "isolated-session") return failure("security", "the isolated browser profile cannot be cleared"); + try { + await this.registry.clear(selected.profileId); + return success({ profileId: selected.profileId, cleared: true }); + } catch { return failure("not_found", "authenticated browser profile was not found"); } + } + + async delete(selection: BrowserProfileSelectionRequest): Promise> { + if (this.disposed) return failure("invalid_state", "profile automation is disposed"); + const selected = selectedProfile(selection); + if ("ok" in selected) return selected; + if (selected.profileId === "isolated-session") return failure("security", "the isolated browser profile cannot be deleted"); + try { + await this.registry.delete(selected.profileId); + return success({ profileId: selected.profileId, deleted: true }); + } catch { return failure("invalid_state", "authenticated browser profile could not be deleted"); } + } + + async importCookies(request: BrowserCookieImportRequest): Promise> { + if (this.disposed) return failure("invalid_state", "profile automation is disposed"); + const selected = selectedProfile(request); + if ("ok" in selected) return selected; + const filePath = boundedString(request.filePath, MAX_FILE_PATH_BYTES); + if (filePath === undefined || extname(filePath).toLowerCase() !== ".json") return failure("not_supported", "cookie import requires an explicitly selected JSON export file"); + if (selected.profileId === "isolated-session") return failure("security", "cookie import requires an explicitly selected authenticated profile"); + let cookies: ElectronCookieDetails[] | undefined; + try { cookies = parseCookieExport(await this.readSelectedFile(filePath)); } catch { return failure("not_supported", "the selected cookie export could not be read"); } + if (cookies === undefined) return failure("not_supported", "the selected file is not a safely parseable Chromium cookie export"); + let session: Session; + try { session = this.registry.getSession({ kind: "authenticated-profile", profileId: selected.profileId, explicitOptIn: true }); } catch { return failure("security", "authenticated browser profile selection was not exact"); } + const cookieStore = session.cookies; + if (!isCookieStore(cookieStore)) return failure("not_supported", "Electron cookie storage is unavailable"); + try { + for (const cookie of cookies) await cookieStore.set(cookie); + } catch { return failure("internal", "one or more imported cookies could not be applied"); } + return success({ profileId: selected.profileId, imported: cookies.length, selected: false }); + } + + dispose(): void { + this.disposed = true; + } +} + +export function createBrowserProfileAutomation(options: BrowserProfileAutomationOptions): BrowserProfileAutomation { + return new BrowserProfileAutomation(options); +} + +export { browserProfileToProtocol }; +export const BrowserProfileController = BrowserProfileAutomation; diff --git a/apps/desktop/src/browser-profiles.ts b/apps/desktop/src/browser-profiles.ts new file mode 100644 index 0000000..00a98b1 --- /dev/null +++ b/apps/desktop/src/browser-profiles.ts @@ -0,0 +1,336 @@ +import ElectronStore from "electron-store"; +import { createHash } from "node:crypto"; +import { session as electronSession, type Session } from "electron"; +import type { BrowserProfile } from "@t4-code/protocol/browser-ipc"; + +export const BROWSER_ISOLATED_PROFILE_ID = "isolated-session" as const; +export const BROWSER_ISOLATED_PARTITION = "browser-isolated-session" as const; +export const BROWSER_PROFILE_STORE_VERSION = 1 as const; + +const MAX_PROFILES = 64; +const MAX_ID_BYTES = 96; +const MAX_LABEL_BYTES = 128; +const PROFILE_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u; +const RESERVED_PROFILE_IDS = new Set([BROWSER_ISOLATED_PROFILE_ID, "default", "session"]); + +export interface BrowserProfileMetadata { + readonly profileId: string; + readonly label: string; + readonly partition: string; + readonly kind: "isolated-session" | "authenticated-profile"; + readonly explicitOptIn?: true; + readonly createdAt: number; + readonly updatedAt: number; +} + +export interface BrowserProfileStoreRecord { + readonly profileId: string; + readonly label: string; + readonly createdAt: number; + readonly updatedAt: number; +} + +export interface BrowserProfileStoreState { + readonly version: 1; + readonly records: readonly BrowserProfileStoreRecord[]; +} + +export interface BrowserProfileRegistryStore { + readonly store: unknown; + set(key: string, value: unknown): void; +} + +export interface BrowserSessionProvider { + fromPartition(partition: string, options?: { readonly cache?: boolean }): Session; +} + +export interface BrowserProfileRegistryOptions { + readonly userDataPath?: string; + readonly store?: BrowserProfileRegistryStore; + readonly session?: BrowserSessionProvider; + readonly now?: () => number; +} + +export interface BrowserProfileCreateOptions { + readonly profileId?: string; + readonly label?: string; +} + +export interface BrowserProfileDeleteOptions { + readonly inUse?: boolean; +} + +const DEFAULT_LABEL = "OMP session"; + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} +function replaceControlCharacters(value: string, replacement: string): string { + let result = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + result += codePoint !== undefined && (codePoint <= 0x1F || codePoint === 0x7F || (codePoint >= 0x80 && codePoint <= 0x9F)) ? replacement : character; + } + return result; +} + +function boundedText(value: unknown, fallback: string, maxBytes: number): string { + if (typeof value !== "string") return fallback; + let result = replaceControlCharacters(value.normalize("NFKC"), " ").trim(); + if (result.length === 0) return fallback; + while (utf8Length(result) > maxBytes) result = result.slice(0, Math.max(1, result.length - 1)); + return result || fallback; +} + +/** Produces a stable, non-secret identifier suitable for a persistent Electron partition. */ +export function sanitizeBrowserProfileId(value: unknown, fallback = "profile"): string { + let source = typeof value === "string" ? value.normalize("NFKC").trim().toLowerCase() : ""; + source = source.replace(/[^a-z0-9._-]+/gu, "-").replace(/^[^a-z]+/u, "").replace(/-{2,}/gu, "-"); + source = source.replace(/[-_.]+$/u, ""); + if (source.length === 0) source = fallback; + source = boundedText(source, fallback, MAX_ID_BYTES).slice(0, 64); + if (!/^[a-z]/u.test(source)) source = `profile-${source}`; + if (!PROFILE_ID_PATTERN.test(source) || RESERVED_PROFILE_IDS.has(source)) { + source = boundedText(fallback, "profile", MAX_ID_BYTES).toLowerCase().replace(/[^a-z0-9._-]+/gu, "-"); + source = source.replace(/^[^a-z]+/u, "").replace(/[-_.]+$/u, "").slice(0, 64); + if (!/^[a-z][a-z0-9._-]{0,63}$/u.test(source) || RESERVED_PROFILE_IDS.has(source)) source = "profile"; + } + return source; +} + +export function sanitizeBrowserProfileLabel(value: unknown, fallback = "Profile"): string { + return boundedText(value, fallback, MAX_LABEL_BYTES).slice(0, 128); +} + +function profilePartition(profileId: string): string { + return `persist:browser-profile-${profileId}`; +} + +function isolatedPartition(ownerSessionId: string): string { + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(ownerSessionId)) { + throw new Error("an owning OMP session is required for isolated browser state"); + } + // Partition names are observable in Electron diagnostics. Hash the durable + // OMP session id so browser isolation does not disclose the session id. + const ownerHash = createHash("sha256").update(ownerSessionId, "utf8").digest("hex").slice(0, 32); + return `${BROWSER_ISOLATED_PARTITION}-${ownerHash}`; +} + +function metadataForRecord(record: BrowserProfileStoreRecord): BrowserProfileMetadata { + return { + profileId: record.profileId, + label: record.label, + partition: profilePartition(record.profileId), + kind: "authenticated-profile", + explicitOptIn: true, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +function profileForMetadata(metadata: BrowserProfileMetadata): BrowserProfile { + return metadata.kind === "isolated-session" + ? { kind: "isolated-session", profileId: BROWSER_ISOLATED_PROFILE_ID } + : { kind: "authenticated-profile", profileId: metadata.profileId, explicitOptIn: true }; +} + +function emptyState(): BrowserProfileStoreState { + return { version: BROWSER_PROFILE_STORE_VERSION, records: [] }; +} + +function decodeState(value: unknown): BrowserProfileStoreState { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid browser profile state"); + const root = value as Record; + if (root.version !== BROWSER_PROFILE_STORE_VERSION || !Array.isArray(root.records)) throw new Error("invalid browser profile state"); + const records: BrowserProfileStoreRecord[] = []; + const seen = new Set(); + for (const item of root.records) { + if (item === null || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid browser profile record"); + const record = item as Record; + if (typeof record.profileId !== "string" || typeof record.label !== "string" || Object.keys(record).some((key) => !["profileId", "label", "createdAt", "updatedAt"].includes(key))) throw new Error("invalid browser profile record"); + const profileId = sanitizeBrowserProfileId(record.profileId, ""); + if (profileId !== record.profileId || !PROFILE_ID_PATTERN.test(profileId) || RESERVED_PROFILE_IDS.has(profileId) || seen.has(profileId)) throw new Error("invalid browser profile id"); + const label = sanitizeBrowserProfileLabel(record.label, "Profile"); + if (typeof record.createdAt !== "number" || !Number.isFinite(record.createdAt) || typeof record.updatedAt !== "number" || !Number.isFinite(record.updatedAt)) throw new Error("invalid browser profile timestamps"); + seen.add(profileId); + records.push({ profileId, label, createdAt: record.createdAt, updatedAt: record.updatedAt }); + if (records.length > MAX_PROFILES) throw new Error("too many browser profiles"); + } + return { version: 1, records }; +} +export function decodeBrowserProfileStoreState(value: unknown): BrowserProfileStoreState { + try { return decodeState(value); } catch { return emptyState(); } +} + + +const isolatedMetadata: BrowserProfileMetadata = Object.freeze({ + profileId: BROWSER_ISOLATED_PROFILE_ID, + label: DEFAULT_LABEL, + partition: BROWSER_ISOLATED_PARTITION, + kind: "isolated-session", + createdAt: 0, + updatedAt: 0, +}); + +/** Owns the safe profile catalogue and maps each profile to an isolated Electron partition. */ +export class BrowserProfileRegistry { + private readonly store: BrowserProfileRegistryStore; + private readonly sessions: BrowserSessionProvider; + private readonly now: () => number; + private readonly activeProfileCounts = new Map(); + private state: BrowserProfileStoreState; + + constructor(options: BrowserProfileRegistryOptions = {}) { + this.store = options.store ?? new ElectronStore({ + name: "browser-profiles", + ...(options.userDataPath === undefined ? {} : { cwd: options.userDataPath }), + defaults: emptyState(), + }); + this.sessions = options.session ?? electronSession; + this.now = options.now ?? Date.now; + this.state = this.readState(); + } + + /** Lists the built-in isolated profile and persisted authenticated profile metadata. */ + list(): readonly BrowserProfileMetadata[] { + return [isolatedMetadata, ...this.state.records.map(metadataForRecord)]; + } + + listProfiles(): readonly BrowserProfileMetadata[] { return this.list(); } + + /** Resolves only an exact authenticated id; an omitted id always resolves to isolation. */ + resolve(profileId?: string | null): BrowserProfileMetadata { + if (profileId === undefined || profileId === null || profileId === "") return isolatedMetadata; + const exact = this.state.records.find((record) => record.profileId === profileId); + if (exact === undefined) throw new Error("authenticated browser profile was not found"); + return metadataForRecord(exact); + } + + get(profileId?: string | null): BrowserProfileMetadata { return this.resolve(profileId); } + + profile(profileId?: string | null): BrowserProfile { return profileForMetadata(this.resolve(profileId)); } + + getProfile(profileId?: string | null): BrowserProfile { return this.profile(profileId); } + + getPartition(profileId?: string | null, ownerSessionId?: string): string { + const metadata = this.resolve(profileId); + return metadata.kind === "isolated-session" + ? isolatedPartition(ownerSessionId ?? "") + : metadata.partition; + } + /** Returns a profile session; isolated state is scoped to one owning OMP session. */ + getSession( + profile: BrowserProfile | string | null | undefined = undefined, + ownerSessionId?: string, + ): Session { + const profileId = typeof profile === "string" + ? profile + : profile?.kind === "authenticated-profile" ? profile.profileId : undefined; + const metadata = this.resolve(profileId); + if (profile !== undefined && profile !== null && typeof profile !== "string" && profile.kind === "authenticated-profile" && profile.explicitOptIn !== true) { + throw new Error("authenticated browser profile requires explicit opt-in"); + } + const partition = metadata.kind === "isolated-session" + ? isolatedPartition(ownerSessionId ?? "") + : metadata.partition; + return this.sessions.fromPartition(partition, { cache: metadata.kind === "authenticated-profile" }); + } + + create(options: BrowserProfileCreateOptions = {}): BrowserProfileMetadata { + if (this.state.records.length >= MAX_PROFILES) throw new Error("browser profile limit reached"); + const base = sanitizeBrowserProfileId(options.profileId ?? options.label ?? "profile"); + let profileId = base; + let suffix = 2; + while (this.state.records.some((record) => record.profileId === profileId) || RESERVED_PROFILE_IDS.has(profileId)) { + profileId = `${base}-${suffix}`.slice(0, 64); + suffix += 1; + if (suffix > 10_000) throw new Error("could not allocate browser profile id"); + } + const timestamp = this.now(); + const record: BrowserProfileStoreRecord = { + profileId, + label: sanitizeBrowserProfileLabel(options.label, profileId), + createdAt: timestamp, + updatedAt: timestamp, + }; + this.state = { version: 1, records: [...this.state.records, record] }; + this.persist(this.state); + return metadataForRecord(record); + } + + rename(profileId: string, label: string): BrowserProfileMetadata { + const exact = this.state.records.find((record) => record.profileId === profileId); + if (exact === undefined) throw new Error("authenticated browser profile was not found"); + const record = { ...exact, label: sanitizeBrowserProfileLabel(label, exact.label), updatedAt: this.now() }; + this.state = { version: 1, records: this.state.records.map((item) => item.profileId === profileId ? record : item) }; + this.persist(this.state); + return metadataForRecord(record); + } + + async clear(profileId: string): Promise { + const metadata = this.requireAuthenticated(profileId); + const profileSession = this.getSession(profileForMetadata(metadata)); + await profileSession.clearStorageData(); + await profileSession.clearCache(); + } + + async delete(profileId: string, options: BrowserProfileDeleteOptions | boolean = {}): Promise { + this.requireAuthenticated(profileId); + const inUse = typeof options === "boolean" ? options : options.inUse === true; + if (inUse || (this.activeProfileCounts.get(profileId) ?? 0) > 0) throw new Error("browser profile is in use"); + await this.clear(profileId); + this.state = { version: 1, records: this.state.records.filter((record) => record.profileId !== profileId) }; + await this.persist(this.state); + return true; + } + + markInUse(profileId: string): void { + if (profileId === BROWSER_ISOLATED_PROFILE_ID) return; + this.requireAuthenticated(profileId); + this.activeProfileCounts.set(profileId, (this.activeProfileCounts.get(profileId) ?? 0) + 1); + } + release(profileId: string): void { + if (profileId === BROWSER_ISOLATED_PROFILE_ID) return; + const count = this.activeProfileCounts.get(profileId) ?? 0; + if (count <= 1) this.activeProfileCounts.delete(profileId); + else this.activeProfileCounts.set(profileId, count - 1); + } + isInUse(profileId: string): boolean { return (this.activeProfileCounts.get(profileId) ?? 0) > 0; } + + /** Acquires a deletion guard for a profile and releases it when the returned callback runs. */ + acquire(profileId: string): () => void { + this.markInUse(profileId); + return () => this.release(profileId); + } + + private requireAuthenticated(profileId: string): BrowserProfileMetadata { + if (profileId === BROWSER_ISOLATED_PROFILE_ID || profileId === "default" || profileId === "session") throw new Error("the isolated browser profile cannot be changed"); + const metadata = this.resolve(profileId); + if (metadata.kind !== "authenticated-profile") throw new Error("an authenticated browser profile is required"); + return metadata; + } + + private readState(): BrowserProfileStoreState { + try { + const value = this.store.store; + const state = decodeState(value); + return state; + } catch { + const state = emptyState(); + try { this.store.set("version", state.version); this.store.set("records", state.records); } catch { /* best effort recovery */ } + return state; + } + } + + private persist(state: BrowserProfileStoreState): Promise { + const decoded = decodeState(state); + this.store.set("version", decoded.version); + this.store.set("records", decoded.records); + return Promise.resolve(); + } + +} + +export function browserProfileToProtocol(metadata: BrowserProfileMetadata): BrowserProfile { + return profileForMetadata(metadata); +} diff --git a/apps/desktop/src/browser-proxy.ts b/apps/desktop/src/browser-proxy.ts new file mode 100644 index 0000000..565331a --- /dev/null +++ b/apps/desktop/src/browser-proxy.ts @@ -0,0 +1,138 @@ +import type { ProxyConfig, Session } from "electron"; + +const MAX_TEXT_BYTES = 2_048; +const MAX_BYPASS = 64; +const LOOPBACK_BYPASS: string[] = ["", "localhost", "127.0.0.1", "[::1]", "*.local"]; +const METADATA_BYPASS: string[] = ["169.254.169.254", "metadata.google.internal", "metadata.google", "100.100.100.200"]; + +export type BrowserProxyMode = "direct" | "fixed" | "pac" | "wpad" | "system" | "split"; + +export interface BrowserSystemProxySettings { + readonly mode?: BrowserProxyMode | string; + readonly type?: BrowserProxyMode | string; + readonly proxy?: string; + readonly http?: string; + readonly https?: string; + readonly socks?: string; + readonly ftp?: string; + readonly httpProxy?: string; + readonly httpsProxy?: string; + readonly bypass?: readonly string[]; + readonly noProxy?: readonly string[]; + readonly pacUrl?: string; + readonly pacScript?: string; + readonly wpad?: boolean; + readonly autoDetect?: boolean; +} + +export interface BrowserProxyMapping { + readonly ok: true; + readonly config: ProxyConfig; +} + +export interface BrowserProxyUnsupported { + readonly ok: false; + readonly code: "not_supported"; + readonly message: string; +} + +export type BrowserProxyResult = BrowserProxyMapping | BrowserProxyUnsupported; + +export interface BrowserProxyController { + configure(settings: BrowserSystemProxySettings): Promise; + dispose(): Promise; +} + +function bounded(value: unknown, max = MAX_TEXT_BYTES): value is string { + return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).byteLength <= max; +} + +function unsupported(message: string): BrowserProxyUnsupported { + return { ok: false, code: "not_supported", message }; +} + +function endpoint(value: unknown): { readonly scheme: string; readonly authority: string } | null { + if (!bounded(value)) return null; + let parsed: URL; + try { parsed = new URL(value); } catch { return null; } + if (!["http:", "https:", "socks4:", "socks5:", "socks5h:"].includes(parsed.protocol)) return null; + if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || !parsed.hostname) return null; + const port = parsed.port ? Number(parsed.port) : parsed.protocol.startsWith("socks") ? 1080 : 80; + if (!Number.isInteger(port) || port < 1 || port > 65_535) return null; + return { scheme: parsed.protocol.slice(0, -1), authority: `${parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname}:${port}` }; +} + +function normalizedBypass(value: readonly string[] | undefined): string { + const rows = [...LOOPBACK_BYPASS, ...METADATA_BYPASS]; + if (value) { + if (value.length > MAX_BYPASS) throw new Error("proxy bypass list is too large"); + for (const item of value) { + if (!bounded(item, 512) || /[;\r\n]/u.test(item)) throw new Error("proxy bypass entry is invalid"); + rows.push(item.trim()); + } + } + return [...new Set(rows)].join(","); +} + +/** Maps only a single fixed proxy endpoint to Electron's representable rules. */ +export function mapBrowserSystemProxy(settings: BrowserSystemProxySettings): BrowserProxyResult { + if (settings === null || typeof settings !== "object") return unsupported("proxy settings are invalid"); + const rawMode = settings.mode ?? settings.type; + const mode = typeof rawMode === "string" ? rawMode.toLowerCase() : undefined; + if (settings.wpad === true || settings.autoDetect === true || mode === "pac" || mode === "wpad" || mode === "auto_detect" || mode === "pac_script" || bounded(settings.pacUrl) || bounded(settings.pacScript)) { + return unsupported("PAC and WPAD proxy configuration is not supported"); + } + if (mode === "system") return unsupported("system proxy delegation is not supported"); + if (mode === "split") return unsupported("split proxy configuration is not supported"); + if (mode !== undefined && mode !== "direct" && mode !== "fixed" && mode !== "fixed_servers") return unsupported("proxy mode is not supported"); + + let bypass: string; + try { bypass = normalizedBypass(settings.bypass ?? settings.noProxy); } catch { return unsupported("proxy bypass settings are invalid"); } + const values = [settings.proxy, settings.http, settings.https, settings.socks, settings.ftp, settings.httpProxy, settings.httpsProxy].filter((value): value is string => value !== undefined); + if (values.some((value) => !bounded(value))) return unsupported("proxy endpoint is invalid"); + const endpoints = values.map(endpoint); + if (endpoints.some((value) => value === null)) return unsupported("proxy endpoint is not representable"); + const unique = new Map(endpoints.map((value) => [value!.scheme + "|" + value!.authority, value!])); + if (unique.size > 1) return unsupported("split proxy routes are not supported"); + const selected = unique.values().next().value; + if (!selected || mode === "direct" || values.length === 0) return { ok: true, config: { mode: "direct", proxyBypassRules: bypass } }; + const rule = selected.scheme.startsWith("socks") + ? `socks=${selected.authority}` + : settings.proxy + ? `http=${selected.authority};https=${selected.authority};socks=${selected.authority}` + : settings.socks + ? `socks=${selected.authority}` + : settings.https || settings.httpsProxy + ? `https=${selected.authority}` + : `http=${selected.authority}`; + return { ok: true, config: { mode: "fixed_servers", proxyRules: rule, proxyBypassRules: bypass } }; +} + +export function createBrowserProxyController(targetSession: Session): BrowserProxyController { + let disposed = false; + let generation = 0; + return { + async configure(settings): Promise { + if (disposed) return unsupported("proxy controller is disposed"); + const mapped = mapBrowserSystemProxy(settings); + if (!mapped.ok) return mapped; + const current = ++generation; + try { + await targetSession.setProxy(mapped.config); + if (current !== generation || disposed) return unsupported("proxy configuration was superseded"); + await targetSession.closeAllConnections(); + return mapped; + } catch { + return unsupported("proxy configuration could not be applied"); + } + }, + async dispose(): Promise { + if (disposed) return; + disposed = true; + generation++; + try { await targetSession.setProxy({ mode: "direct", proxyBypassRules: [...LOOPBACK_BYPASS, ...METADATA_BYPASS].join(",") }); } catch { /* teardown is best effort */ } + }, + }; +} + +export const mapSystemProxy = mapBrowserSystemProxy; diff --git a/apps/desktop/src/browser-runtime.ts b/apps/desktop/src/browser-runtime.ts new file mode 100644 index 0000000..525d3a3 --- /dev/null +++ b/apps/desktop/src/browser-runtime.ts @@ -0,0 +1,969 @@ +import { randomUUID } from "node:crypto"; +import type { BrowserWindow, Session, WebContents } from "electron"; +import type { + BrowserCall, + BrowserCallResult, + BrowserErrorCode, + BrowserEvent, + BrowserJsonValue, + BrowserMethod, + BrowserProfile, + BrowserSurfaceState, + OwnerSessionId, + SurfaceHandle, + SurfaceId, +} from "@t4-code/protocol/browser-ipc"; +import type { BrowserSessionMetadata } from "./browser-session-store.ts"; +import { BrowserProfileRegistry, type BrowserProfileCreateOptions } from "./browser-profiles.ts"; +import { BrowserSessionStore } from "./browser-session-store.ts"; +import { BrowserDownloadController } from "./browser-downloads.ts"; +import { installBrowserSurfaceSecurity, type BrowserSurfaceSecurityController, type BrowserSurfaceSecurityOptions } from "./browser-security.ts"; +import { BrowserAutomationCoordinator } from "./browser-automation.ts"; +import { BrowserCaptureCoordinator } from "./browser-capture.ts"; +import { BrowserInputCoordinator } from "./browser-input.ts"; +import { BrowserNetworkController } from "./browser-network.ts"; +import { BrowserProfileAutomation, type BrowserCookieImportRequest, type BrowserProfileSelectionRequest } from "./browser-profile-automation.ts"; +import { BrowserSurface, isSafeBrowserUrl, type BrowserSurfaceAction, type BrowserSurfaceAutomationAdapter } from "./browser-surface.ts"; + +export interface SessionStoreLike { + readonly load?: () => readonly BrowserSessionMetadata[] | Promise; + readonly save?: (value: unknown) => Promise | void; +} + +export interface ProfileRegistryLike { + resolve?: (profileId?: string) => unknown; + getSession?: (profile: BrowserProfile, ownerSessionId?: OwnerSessionId) => unknown; + markInUse?: (profileId: string) => void; + release?: (profileId: string) => void; +} +export interface DownloadControllerLike { + attach?: (webContents: WebContents, surfaceId: SurfaceId, session: Session) => unknown; + disposeSurface?: (surfaceId: SurfaceId) => unknown; + list?: (surfaceId?: SurfaceId) => unknown; + wait?: (downloadId: string, timeoutMs?: number) => Promise; + dispose?: () => unknown; +} +export interface SecurityInstallerLike { + (options: BrowserSurfaceSecurityOptions): BrowserSurfaceSecurityController; +} + +export interface BrowserRuntimeOptions { + readonly window: BrowserWindow; + readonly emit: (event: BrowserEvent) => void; + readonly userDataPath: string; + readonly profileRegistry?: ProfileRegistryLike; + readonly sessionStore?: SessionStoreLike; + readonly downloadController?: DownloadControllerLike; + readonly installSecurity?: SecurityInstallerLike; + readonly allowFileUrls?: boolean; + readonly prewarmTtlMs?: number; +} + +export class BrowserRuntimeError extends Error { + readonly code: BrowserErrorCode; + readonly method: BrowserMethod | undefined; + readonly surfaceId: SurfaceId | undefined; + + constructor(code: BrowserErrorCode, message: string, method?: BrowserMethod, surfaceId?: SurfaceId) { + super(message); + this.name = "BrowserRuntimeError"; + this.code = code; + this.method = method; + this.surfaceId = surfaceId; + } +} + + +interface BrowserRuntimeAutomationSurface extends BrowserSurfaceAutomationAdapter {} + +type ProtocolCodedError = { + readonly code?: unknown; + readonly message?: unknown; + readonly reason?: unknown; + readonly surfaceId?: unknown; +}; + + +function protocolError(error: unknown, method: BrowserMethod, surfaceId?: SurfaceId): BrowserRuntimeError | undefined { + if (error instanceof BrowserRuntimeError) return error; + if (typeof error !== "object" || error === null) return undefined; + const candidate = error as ProtocolCodedError; + const code = candidate.code === "invalid_params" || candidate.code === "not_found" || candidate.code === "invalid_state" || candidate.code === "not_supported" || candidate.code === "timeout" || candidate.code === "security" || candidate.code === "internal" ? candidate.code : undefined; + if (code === undefined) return undefined; + const message = typeof candidate.message === "string" ? candidate.message : typeof candidate.reason === "string" ? candidate.reason : "Browser operation failed"; + const errorSurfaceId = typeof candidate.surfaceId === "string" ? candidate.surfaceId as SurfaceId : surfaceId; + return new BrowserRuntimeError(code, message, method, errorSurfaceId); +} +interface PrewarmEntry { + readonly surface: BrowserSurface; + readonly ownerSessionId: OwnerSessionId; + readonly profileKey: string; + readonly timer: ReturnType; +} + +interface RecoveryTarget { + readonly surface: BrowserSurface; + readonly contents: WebContents; + readonly generation: number; +} + +interface RecoveryEntry { + readonly surfaceId: SurfaceId; + target: RecoveryTarget; + pending: RecoveryTarget | undefined; +} + +function profileKey(profile: BrowserProfile): string { + return profile.kind === "authenticated-profile" ? `authenticated:${profile.profileId}` : "isolated-session"; +} + + +function surfaceIdFromRequest(request: unknown): SurfaceId { + if (typeof request !== "object" || request === null || !("surfaceId" in request) || typeof request.surfaceId !== "string") { + throw new BrowserRuntimeError("invalid_params", "surfaceId is required"); + } + return request.surfaceId as SurfaceId; +} + +function booleanOption(request: unknown, key: string): boolean { + if (typeof request !== "object" || request === null || !(key in request)) return false; + const value = (request as Record)[key]; + return value === true; +} + +function requestRecord(request: unknown): Record { + if (typeof request !== "object" || request === null || Array.isArray(request)) throw new BrowserRuntimeError("invalid_params", "Request must be an object"); + return request as Record; +} + +function ownerSessionIdFromCall(call: BrowserCall, method: BrowserMethod): OwnerSessionId { + if (typeof call.ownerSessionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(call.ownerSessionId)) { + throw new BrowserRuntimeError("invalid_params", "ownerSessionId is required", method); + } + return call.ownerSessionId as OwnerSessionId; +} + +/** Coordinates native surfaces so React chrome always has at most one attached tab. */ +export class BrowserRuntime { + + private readonly window: BrowserWindow; + private readonly emitEvent: (event: BrowserEvent) => void; + private readonly userDataPath: string; + private readonly profileRegistry: ProfileRegistryLike; + private readonly sessionStore: SessionStoreLike; + private readonly downloadController: DownloadControllerLike; + private readonly installSecurity: SecurityInstallerLike; + private readonly allowFileUrls: boolean; + private readonly prewarmTtlMs: number; + private readonly surfaces = new Map(); + private readonly surfaceOwners = new Map(); + private readonly securityControllers = new Map(); + private readonly orderedSurfaceIds: SurfaceId[] = []; + private readonly prewarmEntries = new Map(); + private readonly restoredOwners = new Set(); + private readonly restoringOwners = new Map>(); + private restoringSurfaceCount = 0; + private readonly automationCoordinator: BrowserAutomationCoordinator; + private readonly captureCoordinator: BrowserCaptureCoordinator; + private readonly inputCoordinator: BrowserInputCoordinator; + private readonly profileAutomation: BrowserProfileAutomation; + private readonly networkControllers = new Map(); + private nextSurfaceNumber = 1; + private readonly activeSurfaceIds = new Map(); + private disposed = false; + private readonly recoveries = new Map(); + + constructor(options: BrowserRuntimeOptions) { + this.window = options.window; + this.emitEvent = options.emit; + this.userDataPath = options.userDataPath; + this.profileRegistry = options.profileRegistry ?? new BrowserProfileRegistry({ userDataPath: options.userDataPath }); + this.sessionStore = options.sessionStore ?? new BrowserSessionStore({ userDataPath: options.userDataPath }); + this.downloadController = options.downloadController ?? new BrowserDownloadController({ emit: options.emit }); + this.installSecurity = options.installSecurity ?? installBrowserSurfaceSecurity; + this.allowFileUrls = options.allowFileUrls === true; + this.prewarmTtlMs = Math.max(1_000, options.prewarmTtlMs ?? 30_000); + this.automationCoordinator = new BrowserAutomationCoordinator({ + resolveSurface: (surfaceId) => { + try { + // Runtime calls resolve an owner-scoped adapter before they reach this coordinator. + const surface = surfaceId === undefined ? undefined : this.surfaces.get(surfaceId as SurfaceId); + if (!surface) return undefined; + return this.automationAdapter(surface); + } catch { + return undefined; + } + }, + downloads: { + wait: (downloadId, timeoutMs) => Promise.resolve(this.downloadController.wait?.(downloadId, timeoutMs) ?? undefined), + list: (surfaceId) => { + const listed = this.downloadController.list?.(surfaceId); + return Array.isArray(listed) ? listed as readonly { readonly downloadId?: unknown }[] : []; + }, + }, + emit: (event) => this.emit(event), + }); + this.captureCoordinator = new BrowserCaptureCoordinator(); + + this.inputCoordinator = new BrowserInputCoordinator(); + this.profileAutomation = new BrowserProfileAutomation({ registry: this.profileRegistry as BrowserProfileRegistry }); + } + + private emit(event: BrowserEvent): void { + if (this.disposed) return; + const surfaceId = event.type === "state" + ? event.surface.surfaceId + : event.type === "download" + ? event.download.surfaceId + : event.type === "console" + ? event.console.surfaceId + : event.error.surfaceId; + const ownerSessionId = this.surfaceOwners.get(surfaceId); + // Events without an explicit managed owner are never broadcast to a workspace. + if (ownerSessionId === undefined) return; + try { + this.emitEvent({ ...event, ownerSessionId }); + } catch { + // Browser event listeners are not allowed to terminate the desktop process. + } + } + + private reportNonFatal(surface: BrowserSurface, kind: "renderer" | "security", code: string, error: unknown, fallback: string): void { + const message = error instanceof Error ? error.message : typeof error === "string" ? error : fallback; + try { + this.emit({ + type: "error", + error: { + surfaceId: surface.surfaceId, + kind, + code: code.slice(0, 128), + message: (message || fallback).slice(0, 1_024), + url: surface.state.url, + fatal: false, + timestamp: Date.now(), + }, + }); + } catch { + // A browser error notification must not terminate the desktop process. + } + } + + private observeNonFatal(operation: () => unknown, surface: BrowserSurface, kind: "renderer" | "security", code: string, fallback: string): void { + try { + const result = operation(); + if (typeof (result as PromiseLike | undefined)?.then === "function") { + void Promise.resolve(result).catch((error: unknown) => this.reportNonFatal(surface, kind, code, error, fallback)); + } + } catch (error) { + this.reportNonFatal(surface, kind, code, error, fallback); + } + } + + private installSurfaceControllers(surface: BrowserSurface, contents: WebContents, session: Session): void { + this.observeNonFatal( + () => this.downloadController.attach?.(contents, surface.surfaceId, session), + surface, + "renderer", + "download_attach_failed", + "Browser download attachment failed", + ); + try { + const security = this.installSecurity({ + webContents: contents, + session, + profile: surface.profile, + window: this.window, + onPopup: (request) => { + const ownerSessionId = this.surfaceOwners.get(surface.surfaceId); + // A popup cannot inherit the one-time consent that created an + // authenticated surface. Keep it blocked until the UI can ask again. + if (surface.profile.kind === "authenticated-profile" || ownerSessionId === undefined || !isSafeBrowserUrl(request.url, this.allowFileUrls)) return false; + this.createSurface(ownerSessionId, surface.profile, request.url, { x: 0, y: 0, width: 800, height: 600 }, false); + return true; + }, + onDownload: (request) => isSafeBrowserUrl(request.url, this.allowFileUrls), + }); + this.securityControllers.set(surface.surfaceId, security); + } catch (error) { + this.reportNonFatal(surface, "security", "security_install_failed", error, "Browser security installation failed"); + } + try { + this.networkController(surface, "browser.network.requests"); + } catch (error) { + this.reportNonFatal(surface, "security", "network_install_failed", error, "Browser network installation failed"); + } + } + + private disposeSecurity(surface: BrowserSurface): void { + const security = this.securityControllers.get(surface.surfaceId); + this.securityControllers.delete(surface.surfaceId); + if (!security) return; + try { + security.dispose(); + } catch (error) { + this.reportNonFatal(surface, "security", "security_dispose_failed", error, "Browser security disposal failed"); + } + } + + private async disposeNetwork(surface: BrowserSurface, network: BrowserNetworkController): Promise { + try { + await network.dispose(); + } catch (error) { + this.reportNonFatal(surface, "security", "network_dispose_failed", error, "Browser network disposal failed"); + } + } + + private persistInBackground(surface: BrowserSurface): void { + void this.persist().catch((error: unknown) => this.reportNonFatal(surface, "renderer", "session_persist_failed", error, "Browser session persistence failed")); + } + + private isRecoveryCurrent(target: RecoveryTarget): boolean { + const { surface, contents, generation } = target; + return !this.disposed + && this.surfaces.get(surface.surfaceId) === surface + && surface.webContents === contents + && surface.generation === generation; + } + + private sameRecoveryTarget(left: RecoveryTarget, right: RecoveryTarget): boolean { + return left.surface === right.surface + && left.contents === right.contents + && left.generation === right.generation; + } + + private takePendingRecovery(entry: RecoveryEntry): RecoveryTarget | undefined { + const pending = entry.pending; + entry.pending = undefined; + return pending; + } + + private scheduleSurfaceRecovery(surface: BrowserSurface, contents: WebContents, generation: number): void { + const target: RecoveryTarget = { surface, contents, generation }; + if (!this.isRecoveryCurrent(target)) return; + const existing = this.recoveries.get(surface.surfaceId); + if (existing) { + if (this.sameRecoveryTarget(existing.target, target) + || (existing.pending !== undefined && this.sameRecoveryTarget(existing.pending, target))) return; + existing.pending = target; + return; + } + const entry: RecoveryEntry = { surfaceId: surface.surfaceId, target, pending: undefined }; + this.recoveries.set(surface.surfaceId, entry); + void this.runRecovery(entry).catch((error: unknown) => this.reportNonFatal(surface, "renderer", "recovery_failed", error, "Browser renderer recovery failed")); + } + + private async runRecovery(entry: RecoveryEntry): Promise { + let target: RecoveryTarget = entry.target; + try { + while (true) { + entry.pending = undefined; + await this.recoverSurface(target); + const pending = this.takePendingRecovery(entry); + if (pending === undefined) return; + entry.target = pending; + target = pending; + } + } finally { + if (this.recoveries.get(entry.surfaceId) === entry) this.recoveries.delete(entry.surfaceId); + } + } + + private lookup(surfaceId: SurfaceId, ownerSessionId: OwnerSessionId, method?: BrowserMethod): BrowserSurface { + const surface = this.surfaces.get(surfaceId); + if (!surface || this.surfaceOwners.get(surfaceId) !== ownerSessionId) { + throw new BrowserRuntimeError("not_found", `Unknown browser surface ${surfaceId}`, method, surfaceId); + } + return surface; + } + + private activeSurface(ownerSessionId: OwnerSessionId, method?: BrowserMethod): BrowserSurface { + const active = this.activeSurfaceIds.get(ownerSessionId); + const id = active !== undefined && this.surfaceOwners.get(active) === ownerSessionId + ? active + : [...this.orderedSurfaceIds].reverse().find((surfaceId) => this.surfaceOwners.get(surfaceId) === ownerSessionId); + if (!id) throw new BrowserRuntimeError("not_found", "No browser surface is open", method); + return this.lookup(id, ownerSessionId, method); + } + + private surfaceStates(ownerSessionId: OwnerSessionId): readonly BrowserSurfaceState[] { + return this.orderedSurfaceIds + .filter((surfaceId) => this.surfaceOwners.get(surfaceId) === ownerSessionId) + .map((surfaceId) => this.surfaces.get(surfaceId)?.state) + .filter((state): state is BrowserSurfaceState => state !== undefined); + } + + private automationAdapter(surface: BrowserSurface, method?: BrowserMethod): BrowserRuntimeAutomationSurface { + const candidate = (surface as unknown as { automationAdapter?: () => unknown }).automationAdapter; + if (typeof candidate !== "function") throw new BrowserRuntimeError("not_supported", "Browser surface automation is unavailable", method, surface.surfaceId); + try { + const adapter = candidate.call(surface); + if (typeof adapter !== "object" || adapter === null) throw new BrowserRuntimeError("not_supported", "Browser surface automation is unavailable", method, surface.surfaceId); + const result = adapter as BrowserRuntimeAutomationSurface; + const contents = result.webContents; + if (!contents || (typeof contents.isDestroyed === "function" && contents.isDestroyed())) { + throw new BrowserRuntimeError("not_found", "Browser surface has no live webContents", method, surface.surfaceId); + } + return result; + } catch (error) { + if (error instanceof BrowserRuntimeError) throw error; + throw new BrowserRuntimeError("not_found", "Browser surface has no live webContents", method, surface.surfaceId); + } + } + + private networkController(surface: BrowserSurface, method: BrowserMethod): BrowserNetworkController { + const existing = this.networkControllers.get(surface.surfaceId); + if (existing) return existing; + const adapter = this.automationAdapter(surface, method); + const session = adapter.browserSession ?? adapter.session; + if (!session) throw new BrowserRuntimeError("not_supported", "Browser surface session is unavailable", method, surface.surfaceId); + const controller = new BrowserNetworkController({ session, webContents: adapter.webContents }); + this.networkControllers.set(surface.surfaceId, controller); + return controller; + } + + private targetSurface(request: Record, ownerSessionId: OwnerSessionId, method: BrowserMethod): BrowserSurface { + return typeof request.surfaceId === "string" + ? this.lookup(request.surfaceId as SurfaceId, ownerSessionId, method) + : this.activeSurface(ownerSessionId, method); + } + + private unwrapControllerResult(result: unknown, method: BrowserMethod, surfaceId?: SurfaceId): unknown { + if (typeof result !== "object" || result === null || !("ok" in result)) return result; + const candidate = result as { readonly ok?: unknown; readonly value?: unknown; readonly code?: unknown; readonly message?: unknown; readonly reason?: unknown }; + if (candidate.ok === true) return candidate.value; + const error = protocolError(candidate, method, surfaceId); + if (error) throw error ?? new BrowserRuntimeError("internal", "Browser operation failed", method, surfaceId); + throw new BrowserRuntimeError("internal", "Browser operation failed", method, surfaceId); + } + + private resolveProfile(profile: unknown): BrowserProfile { + if (typeof profile !== "object" || profile === null || !("kind" in profile) || !("profileId" in profile) || typeof profile.kind !== "string" || typeof profile.profileId !== "string") { + throw new BrowserRuntimeError("invalid_params", "An explicit browser profile is required"); + } + if (profile.kind === "isolated-session" && profile.profileId === "isolated-session") return { kind: "isolated-session", profileId: "isolated-session" }; + if (profile.kind !== "authenticated-profile" || profile.profileId.length === 0 || (profile as { explicitOptIn?: unknown }).explicitOptIn !== true) { + throw new BrowserRuntimeError("security", "Authenticated profiles require explicit opt-in and an exact profileId"); + } + const selected = { kind: "authenticated-profile", profileId: profile.profileId, explicitOptIn: true } as const; + const resolved = this.profileRegistry.resolve?.(selected.profileId); + if (this.profileRegistry.resolve && resolved === undefined) { + throw new BrowserRuntimeError("security", "Authenticated profile was not found"); + } + if (resolved && typeof resolved === "object" && "profileId" in resolved && resolved.profileId !== selected.profileId) { + throw new BrowserRuntimeError("security", "Authenticated profile selection was not exact"); + } + return selected; + } + + private resolveSession(profile: BrowserProfile, ownerSessionId: OwnerSessionId): Session { + const candidate = this.profileRegistry?.getSession?.( + profile, + profile.kind === "isolated-session" ? ownerSessionId : undefined, + ); + if (profile.kind === "isolated-session" && candidate && typeof candidate === "object" && !("then" in candidate)) { + return candidate as Session; + } + if (profile.kind === "isolated-session") throw new BrowserRuntimeError("security", "An isolated browser session could not be created"); + if (candidate && typeof candidate === "object" && !("then" in candidate)) return candidate as Session; + throw new BrowserRuntimeError("security", "An authenticated browser session could not be created"); + } + + private createSurface( + ownerSessionId: OwnerSessionId, + profile: BrowserProfile, + url: string, + bounds: { x: number; y: number; width: number; height: number }, + visible: boolean, + identity?: Pick, + ): BrowserSurface { + const surfaceId = identity?.surfaceId ?? randomUUID() as SurfaceId; + const handle = identity?.handle ?? `surface:${this.nextSurfaceNumber++}` as SurfaceHandle; + const handleNumber = Number(handle.slice("surface:".length)); + if (Number.isSafeInteger(handleNumber)) this.nextSurfaceNumber = Math.max(this.nextSurfaceNumber, handleNumber + 1); + const session = this.resolveSession(profile, ownerSessionId); + const surface = new BrowserSurface({ + window: this.window, + surfaceId, + handle, + profile, + session, + url, + bounds, + visible, + allowFileUrls: this.allowFileUrls, + emit: (event) => this.emit(event), + onCrash: (crashed, contents, generation) => this.scheduleSurfaceRecovery(crashed, contents, generation), + }); + this.surfaces.set(surfaceId, surface); + this.surfaceOwners.set(surfaceId, ownerSessionId); + this.orderedSurfaceIds.push(surfaceId); + this.profileRegistry.markInUse?.(profile.profileId); + const contents = surface.webContents; + if (contents) this.installSurfaceControllers(surface, contents, session); + if (this.restoringSurfaceCount === 0) this.persistInBackground(surface); + return surface; + } + + private async recoverSurface(target: RecoveryTarget): Promise { + const { surface, contents, generation } = target; + if (!this.isRecoveryCurrent(target)) return; + this.disposeSecurity(surface); + const network = this.networkControllers.get(surface.surfaceId); + this.networkControllers.delete(surface.surfaceId); + if (network) await this.disposeNetwork(surface, network); + if (!this.isRecoveryCurrent(target)) return; + const adapter = await surface.replaceAfterCrash(contents, generation); + if (!adapter) return; + const recoveredTarget: RecoveryTarget = { + surface, + contents: adapter.webContents, + generation: surface.generation, + }; + if (!this.isRecoveryCurrent(recoveredTarget)) return; + const ownerSessionId = this.surfaceOwners.get(surface.surfaceId); + if (ownerSessionId === undefined) return; + const session = this.resolveSession(surface.profile, ownerSessionId); + this.installSurfaceControllers(surface, adapter.webContents, session); + } + + private async persist(): Promise { + const save = this.sessionStore.save; + if (!save) return; + const metadata: BrowserSessionMetadata[] = []; + for (const [order, id] of this.orderedSurfaceIds.entries()) { + const surface = this.surfaces.get(id); + if (!surface) continue; + const ownerSessionId = this.surfaceOwners.get(id); + if (ownerSessionId === undefined) continue; + // Authenticated pages require fresh, explicit user selection after an + // app restart. Do not persist their URL as an auto-load instruction. + if (surface.profile.kind === "authenticated-profile") continue; + metadata.push({ surfaceId: id, handle: surface.handle, ownerSessionId, profile: surface.state.profile, url: surface.state.url, order, zoom: 1 }); + } + await Promise.resolve(save.call(this.sessionStore, metadata)); + } + + /** Restores only records that explicitly belong to this durable workspace session. */ + async restore(ownerSessionId: OwnerSessionId): Promise { + if (this.restoredOwners.has(ownerSessionId)) return this.surfaceStates(ownerSessionId); + const inFlight = this.restoringOwners.get(ownerSessionId); + if (inFlight) return inFlight; + const operation = (async (): Promise => { + const load = this.sessionStore.load; + const records = load === undefined ? [] : await Promise.resolve(load.call(this.sessionStore)); + this.restoringSurfaceCount += 1; + try { + if (Array.isArray(records)) { + for (const record of [...records].sort((left, right) => left.order - right.order)) { + if (record.ownerSessionId !== ownerSessionId || this.surfaces.has(record.surfaceId)) continue; + // A stored opt-in is not fresh consent. Old authenticated records + // are ignored without loading their URL or touching the profile. + if (record.profile.kind === "authenticated-profile") continue; + try { + const profile = this.resolveProfile(record.profile); + this.createSurface(ownerSessionId, profile, record.url, { x: 0, y: 0, width: 800, height: 600 }, false, record); + } catch { + // A deleted profile or malformed legacy record must not fall back to another owner. + } + } + } + } finally { + this.restoringSurfaceCount -= 1; + } + this.restoredOwners.add(ownerSessionId); + return this.surfaceStates(ownerSessionId); + })(); + this.restoringOwners.set(ownerSessionId, operation); + try { + return await operation; + } finally { + this.restoringOwners.delete(ownerSessionId); + } + } + + private async activate(surface: BrowserSurface, ownerSessionId: OwnerSessionId): Promise { + for (const candidate of this.surfaces.values()) { + if (candidate.surfaceId !== surface.surfaceId) candidate.setVisible(false); + } + surface.setVisible(true); + this.activeSurfaceIds.set(ownerSessionId, surface.surfaceId); + try { + await this.persist(); + } catch (error) { + this.reportNonFatal(surface, "renderer", "session_persist_failed", error, "Browser session persistence failed"); + } + } + + async prewarm(ownerSessionId: OwnerSessionId, profile: BrowserProfile, url = "about:blank"): Promise { + if (this.disposed) throw new BrowserRuntimeError("invalid_state", "Browser runtime is disposed"); + await this.restore(ownerSessionId); + const resolved = this.resolveProfile(profile); + if (!isSafeBrowserUrl(url, this.allowFileUrls)) throw new BrowserRuntimeError("security", "Unsafe browser URL"); + const existing = this.prewarmEntries.get(ownerSessionId); + if (existing && existing.profileKey === profileKey(resolved)) return existing.surface.state; + this.clearPrewarm(ownerSessionId); + const surface = this.createSurface(ownerSessionId, resolved, url, { x: 0, y: 0, width: 800, height: 600 }, false); + const timer = setTimeout(() => { + if (this.prewarmEntries.get(ownerSessionId)?.surface.surfaceId === surface.surfaceId) this.clearPrewarm(ownerSessionId); + }, this.prewarmTtlMs); + this.prewarmEntries.set(ownerSessionId, { surface, ownerSessionId, profileKey: profileKey(resolved), timer }); + return surface.state; + } + + private clearPrewarm(ownerSessionId: OwnerSessionId): void { + const entry = this.prewarmEntries.get(ownerSessionId); + if (!entry) return; + clearTimeout(entry.timer); + this.prewarmEntries.delete(ownerSessionId); + const existing = this.surfaces.get(entry.surface.surfaceId); + if (existing) this.removeSurface(existing); + } + + private removeSurface(surface: BrowserSurface): BrowserSurfaceState { + this.disposeSecurity(surface); + const network = this.networkControllers.get(surface.surfaceId); + this.networkControllers.delete(surface.surfaceId); + if (network) { + void this.disposeNetwork(surface, network).catch((error: unknown) => this.reportNonFatal(surface, "security", "network_dispose_failed", error, "Browser network disposal failed")); + } + const state = surface.close(); + this.observeNonFatal( + () => this.downloadController.disposeSurface?.(surface.surfaceId), + surface, + "renderer", + "download_dispose_failed", + "Browser download disposal failed", + ); + const index = this.orderedSurfaceIds.indexOf(surface.surfaceId); + if (index >= 0) this.orderedSurfaceIds.splice(index, 1); + this.surfaces.delete(surface.surfaceId); + const ownerSessionId = this.surfaceOwners.get(surface.surfaceId); + this.surfaceOwners.delete(surface.surfaceId); + this.profileRegistry.release?.(surface.profile.profileId); + if (ownerSessionId !== undefined && this.activeSurfaceIds.get(ownerSessionId) === surface.surfaceId) { + const next = [...this.orderedSurfaceIds].reverse().find((surfaceId) => this.surfaceOwners.get(surfaceId) === ownerSessionId); + if (next === undefined) this.activeSurfaceIds.delete(ownerSessionId); + else this.activeSurfaceIds.set(ownerSessionId, next); + } + this.persistInBackground(surface); + return state; + } + async call(call: BrowserCall): Promise { + const method = call.method; + try { + if (this.disposed) throw new BrowserRuntimeError("invalid_state", "Browser runtime is disposed", method); + const ownerSessionId = ownerSessionIdFromCall(call, method); + await this.restore(ownerSessionId); + const request = requestRecord(call.request); + switch (method) { + case "browser.profiles.list": + return { profiles: this.unwrapControllerResult(this.profileAutomation.list(), method) }; + case "browser.profiles.create": { + const profileOptions: BrowserProfileCreateOptions = { + ...(typeof request.profileId === "string" ? { profileId: request.profileId } : {}), + ...(typeof request.label === "string" ? { label: request.label } : {}), + }; + return this.unwrapControllerResult(this.profileAutomation.create(profileOptions), method) as BrowserCallResult; + } + case "browser.profiles.rename": + return this.unwrapControllerResult(this.profileAutomation.rename( + typeof request.profileId === "string" ? request.profileId : "", + typeof request.label === "string" ? request.label : "", + ), method) as BrowserCallResult; + case "browser.profiles.clear": + return this.unwrapControllerResult(this.profileAutomation.clear(request as unknown as BrowserProfileSelectionRequest), method) as BrowserCallResult; + case "browser.profiles.delete": + return this.unwrapControllerResult(this.profileAutomation.delete(request as unknown as BrowserProfileSelectionRequest), method) as BrowserCallResult; + case "browser.import.cookies": + return this.unwrapControllerResult(this.profileAutomation.importCookies(request as unknown as BrowserCookieImportRequest), method) as BrowserCallResult; + case "surface.create": { + const profile = this.resolveProfile(request.profile); + const url = request.url === undefined ? "about:blank" : request.url; + if (typeof url !== "string" || !isSafeBrowserUrl(url, this.allowFileUrls)) throw new BrowserRuntimeError("security", "Unsafe browser URL", method); + const bounds = typeof request.bounds === "object" && request.bounds !== null ? request.bounds as { x: number; y: number; width: number; height: number } : { x: 0, y: 0, width: 800, height: 600 }; + const visible = request.visible !== false; + let surface: BrowserSurface; + const entry = this.prewarmEntries.get(ownerSessionId); + if (entry && entry.profileKey === profileKey(profile) && entry.surface.state.url === url) { + surface = entry.surface; + this.prewarmEntries.delete(ownerSessionId); + clearTimeout(entry.timer); + surface.setBounds(bounds); + if (visible) await this.activate(surface, ownerSessionId); + } else { + if (entry) this.clearPrewarm(ownerSessionId); + surface = this.createSurface(ownerSessionId, profile, url, bounds, false); + if (visible) await this.activate(surface, ownerSessionId); + } + return { surface: surface.state, ...(booleanOption(request, "snapshotAfter") ? { snapshot: await surface.snapshot() } : {}) }; + } + case "surface.list": + return { surfaces: this.surfaceStates(ownerSessionId) }; + case "surface.get": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return { surface: surface.state }; + } + case "surface.close": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return { surface: this.removeSurface(surface) }; + } + case "surface.navigate": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + if (typeof request.url !== "string") throw new BrowserRuntimeError("invalid_params", "url is required", method, surface.surfaceId); + const result = await surface.navigate(request.url, booleanOption(request, "snapshotAfter")); + return result; + } + case "surface.reload": return this.actionFor(ownerSessionId, method, request, (surface) => surface.reload(booleanOption(request, "snapshotAfter"))); + case "surface.goBack": return this.actionFor(ownerSessionId, method, request, (surface) => surface.goBack(booleanOption(request, "snapshotAfter"))); + case "surface.goForward": return this.actionFor(ownerSessionId, method, request, (surface) => surface.goForward(booleanOption(request, "snapshotAfter"))); + case "surface.stop": return this.actionFor(ownerSessionId, method, request, (surface) => surface.stop(booleanOption(request, "snapshotAfter"))); + case "surface.snapshot": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return { snapshot: await surface.snapshot() }; + } + case "surface.screenshot": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return this.captureCoordinator.call(method, request, this.automationAdapter(surface, method)); + } + case "surface.title": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return await surface.title(); + } + case "surface.evaluate": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + if (typeof request.expression !== "string") throw new BrowserRuntimeError("invalid_params", "expression is required", method, surface.surfaceId); + const args = Array.isArray(request.args) ? request.args as readonly BrowserJsonValue[] : []; + return { value: await surface.evaluate(request.expression, args) }; + } + case "surface.setBounds": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + if (typeof request.bounds !== "object" || request.bounds === null) throw new BrowserRuntimeError("invalid_params", "bounds is required", method, surface.surfaceId); + const visible = typeof request.visible === "boolean" ? request.visible : undefined; + surface.setBounds(request.bounds as { x: number; y: number; width: number; height: number }); + if (visible === true) { + await this.activate(surface, ownerSessionId); + } else if (visible === false) { + surface.setVisible(false); + } + return { surface: surface.state }; + } + case "surface.setMuted": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return { surface: surface.setMuted(request.muted === true) }; + } + case "surface.setOmnibarVisible": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return { surface: surface.state }; + } + case "surface.focusAddressBar": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return { surface: surface.setFocused("address") }; + } + case "surface.focusWebView": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + await this.activate(surface, ownerSessionId); + return { surface: surface.setFocused("webview") }; + } + case "surface.restore": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + await surface.restore(typeof request.url === "string" ? request.url : undefined); + return { surface: surface.state }; + } + case "surface.downloads": { + const surfaceId = surfaceIdFromRequest(request); + this.lookup(surfaceId, ownerSessionId, method); + return { downloads: await Promise.resolve(this.downloadController?.list?.(surfaceId) ?? []) }; + } + case "browser.snapshot": + case "browser.eval": + case "browser.wait": + case "browser.click": + case "browser.dblclick": + case "browser.hover": + case "browser.focus": + case "browser.type": + case "browser.fill": + case "browser.press": + case "browser.keydown": + case "browser.keyup": + case "browser.check": + case "browser.uncheck": + case "browser.select": + case "browser.scroll": + case "browser.scroll_into_view": + case "browser.get.text": + case "browser.get.html": + case "browser.get.value": + case "browser.get.attr": + case "browser.get.count": + case "browser.get.box": + case "browser.get.styles": + case "browser.get.title": + case "browser.is.visible": + case "browser.is.enabled": + case "browser.is.checked": + case "browser.find.role": + case "browser.find.text": + case "browser.find.label": + case "browser.find.placeholder": + case "browser.find.testid": + case "browser.find.first": + case "browser.find.last": + case "browser.find.nth": + case "browser.highlight": + case "browser.frame.select": + case "browser.frame.main": + case "browser.cookies.get": + case "browser.cookies.set": + case "browser.cookies.clear": + case "browser.storage.get": + case "browser.storage.set": + case "browser.storage.clear": + case "browser.console.list": + case "browser.console.clear": + case "browser.console.show": + case "browser.errors.list": + case "browser.state.save": + case "browser.state.load": + case "browser.addinitscript": + case "browser.addscript": + case "browser.addstyle": { + const surface = this.targetSurface(request, ownerSessionId, method); + this.automationAdapter(surface, method); + const scopedCall: BrowserCall = { + ...call, + request: { ...request, surfaceId: surface.surfaceId } as BrowserCall["request"], + }; + return this.automationCoordinator.call(scopedCall); + } + case "browser.download.wait": { + const surfaceId = surfaceIdFromRequest(request); + this.lookup(surfaceId, ownerSessionId, method); + const downloadId = typeof request.downloadId === "string" ? request.downloadId : ""; + const downloads = this.downloadController.list?.(surfaceId); + if (!downloadId || !Array.isArray(downloads) || !downloads.some((download) => download !== null && typeof download === "object" && "downloadId" in download && download.downloadId === downloadId)) { + throw new BrowserRuntimeError("not_found", "Download is not owned by this browser surface", method, surfaceId); + } + return this.automationCoordinator.call(call); + } + case "browser.screenshot": + case "browser.viewport.set": + case "browser.zoom.set": + case "browser.is_webview_focused": + case "browser.screencast.start": + case "browser.screencast.stop": + case "browser.trace.start": + case "browser.trace.stop": { + const surface = this.targetSurface(request, ownerSessionId, method); + return this.captureCoordinator.call(method, request, this.automationAdapter(surface, method)); + } + case "browser.focus_webview": { + const surface = this.targetSurface(request, ownerSessionId, method); + await this.activate(surface, ownerSessionId); + return this.captureCoordinator.call(method, request, this.automationAdapter(surface, method)); + } + case "browser.input_mouse": + case "browser.input_keyboard": + case "browser.input_touch": { + const surface = this.targetSurface(request, ownerSessionId, method); + return this.inputCoordinator.call(method, request, this.automationAdapter(surface, method)); + } + case "browser.offline.set": + case "browser.geolocation.set": + case "browser.network.route": + case "browser.network.unroute": + case "browser.network.requests": { + const surface = this.targetSurface(request, ownerSessionId, method); + const network = this.networkController(surface, method); + let result: unknown; + if (method === "browser.offline.set") result = network.setOffline(request as never); + else if (method === "browser.geolocation.set") result = network.setGeolocation(request); + else if (method === "browser.network.route") result = network.route(request as never); + else if (method === "browser.network.unroute") result = network.unroute(typeof request.routeId === "string" ? request.routeId : ""); + else result = network.listRequests(request as never); + return this.unwrapControllerResult(result, method, surface.surfaceId) as BrowserCallResult; + } + case "browser.navigate": { + const surface = this.activeSurface(ownerSessionId, method); + if (typeof request.url !== "string") throw new BrowserRuntimeError("invalid_params", "url is required", method, surface.surfaceId); + return surface.navigate(request.url, booleanOption(request, "snapshotAfter")); + } + case "browser.back": return this.activeSurface(ownerSessionId, method).goBack(booleanOption(request, "snapshotAfter")); + case "browser.forward": return this.activeSurface(ownerSessionId, method).goForward(booleanOption(request, "snapshotAfter")); + case "browser.reload": return this.activeSurface(ownerSessionId, method).reload(booleanOption(request, "snapshotAfter")); + case "browser.url.get": { + const surface = this.activeSurface(ownerSessionId, method); + return surface.title(); + } + case "browser.tab.list": + return { surfaces: this.surfaceStates(ownerSessionId) }; + case "browser.tab.switch": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + await this.activate(surface, ownerSessionId); + return { surface: surface.state }; + } + case "browser.tab.close": { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return { surface: this.removeSurface(surface) }; + } + default: + throw new BrowserRuntimeError("not_supported", `Browser method ${method} is not supported`, method); + } + } catch (error) { + const coded = protocolError(error, method); + if (coded) throw coded; + throw error; + } + } + + private async actionFor(ownerSessionId: OwnerSessionId, method: BrowserMethod, request: Record, action: (surface: BrowserSurface) => Promise): Promise { + const surface = this.lookup(surfaceIdFromRequest(request), ownerSessionId, method); + return action(surface); + } + + async dispose(): Promise { + if (this.disposed) return; + for (const ownerSessionId of this.prewarmEntries.keys()) this.clearPrewarm(ownerSessionId); + let reportingSurface: BrowserSurface | undefined; + try { + await this.persist(); + } catch (error) { + reportingSurface = this.surfaces.values().next().value as BrowserSurface | undefined; + if (reportingSurface) this.reportNonFatal(reportingSurface, "renderer", "session_persist_failed", error, "Browser session persistence failed"); + } + for (const surface of this.surfaces.values()) { + reportingSurface = surface; + this.disposeSecurity(surface); + const network = this.networkControllers.get(surface.surfaceId); + this.networkControllers.delete(surface.surfaceId); + if (network) await this.disposeNetwork(surface, network); + surface.close(); + this.observeNonFatal( + () => this.downloadController.disposeSurface?.(surface.surfaceId), + surface, + "renderer", + "download_dispose_failed", + "Browser download disposal failed", + ); + this.profileRegistry.release?.(surface.profile.profileId); + } + this.networkControllers.clear(); + this.surfaces.clear(); + this.surfaceOwners.clear(); + this.orderedSurfaceIds.length = 0; + this.activeSurfaceIds.clear(); + this.restoredOwners.clear(); + this.recoveries.clear(); + this.automationCoordinator.dispose(); + this.captureCoordinator.dispose(); + this.inputCoordinator.dispose(); + this.profileAutomation.dispose(); + try { + await Promise.resolve(this.downloadController.dispose?.()); + } catch (error) { + if (reportingSurface) this.reportNonFatal(reportingSurface, "renderer", "download_dispose_failed", error, "Browser download disposal failed"); + } finally { + this.disposed = true; + } + } +} diff --git a/apps/desktop/src/browser-security.ts b/apps/desktop/src/browser-security.ts new file mode 100644 index 0000000..3314486 --- /dev/null +++ b/apps/desktop/src/browser-security.ts @@ -0,0 +1,238 @@ +import { app } from "electron"; +import type { BrowserProfile } from "@t4-code/protocol/browser-ipc"; +import type { BrowserWindow, Certificate, Event, Session, WebContents } from "electron"; +import type { BrowserAuthController, BrowserAuthControllerOptions } from "./browser-auth.ts"; +import { createBrowserAuthController } from "./browser-auth.ts"; +import type { BrowserProxyResult, BrowserSystemProxySettings } from "./browser-proxy.ts"; + +const MAX_TEXT_BYTES = 8_192; +const MAX_GRANT_LIFETIME_MS = 10 * 60_000; +type BrowserCancelableEvent = { readonly preventDefault: () => void }; +type PermissionRequestDetails = { readonly requestingUrl?: string; readonly isMainFrame?: boolean }; +type PermissionRequestHandler = (contents: WebContents, permission: string, callback: (allowed: boolean) => void, details?: PermissionRequestDetails) => void; +const ALLOWED_NAVIGATION_SCHEMES = new Set(["http:", "https:"]); + +export type PopupRequest = { + readonly url: string; + readonly frameName: string; + readonly disposition: string; + readonly referrer: string; +}; + +export type DownloadRequest = { readonly url: string; readonly filename: string }; +export type PermissionRequest = { readonly permission: string; readonly origin: string; readonly isMainFrame: boolean; readonly webContents: WebContents }; +export type CertificateRequest = { readonly url: string; readonly scheme: string; readonly host: string; readonly port: number; readonly fingerprint: string; readonly method: "GET" | "HEAD"; readonly error: string; readonly isMainFrame: boolean }; + +export interface BrowserCertificateGrant { + readonly scheme: string; + readonly host: string; + readonly port: number; + readonly fingerprint: string; + readonly expiresAt: number; + readonly method: "GET" | "HEAD"; +} + +export interface BrowserSurfaceSecurityOptions { + readonly webContents: WebContents; + readonly session: Session; + readonly profile: BrowserProfile; + readonly window?: BrowserWindow; + readonly onPopup?: (request: PopupRequest) => boolean; + readonly onDownload?: (request: DownloadRequest) => boolean; + readonly onPermissionPrompt?: (request: PermissionRequest) => boolean; + readonly onCertificateError?: (request: CertificateRequest) => boolean; + readonly auth?: BrowserAuthControllerOptions; +} + +export interface BrowserSurfaceSecurityController { + readonly auth: BrowserAuthController | null; + dispose(): void; + clearTrustGrants(): void; + grantCertificate(grant: Omit & { readonly expiresAt?: number }): boolean; + setProfile(profile: BrowserProfile): void; + configureProxy(settings: BrowserSystemProxySettings): Promise; +} + +interface SessionPermissionState { + readonly policies: Map; + readonly requestHandler: PermissionRequestHandler; +} + +const sessionPermissionStates = new WeakMap(); + +function registerSessionPermissionPolicy(session: Session, webContents: WebContents, policy: PermissionRequestHandler): () => void { + let state = sessionPermissionStates.get(session); + if (!state) { + const policies = new Map(); + const requestHandler: PermissionRequestHandler = (contents, permission, callback, details) => { + const currentPolicy = policies.get(contents); + if (!currentPolicy) { + callback(false); + return; + } + currentPolicy(contents, permission, callback, details); + }; + state = { policies, requestHandler }; + sessionPermissionStates.set(session, state); + session.setPermissionRequestHandler(requestHandler); + session.setPermissionCheckHandler(() => false); + } + state.policies.set(webContents, policy); + + return (): void => { + if (state.policies.get(webContents) !== policy) return; + state.policies.delete(webContents); + if (state.policies.size > 0) return; + sessionPermissionStates.delete(session); + session.setPermissionRequestHandler(null); + session.setPermissionCheckHandler(null); + }; +} + +function text(value: unknown, max = MAX_TEXT_BYTES): value is string { + return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).byteLength <= max; +} + +function safeUrl(value: string): URL | null { + if (!text(value)) return null; + try { return new URL(value); } catch { return null; } +} + +function certificateKey(scheme: string, host: string, port: number, fingerprint: string): string { + return `${scheme.toLowerCase()}|${host.toLowerCase()}|${port}|${fingerprint.toLowerCase().replaceAll(":", "")}`; +} + +function certificateData(url: string, certificate: Certificate): { scheme: string; host: string; port: number; fingerprint: string } | null { + const parsed = safeUrl(url); + const fingerprint = text(certificate.fingerprint, 512) ? certificate.fingerprint : null; + if (!parsed || !ALLOWED_NAVIGATION_SCHEMES.has(parsed.protocol) || !parsed.hostname || !fingerprint) return null; + const port = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80; + if (!Number.isInteger(port) || port < 1 || port > 65_535) return null; + return { scheme: parsed.protocol.slice(0, -1), host: parsed.hostname.toLowerCase(), port, fingerprint }; +} + +function popupRequest(url: string, frameName: string, disposition: string, referrer: string): PopupRequest | null { + if (!text(url) || typeof frameName !== "string" || new TextEncoder().encode(frameName).byteLength > 512 || typeof disposition !== "string" || new TextEncoder().encode(disposition).byteLength > 128 || typeof referrer !== "string" || new TextEncoder().encode(referrer).byteLength > MAX_TEXT_BYTES) return null; + return { url, frameName, disposition, referrer }; +} + +export function installBrowserSurfaceSecurity(options: BrowserSurfaceSecurityOptions): BrowserSurfaceSecurityController { + const { webContents, session } = options; + const grants = new Map(); + const auth = options.auth ? createBrowserAuthController(options.auth) : null; + let profile = options.profile; + let disposed = false; + + const permissionRequest: PermissionRequestHandler = (contents, permission, callback, details) => { + if (disposed || permission === "openExternal") { callback(false); return; } + const origin = details?.requestingUrl ?? contents.getURL(); + const parsedOrigin = text(origin) ? safeUrl(origin) : null; + let allowed = false; + if (parsedOrigin && ALLOWED_NAVIGATION_SCHEMES.has(parsedOrigin.protocol) && options.onPermissionPrompt) { + try { allowed = options.onPermissionPrompt({ permission, origin, isMainFrame: details?.isMainFrame === true, webContents: contents }) === true; } catch { allowed = false; } + } + callback(allowed); + }; + const disposePermissionPolicy = registerSessionPermissionPolicy(session, webContents, permissionRequest); + + const downloadHandler = (event: BrowserCancelableEvent, item: Electron.DownloadItem, contents: WebContents): void => { + if (contents !== webContents) return; + const url = item.getURL(); + const filename = item.getFilename(); + const parsed = safeUrl(url); + let allowed = false; + if (parsed && ALLOWED_NAVIGATION_SCHEMES.has(parsed.protocol) && text(filename, 512) && options.onDownload) { + try { allowed = options.onDownload({ url, filename }) === true; } catch { allowed = false; } + } + if (!allowed) event.preventDefault(); + }; + session.on("will-download", downloadHandler); + const webviewHandler = (event: BrowserCancelableEvent): void => event.preventDefault(); + webContents.on("will-attach-webview", webviewHandler); + const navigateHandler = (event: BrowserCancelableEvent, url: string, _isInPlace: boolean, isMainFrame: boolean): void => { + if (!isMainFrame) return; + grants.clear(); + const parsed = safeUrl(url); + if (!parsed || !ALLOWED_NAVIGATION_SCHEMES.has(parsed.protocol)) event.preventDefault(); + }; + webContents.on("will-navigate", navigateHandler); + const startNavigationHandler = (_event: BrowserCancelableEvent, _url: string, isInPlace: boolean, isMainFrame: boolean): void => { + if (isMainFrame && !isInPlace) grants.clear(); + }; + webContents.on("did-start-navigation", startNavigationHandler); + + const windowOpenHandler = (details: Electron.HandlerDetails): Electron.WindowOpenHandlerResponse => { + const request = popupRequest(details.url, details.frameName, details.disposition, details.referrer.url); + const parsed = request ? safeUrl(request.url) : null; + if (!request || !parsed || !ALLOWED_NAVIGATION_SCHEMES.has(parsed.protocol) || !options.onPopup) return { action: "deny" }; + try { void options.onPopup(request); } catch { /* a failed managed popup must not create Electron's child */ } + return { action: "deny" }; + }; + webContents.setWindowOpenHandler(windowOpenHandler); + const certificateHandler = (event: BrowserCancelableEvent, url: string, error: string, certificate: Certificate, callback: (isTrusted: boolean) => void, isMainFrame: boolean): void => { + event.preventDefault(); + const data = certificateData(url, certificate); + if (!data || !isMainFrame) { callback(false); return; } + const request: CertificateRequest = { url, ...data, method: "GET", error: text(error, 512) ? error : "certificate-error", isMainFrame }; + const key = certificateKey(data.scheme, data.host, data.port, data.fingerprint); + const grant = grants.get(key); + const now = Date.now(); + if (grant && grant.expiresAt > now && (grant.method === "GET" || grant.method === "HEAD")) { + let approved = !options.onCertificateError; + if (options.onCertificateError) { + try { approved = options.onCertificateError(request) === true; } catch { approved = false; } + } + if (grant.expiresAt <= now + MAX_GRANT_LIFETIME_MS && approved) { callback(true); return; } + } + callback(false); + }; + webContents.on("certificate-error", certificateHandler); + const authLoginHandler = (event: Event, contents: WebContents, details: Electron.AuthenticationResponseDetails, authInfo: Electron.AuthInfo, callback: (username?: string, password?: string) => void): void => { + if (disposed || contents !== webContents) return; + auth?.handleLogin(event, contents, details, authInfo, callback); + }; + if (auth) app.on("login", authLoginHandler); + + return { + auth, + dispose(): void { + if (disposed) return; + disposed = true; + grants.clear(); + disposePermissionPolicy(); + session.off("will-download", downloadHandler); + webContents.off("will-attach-webview", webviewHandler); + webContents.off("will-navigate", navigateHandler); + webContents.off("did-start-navigation", startNavigationHandler); + webContents.off("certificate-error", certificateHandler); + if (auth) app.off("login", authLoginHandler); + webContents.setWindowOpenHandler(() => ({ action: "deny" })); + auth?.dispose(); + }, + clearTrustGrants(): void { grants.clear(); }, + grantCertificate(grant): boolean { + if (disposed || !text(grant.scheme, 32) || !text(grant.host, 512) || grant.host.includes("*") || !text(grant.fingerprint, 512) || !/^[a-f0-9:]+$/iu.test(grant.fingerprint) || !Number.isInteger(grant.port) || grant.port < 1 || grant.port > 65_535 || (grant.method !== "GET" && grant.method !== "HEAD")) return false; + const parsedScheme = grant.scheme.toLowerCase().replace(/:$/u, ""); + if (parsedScheme !== "http" && parsedScheme !== "https") return false; + const expiry = grant.expiresAt ?? Date.now() + MAX_GRANT_LIFETIME_MS; + if (!Number.isFinite(expiry) || expiry <= Date.now() || expiry > Date.now() + MAX_GRANT_LIFETIME_MS) return false; + const value: BrowserCertificateGrant = { scheme: parsedScheme, host: grant.host.toLowerCase(), port: grant.port, fingerprint: grant.fingerprint, expiresAt: expiry, method: grant.method }; + grants.set(certificateKey(value.scheme, value.host, value.port, value.fingerprint), value); + return true; + }, + setProfile(nextProfile): void { + if (nextProfile.profileId !== profile.profileId || nextProfile.kind !== profile.kind) grants.clear(); + profile = nextProfile; + }, + configureProxy(_settings): Promise { + return Promise.resolve({ + ok: false, + code: "not_supported", + message: "Electron proxy configuration is session-wide and cannot be safely scoped to one browser surface", + }); + }, + }; +} + +export type { BrowserAuthControllerOptions } from "./browser-auth.ts"; +export type { BrowserProxyResult, BrowserSystemProxySettings } from "./browser-proxy.ts"; diff --git a/apps/desktop/src/browser-session-store.ts b/apps/desktop/src/browser-session-store.ts new file mode 100644 index 0000000..581dc2d --- /dev/null +++ b/apps/desktop/src/browser-session-store.ts @@ -0,0 +1,256 @@ +import ElectronStore from "electron-store"; +import type { BrowserProfile, SurfaceHandle, SurfaceId } from "@t4-code/protocol/browser-ipc"; + +export const BROWSER_SESSION_STORE_VERSION = 2 as const; +export const MAX_BROWSER_SESSIONS = 64; +const MAX_SURFACE_ID_BYTES = 64; +const MAX_SURFACE_HANDLE_BYTES = 32; +const MAX_SESSION_ID_BYTES = 128; +const MAX_URL_BYTES = 8_192; +const MAX_ORDER = 100_000; +const MIN_ZOOM = 0.25; +const MAX_ZOOM = 5; +const SURFACE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const SURFACE_HANDLE_PATTERN = /^surface:[1-9][0-9]{0,8}$/u; +const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const SENSITIVE_QUERY_KEY = /(?:token|secret|password|passwd|credential|authorization|auth|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|session|cookie|code)/iu; + +export interface BrowserSessionMetadata { + readonly surfaceId: SurfaceId; + readonly handle: SurfaceHandle; + /** Durable workspace-session owner. Records without this field are legacy and ignored. */ + readonly ownerSessionId: string; + readonly profile: BrowserProfile; + readonly url: string; + readonly order: number; + readonly zoom: number; +} + +export interface BrowserSessionStoreState { + readonly version: 2; + readonly surfaces: readonly BrowserSessionMetadata[]; +} + +export interface BrowserSessionStoreBackend { + readonly store: unknown; + set(key: string, value: unknown): void; +} + +export interface BrowserSessionStoreOptions { + readonly userDataPath?: string; + readonly store?: BrowserSessionStoreBackend; +} + +function emptyState(): BrowserSessionStoreState { + return { version: BROWSER_SESSION_STORE_VERSION, surfaces: [] }; +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} +function replaceControlCharacters(value: string, replacement: string): string { + let result = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + result += codePoint !== undefined && (codePoint <= 0x1F || codePoint === 0x7F || (codePoint >= 0x80 && codePoint <= 0x9F)) ? replacement : character; + } + return result; +} + +function boundedString(value: unknown, maxBytes: number): string | undefined { + if (typeof value !== "string" || value.length === 0) return undefined; + let result = replaceControlCharacters(value.normalize("NFKC"), " ").trim(); + while (utf8Length(result) > maxBytes) result = result.slice(0, Math.max(1, result.length - 1)); + return result.length === 0 ? undefined : result; +} + +/** + * Keeps only a navigation URL that is safe to restore. Temporary document URLs + * and credential-bearing URLs become about:blank; fragments and known secret + * query parameters are never written to disk. + */ +export function sanitizeBrowserNavigationUrl(value: unknown): string { + const candidate = boundedString(value, MAX_URL_BYTES); + if (candidate === undefined) return "about:blank"; + if (candidate.toLowerCase() === "about:blank") return "about:blank"; + let parsed: URL; + try { parsed = new URL(candidate); } catch { return "about:blank"; } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "about:blank"; + if (parsed.username !== "" || parsed.password !== "") return "about:blank"; + for (const key of Array.from(parsed.searchParams.keys())) { + if (SENSITIVE_QUERY_KEY.test(key)) parsed.searchParams.delete(key); + } + parsed.hash = ""; + const result = parsed.toString(); + return utf8Length(result) <= MAX_URL_BYTES ? result : "about:blank"; +} + +function safeSurfaceId(value: unknown): SurfaceId | undefined { + const result = boundedString(value, MAX_SURFACE_ID_BYTES); + return result !== undefined && SURFACE_ID_PATTERN.test(result) ? result as SurfaceId : undefined; +} + +function safeSurfaceHandle(value: unknown): SurfaceHandle | undefined { + const result = boundedString(value, MAX_SURFACE_HANDLE_BYTES); + return result !== undefined && SURFACE_HANDLE_PATTERN.test(result) ? result as SurfaceHandle : undefined; +} + +function safeOwnerSessionId(value: unknown): string | undefined { + const result = boundedString(value, MAX_SESSION_ID_BYTES); + return result !== undefined && SESSION_ID_PATTERN.test(result) ? result : undefined; +} + +function safeProfile(value: unknown): BrowserProfile | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const profile = value as Record; + if (profile.kind === "isolated-session" && profile.profileId === "isolated-session" && Object.keys(profile).every((key) => key === "kind" || key === "profileId")) { + return { kind: "isolated-session", profileId: "isolated-session" }; + } + if (profile.kind === "authenticated-profile" && profile.explicitOptIn === true && typeof profile.profileId === "string") { + const profileId = boundedString(profile.profileId, 96); + if (profileId !== undefined && /^[a-z][a-z0-9._-]{0,63}$/u.test(profileId) && !["default", "session"].includes(profileId)) { + return { kind: "authenticated-profile", profileId, explicitOptIn: true }; + } + } + return undefined; +} + +function normalizeRecord(value: unknown): BrowserSessionMetadata | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const input = value as Record; + const surfaceId = safeSurfaceId(input.surfaceId); + const handle = safeSurfaceHandle(input.handle); + const ownerSessionId = safeOwnerSessionId(input.ownerSessionId); + const profile = safeProfile(input.profile); + if (surfaceId === undefined || handle === undefined || ownerSessionId === undefined || profile === undefined) return undefined; + const order = typeof input.order === "number" && Number.isSafeInteger(input.order) && input.order >= 0 && input.order <= MAX_ORDER ? input.order : undefined; + const zoom = typeof input.zoom === "number" && Number.isFinite(input.zoom) && input.zoom >= MIN_ZOOM && input.zoom <= MAX_ZOOM ? input.zoom : undefined; + if (order === undefined || zoom === undefined) return undefined; + return { surfaceId, handle, ownerSessionId, profile, url: sanitizeBrowserNavigationUrl(input.url), order, zoom }; +} + +function decodeState(value: unknown): BrowserSessionStoreState { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid browser session state"); + const root = value as Record; + if (root.version !== BROWSER_SESSION_STORE_VERSION || !Array.isArray(root.surfaces) || root.surfaces.length > MAX_BROWSER_SESSIONS) throw new Error("invalid browser session state"); + const surfaces: BrowserSessionMetadata[] = []; + const surfaceIds = new Set(); + const handles = new Set(); + for (const item of root.surfaces) { + // Legacy records used a runtime-generated sessionId and cannot be safely + // assigned to a durable workspace owner. Drop them rather than restoring them. + if (item !== null && typeof item === "object" && !Array.isArray(item) && !("ownerSessionId" in item)) continue; + const record = normalizeRecord(item); + if (record === undefined || surfaceIds.has(record.surfaceId) || handles.has(record.handle)) throw new Error("invalid browser session record"); + surfaceIds.add(record.surfaceId); + handles.add(record.handle); + surfaces.push(record); + } + return { version: BROWSER_SESSION_STORE_VERSION, surfaces }; +} +export function decodeBrowserSessionStoreState(value: unknown): BrowserSessionStoreState { + try { return decodeState(value); } catch { return emptyState(); } +} + +function enqueue(queue: { tail: Promise }, operation: () => T | Promise): Promise { + const result = queue.tail.then(operation, operation); + queue.tail = result.then(() => undefined, () => undefined); + return result; +} + +/** Persists only bounded, restorable browser layout/navigation metadata. */ +export class BrowserSessionStore { + private readonly store: BrowserSessionStoreBackend; + private readonly writeQueue = { tail: Promise.resolve() }; + private state: BrowserSessionStoreState; + + constructor(options: BrowserSessionStoreOptions = {}) { + this.store = options.store ?? new ElectronStore({ + name: "browser-session-store", + ...(options.userDataPath === undefined ? {} : { cwd: options.userDataPath }), + defaults: emptyState(), + }); + this.state = this.readState(); + } + + load(): readonly BrowserSessionMetadata[] { + this.state = this.readState(); + return this.state.surfaces.map((record) => ({ ...record, profile: { ...record.profile } })); + } + + read(): readonly BrowserSessionMetadata[] { return this.load(); } + + save(value: unknown): Promise { + return this.write(value); + } + + write(value: unknown): Promise { + const records = Array.isArray(value) ? value : [value]; + return enqueue(this.writeQueue, () => { + const normalized = records.map(normalizeRecord).filter((record): record is BrowserSessionMetadata => record !== undefined); + const surfaceIds = new Set(); + const handles = new Set(); + const unique = normalized.filter((record) => { + if (surfaceIds.has(record.surfaceId) || handles.has(record.handle)) return false; + surfaceIds.add(record.surfaceId); + handles.add(record.handle); + return true; + }).slice(0, MAX_BROWSER_SESSIONS); + const next: BrowserSessionStoreState = { version: BROWSER_SESSION_STORE_VERSION, surfaces: unique }; + this.store.set("version", next.version); + this.store.set("surfaces", next.surfaces); + this.state = next; + }); + } + + upsert(value: BrowserSessionMetadata): Promise { + const record = normalizeRecord(value); + if (record === undefined) return Promise.reject(new Error("invalid browser session metadata")); + return enqueue(this.writeQueue, () => { + const records = this.state.surfaces.filter((item) => item.surfaceId !== record.surfaceId); + if (records.some((item) => item.handle === record.handle)) throw new Error("browser surface handle is already in use"); + records.push(record); + records.sort((left, right) => left.order - right.order); + const next: BrowserSessionStoreState = { version: BROWSER_SESSION_STORE_VERSION, surfaces: records.slice(0, MAX_BROWSER_SESSIONS) }; + this.store.set("version", next.version); + this.store.set("surfaces", next.surfaces); + this.state = next; + }); + } + + remove(surfaceId: string): Promise { + return enqueue(this.writeQueue, () => { + const next: BrowserSessionStoreState = { + version: BROWSER_SESSION_STORE_VERSION, + surfaces: this.state.surfaces.filter((record) => record.surfaceId !== surfaceId), + }; + this.store.set("version", next.version); + this.store.set("surfaces", next.surfaces); + this.state = next; + }); + } + + clear(): Promise { + return enqueue(this.writeQueue, () => { + const next = emptyState(); + this.store.set("version", next.version); + this.store.set("surfaces", next.surfaces); + this.state = next; + }); + } + + private readState(): BrowserSessionStoreState { + try { + const state = decodeState(this.store.store); + return state; + } catch { + const state = emptyState(); + try { this.store.set("version", state.version); this.store.set("surfaces", state.surfaces); } catch { /* best effort recovery */ } + return state; + } + } +} + +export function normalizeBrowserSessionMetadata(value: unknown): BrowserSessionMetadata | undefined { + return normalizeRecord(value); +} \ No newline at end of file diff --git a/apps/desktop/src/browser-surface.ts b/apps/desktop/src/browser-surface.ts new file mode 100644 index 0000000..c48bb00 --- /dev/null +++ b/apps/desktop/src/browser-surface.ts @@ -0,0 +1,701 @@ +import { join } from "node:path"; +import type { BrowserWindow, Session, WebContents, WebContentsView } from "electron"; +import { WebContentsView as NativeWebContentsView, session as electronSession } from "electron"; +import type { + BrowserBounds, + BrowserEvent, + BrowserJsonValue, + BrowserProfile, + BrowserReadyState, + BrowserSnapshot, + BrowserSurfaceState, + SurfaceHandle, + SurfaceId, +} from "@t4-code/protocol/browser-ipc"; + +export interface BrowserSurfaceOptions { + readonly window: BrowserWindow; + readonly surfaceId: SurfaceId; + readonly handle: SurfaceHandle; + readonly profile: BrowserProfile; + readonly session?: Session; + readonly url: string; + readonly bounds: BrowserBounds; + readonly visible: boolean; + readonly allowFileUrls?: boolean; + readonly emit: (event: BrowserEvent) => void; + readonly onCrash?: (surface: BrowserSurface, contents: WebContents, generation: number) => void; +} + +export interface BrowserSurfaceAction { + readonly surface: BrowserSurfaceState; + readonly postActionSnapshot?: BrowserSnapshot; +} +export interface BrowserSurfaceViewport { + readonly width: number; + readonly height: number; +} + +/** Stable, credential-free surface view consumed by browser automation modules. */ +export interface BrowserSurfaceAutomationAdapter { + readonly surfaceId: SurfaceId; + readonly webContents: WebContents; + readonly browserSession: Session; + readonly session: Session; + readonly profile: BrowserProfile; + readonly bounds: BrowserBounds; + readonly viewport: BrowserSurfaceViewport; + readonly state: BrowserSurfaceState; + readonly snapshot: () => Promise; + readonly getSnapshot: () => Promise; + readonly getBounds: () => BrowserBounds; + readonly getViewport: () => BrowserSurfaceViewport; + readonly setBounds: (bounds: BrowserBounds) => BrowserSurfaceState; + readonly setViewport: (viewport: BrowserSurfaceViewport) => BrowserSurfaceViewport; + readonly resetViewport: () => BrowserSurfaceViewport; + readonly setZoomFactor: (zoomFactor: number) => number; + readonly getZoomFactor: () => number; + readonly focus: () => void; + readonly isFocused: () => boolean; + readonly waitForContentReady: (timeoutMs: number) => Promise; +} + +const DEFAULT_BOUNDS: BrowserBounds = { x: 0, y: 0, width: 800, height: 600 }; +const EMPTY_URL = "about:blank"; + +export function isSafeBrowserUrl(value: string, allowFile = false): boolean { + if (value.length === 0 || value.length > 16_384) return false; + if (value === "about:blank") return true; + try { + const parsed = new URL(value); + if (parsed.protocol === "http:" || parsed.protocol === "https:") return true; + if (parsed.protocol === "about:") return parsed.href === "about:blank"; + if (parsed.protocol === "file:") return allowFile; + return false; + } catch { + return false; + } +} + +function copyBounds(bounds: BrowserBounds): BrowserBounds { + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; +} + +function safeBounds(bounds: BrowserBounds): BrowserBounds { + const candidate = bounds ?? DEFAULT_BOUNDS; + return { + x: Number.isFinite(candidate.x) ? candidate.x : 0, + y: Number.isFinite(candidate.y) ? candidate.y : 0, + width: Math.max(1, Number.isFinite(candidate.width) ? candidate.width : DEFAULT_BOUNDS.width), + height: Math.max(1, Number.isFinite(candidate.height) ? candidate.height : DEFAULT_BOUNDS.height), + }; +} + +function safeViewport(viewport: BrowserSurfaceViewport): BrowserSurfaceViewport { + const width = Number.isFinite(viewport?.width) ? Math.floor(viewport.width) : DEFAULT_BOUNDS.width; + const height = Number.isFinite(viewport?.height) ? Math.floor(viewport.height) : DEFAULT_BOUNDS.height; + return { width: Math.max(1, Math.min(4_096, width)), height: Math.max(1, Math.min(4_096, height)) }; +} + +function stateReadyState(value: string): BrowserReadyState { + return value === "loading" || value === "interactive" ? value : "complete"; +} + +function failureMessage(error: unknown, fallback: string): string { + const message = error instanceof Error ? error.message : typeof error === "string" ? error : fallback; + return (message || fallback).slice(0, 1_024); +} +type BrowserSurfaceReadyErrorCode = "invalid_state" | "internal" | "timeout"; + +class BrowserSurfaceReadyError extends Error { + readonly code: BrowserSurfaceReadyErrorCode; + + constructor(code: BrowserSurfaceReadyErrorCode, message: string) { + super(message); + this.name = "BrowserSurfaceReadyError"; + this.code = code; + } +} + +/** One native WebContentsView and its serializable browser state. */ +export class BrowserSurface { + readonly surfaceId: SurfaceId; + readonly handle: SurfaceHandle; + readonly profile: BrowserProfile; + readonly window: BrowserWindow; + + private readonly emit: (event: BrowserEvent) => void; + private readonly allowFileUrls: boolean; + private readonly onCrash: ((surface: BrowserSurface, contents: WebContents, generation: number) => void) | undefined; + private readonly browserSession: Session; + private view: WebContentsView | null = null; + private attached = false; + private stateValue: BrowserSurfaceState; + private contentReadyContents: WebContents | null = null; + private contentReadyFailure: { contents: WebContents; error: BrowserSurfaceReadyError } | null = null; + private closed = false; + private replacing = false; + private viewGeneration = 0; + private zoomFactor = 1; + private viewportValue: BrowserSurfaceViewport; + constructor(options: BrowserSurfaceOptions) { + this.surfaceId = options.surfaceId; + this.handle = options.handle; + this.profile = options.profile; + this.window = options.window; + this.emit = options.emit; + this.allowFileUrls = options.allowFileUrls === true; + this.onCrash = options.onCrash; + this.browserSession = options.session ?? electronSession.defaultSession; + const now = Date.now(); + this.stateValue = { + surfaceId: options.surfaceId, + handle: options.handle, + profile: options.profile, + url: isSafeBrowserUrl(options.url, this.allowFileUrls) ? options.url : EMPTY_URL, + title: "", + lifecycle: "creating", + readyState: "loading", + loading: true, + progress: 0, + canGoBack: false, + canGoForward: false, + bounds: safeBounds(options.bounds), + visible: options.visible, + muted: false, + focused: "none", + createdAt: now, + updatedAt: now, + }; + this.viewportValue = { width: this.stateValue.bounds.width, height: this.stateValue.bounds.height }; + this.createView(); + } + + get state(): BrowserSurfaceState { + return { ...this.stateValue, bounds: copyBounds(this.stateValue.bounds) }; + } + + get webContents(): WebContents | null { + return this.view?.webContents ?? null; + } + + get nativeView(): WebContentsView | null { + return this.view; + } + /** + * Returns a fresh structural adapter. Accessors resolve live state so a + * renderer replacement never leaves automation holding stale WebContents. + */ + automationAdapter(): BrowserSurfaceAutomationAdapter { + const getSurface = () => this; + return { + get surfaceId() { return getSurface().surfaceId; }, + get webContents() { + const contents = getSurface().webContents; + if (!contents || contents.isDestroyed()) throw new Error("Surface is unavailable"); + return contents; + }, + get browserSession() { return getSurface().browserSession; }, + get session() { return getSurface().browserSession; }, + get profile() { return getSurface().profile; }, + get bounds() { return getSurface().state.bounds; }, + get viewport() { return getSurface().getViewport(); }, + get state() { return getSurface().state; }, + snapshot: () => getSurface().snapshot(), + getSnapshot: () => getSurface().snapshot(), + getBounds: () => getSurface().state.bounds, + getViewport: () => getSurface().getViewport(), + setBounds: (bounds) => getSurface().setBounds(bounds), + setViewport: (viewport) => getSurface().setViewport(viewport), + resetViewport: () => getSurface().resetViewport(), + setZoomFactor: (zoomFactor) => getSurface().setZoomFactor(zoomFactor), + getZoomFactor: () => getSurface().getZoomFactor(), + waitForContentReady: (timeoutMs) => getSurface().waitForContentReady(timeoutMs), + focus: () => { getSurface().setFocused("webview"); }, + isFocused: () => getSurface().isFocused(), + }; + } + + private update(patch: Partial, emit = true): void { + if (this.closed) return; + this.stateValue = { ...this.stateValue, ...patch, updatedAt: Date.now() }; + if (emit) this.emitState(); + } + + private emitState(): void { + try { + this.emit({ type: "state", surface: this.state }); + } catch { + // Child WebContents lifecycle failures must not escape through listeners. + } + } + + get generation(): number { + return this.viewGeneration; + } + + private isCurrentContents(contents: WebContents, generation: number): boolean { + return !this.closed && this.view?.webContents === contents && this.viewGeneration === generation; + } + + private emitNonFatalError(kind: "navigation" | "renderer", code: string, message: string, url?: string): void { + try { + this.emit({ + type: "error", + error: { + surfaceId: this.surfaceId, + kind, + code: code.slice(0, 128), + message: message.slice(0, 1_024), + ...(url === undefined ? {} : { url }), + fatal: false, + timestamp: Date.now(), + }, + }); + } catch { + // Browser event subscribers must not turn a child WebContents failure fatal. + } + } + + private loadInBackground(url: string, contents: WebContents, generation: number): void { + void this.loadContents(url, contents, generation).catch((error: unknown) => { + if (!this.isCurrentContents(contents, generation)) return; + this.emitNonFatalError("navigation", "load_failed", failureMessage(error, "Browser page failed to load"), url); + }); + } + + private createView(): void { + if (this.closed) return; + this.contentReadyContents = null; + this.contentReadyFailure = null; + const view = new NativeWebContentsView({ + webPreferences: { + preload: join(__dirname, "browser-content-preload.cjs"), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + session: this.browserSession, + }, + }); + const generation = this.viewGeneration + 1; + this.view = view; + this.viewGeneration = generation; + this.installHandlers(view.webContents, generation); + if (this.stateValue.visible) this.attach(); + this.loadInBackground(this.stateValue.url, view.webContents, generation); + } + + private installHandlers(contents: WebContents, generation: number): void { + type SurfaceEventListener = (...args: never[]) => void; + const wc = contents as WebContents & { + on: (name: string, listener: SurfaceEventListener) => void; + }; + const isCurrent = (): boolean => this.isCurrentContents(contents, generation); + wc.on("did-start-loading", () => { + if (!isCurrent()) return; + this.contentReadyContents = null; + this.contentReadyFailure = null; + this.update({ lifecycle: "loading", loading: true, readyState: "loading", progress: 0 }); + }); + wc.on("did-stop-loading", () => { + if (!isCurrent()) return; + this.update({ loading: false, progress: 1, lifecycle: "ready", readyState: "complete", canGoBack: contents.navigationHistory.canGoBack(), canGoForward: contents.navigationHistory.canGoForward() }); + }); + wc.on("did-finish-load", () => { + if (!isCurrent()) return; + this.contentReadyContents = contents; + this.contentReadyFailure = null; + this.update({ url: contents.getURL() || this.stateValue.url, title: contents.getTitle(), loading: false, lifecycle: "ready", readyState: "complete", progress: 1, canGoBack: contents.navigationHistory.canGoBack(), canGoForward: contents.navigationHistory.canGoForward() }); + }); + wc.on("preload-error", () => { + if (!isCurrent()) return; + this.contentReadyFailure = { contents, error: new BrowserSurfaceReadyError("internal", "Browser content preload failed") }; + }); + wc.on("destroyed", () => { + if (!isCurrent()) return; + this.contentReadyFailure = { contents, error: new BrowserSurfaceReadyError("invalid_state", "Browser surface was destroyed") }; + }); + wc.on("did-navigate", (_event: unknown, url: string) => { + if (!isCurrent()) return; + if (isSafeBrowserUrl(url, this.allowFileUrls)) this.update({ url, canGoBack: contents.navigationHistory.canGoBack(), canGoForward: contents.navigationHistory.canGoForward() }); + }); + wc.on("did-navigate-in-page", (_event: unknown, url: string) => { + if (!isCurrent()) return; + if (isSafeBrowserUrl(url, this.allowFileUrls)) this.update({ url, canGoBack: contents.navigationHistory.canGoBack(), canGoForward: contents.navigationHistory.canGoForward() }); + }); + wc.on("page-title-updated", (_event: unknown, title: string) => { + if (isCurrent()) this.update({ title }); + }); + wc.on("did-fail-load", (_event: unknown, errorCode: number, errorDescription: string, validatedURL: string, isMainFrame: boolean) => { + if (!isCurrent() || !isMainFrame || errorCode === -3) return; + this.update({ lifecycle: "failed", loading: false, readyState: "complete", progress: 1, ...(isSafeBrowserUrl(validatedURL, this.allowFileUrls) ? { url: validatedURL } : {}) }); + this.emitNonFatalError("navigation", String(errorCode), errorDescription || "Navigation failed", validatedURL || undefined); + }); + wc.on("render-process-gone", (_event: unknown, details: { reason?: string; exitCode?: number }) => { + if (!isCurrent() || this.replacing) return; + this.contentReadyFailure = { contents, error: new BrowserSurfaceReadyError("invalid_state", "Browser renderer exited") }; + this.update({ lifecycle: "crashed", loading: false }); + this.emitNonFatalError("renderer", details?.reason ?? "crashed", "Browser renderer exited", this.stateValue.url); + try { + this.onCrash?.(this, contents, generation); + } catch (error) { + this.emitNonFatalError("renderer", "recovery_failed", failureMessage(error, "Browser renderer recovery failed"), this.stateValue.url); + } + }); + } + + private attach(): void { + if (this.closed || this.attached || !this.view || !this.stateValue.visible) return; + const contentView = (this.window as BrowserWindow & { contentView?: { addChildView(view: WebContentsView): void } }).contentView; + if (!contentView) return; + try { + contentView.addChildView(this.view); + // Mark the view attached immediately after addChildView succeeds. If + // applying bounds fails while the window is closing, a later visibility + // update must still treat detach as idempotent. + this.attached = true; + try { this.view.setBounds(copyBounds(this.stateValue.bounds)); } catch { /* window is closing */ } + } catch { + // A window can close between the visibility check and attachment. + } + } + + private detach(): void { + if (!this.view || !this.attached) return; + const view = this.view; + const contentView = (this.window as BrowserWindow & { contentView?: { removeChildView(view: WebContentsView): void } }).contentView; + try { contentView?.removeChildView(view); } catch { /* already detached */ } + this.attached = false; + } + + private closeView(): void { + const view = this.view; + if (!view) return; + this.detach(); + try { view.webContents.close({ waitForBeforeUnload: false }); } catch { /* already closed */ } + this.view = null; + this.contentReadyContents = null; + this.contentReadyFailure = null; + } + private applyZoom(): void { + const contents = this.webContents; + if (!contents || contents.isDestroyed()) return; + try { contents.setZoomFactor(this.zoomFactor); } catch { /* before ready or closing */ } + } + + getBounds(): BrowserBounds { + return copyBounds(this.stateValue.bounds); + } + + getViewport(): BrowserSurfaceViewport { + return { width: this.viewportValue.width, height: this.viewportValue.height }; + } + + setViewport(viewport: BrowserSurfaceViewport): BrowserSurfaceViewport { + const next = safeViewport(viewport); + this.viewportValue = next; + const contents = this.webContents as (WebContents & { + enableDeviceEmulation?: (parameters: Record) => void; + }) | null; + if (contents && !contents.isDestroyed() && typeof contents.enableDeviceEmulation === "function") { + try { + contents.enableDeviceEmulation({ + screenPosition: "desktop", + screenSize: next, + viewSize: next, + deviceScaleFactor: 1, + scale: 1, + }); + } catch { /* before ready or closing */ } + } + return next; + } + + resetViewport(): BrowserSurfaceViewport { + this.viewportValue = { width: this.stateValue.bounds.width, height: this.stateValue.bounds.height }; + const contents = this.webContents as (WebContents & { + disableDeviceEmulation?: () => void; + }) | null; + if (contents && !contents.isDestroyed() && typeof contents.disableDeviceEmulation === "function") { + try { contents.disableDeviceEmulation(); } catch { /* before ready or closing */ } + } + return this.getViewport(); + } + + setZoomFactor(zoomFactor: number): number { + this.zoomFactor = Math.max(0.25, Math.min(5, Number.isFinite(zoomFactor) ? zoomFactor : 1)); + this.applyZoom(); + return this.zoomFactor; + } + + getZoomFactor(): number { + const contents = this.webContents as (WebContents & { + getZoomFactor?: () => number; + }) | null; + if (contents && !contents.isDestroyed() && typeof contents.getZoomFactor === "function") { + try { + const factor = contents.getZoomFactor(); + if (Number.isFinite(factor)) return factor; + } catch { /* before ready or closing */ } + } + return this.zoomFactor; + } + + isFocused(): boolean { + const contents = this.webContents as (WebContents & { + isFocused?: () => boolean; + }) | null; + if (!contents || contents.isDestroyed() || typeof contents.isFocused !== "function") return false; + try { return contents.isFocused(); } catch { return false; } + } + async waitForContentReady(timeoutMs: number): Promise { + if (this.closed) throw new BrowserSurfaceReadyError("invalid_state", "Browser surface is closed"); + const contents = this.view?.webContents; + if (!contents || contents.isDestroyed()) throw new BrowserSurfaceReadyError("invalid_state", "Browser surface is unavailable"); + if (this.contentReadyContents === contents) { + let loadingMainFrame = true; + try { loadingMainFrame = contents.isLoadingMainFrame(); } catch { /* contents is closing */ } + if (!loadingMainFrame) return; + } + const timeout = Number.isFinite(timeoutMs) ? Math.max(0, timeoutMs) : 0; + if (timeout === 0) throw new BrowserSurfaceReadyError("timeout", "Browser content did not become ready before the timeout"); + type SurfaceEventListener = (...args: never[]) => void; + const target = contents as WebContents & { + on: (name: string, listener: SurfaceEventListener) => void; + removeListener: (name: string, listener: SurfaceEventListener) => void; + }; + await new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType; + const onFinish = (): void => { + this.contentReadyContents = contents; + this.contentReadyFailure = null; + settleResolve(); + }; + const onPreloadError = (): void => settleReject(new BrowserSurfaceReadyError("internal", "Browser content preload failed")); + const onRendererGone = (): void => settleReject(new BrowserSurfaceReadyError("invalid_state", "Browser renderer exited")); + const onDestroyed = (): void => settleReject(new BrowserSurfaceReadyError("invalid_state", "Browser surface was destroyed")); + const cleanup = (): void => { + target.removeListener("did-finish-load", onFinish); + target.removeListener("preload-error", onPreloadError); + target.removeListener("render-process-gone", onRendererGone); + target.removeListener("destroyed", onDestroyed); + clearTimeout(timer); + }; + const settleResolve = (): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const settleReject = (error: BrowserSurfaceReadyError): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + timer = setTimeout(() => settleReject(new BrowserSurfaceReadyError("timeout", "Browser content did not become ready before the timeout")), timeout); + target.on("did-finish-load", onFinish); + target.on("preload-error", onPreloadError); + target.on("render-process-gone", onRendererGone); + target.on("destroyed", onDestroyed); + if (this.closed || contents.isDestroyed()) settleReject(new BrowserSurfaceReadyError("invalid_state", "Browser surface is unavailable")); + else { + const currentFailure = this.contentReadyFailure; + if (currentFailure?.contents === contents) settleReject(currentFailure.error); + else if (this.contentReadyContents === contents) { + let loadingMainFrame = true; + try { loadingMainFrame = contents.isLoadingMainFrame(); } catch { /* contents is closing */ } + if (!loadingMainFrame) settleResolve(); + } + } + }); + } + + + private async loadContents(url: string, contents: WebContents, generation: number): Promise { + if (!this.isCurrentContents(contents, generation)) return; + this.update({ url, lifecycle: "loading", loading: true, readyState: "loading", progress: 0 }); + this.contentReadyContents = null; + this.contentReadyFailure = null; + try { + await contents.loadURL(url); + } catch (error) { + if (this.isCurrentContents(contents, generation)) this.update({ lifecycle: "failed", loading: false, progress: 1 }); + throw error; + } + } + + async load(url: string): Promise { + if (this.closed) return; + if (!isSafeBrowserUrl(url, this.allowFileUrls)) throw new Error("Unsafe browser URL"); + const contents = this.view?.webContents; + if (!contents || contents.isDestroyed()) return; + await this.loadContents(url, contents, this.viewGeneration); + } + + async navigate(url: string, snapshotAfter = false): Promise { + await this.load(url); + return this.action(snapshotAfter); + } + + async reload(snapshotAfter = false): Promise { + const contents = this.view?.webContents; + this.contentReadyContents = null; + this.contentReadyFailure = null; + if (contents && !contents.isDestroyed()) contents.reload(); + return this.action(snapshotAfter); + } + + async goBack(snapshotAfter = false): Promise { + const contents = this.view?.webContents; + if (contents?.navigationHistory.canGoBack()) { + this.contentReadyContents = null; + this.contentReadyFailure = null; + contents.navigationHistory.goBack(); + } + return this.action(snapshotAfter); + } + + async goForward(snapshotAfter = false): Promise { + const contents = this.view?.webContents; + if (contents?.navigationHistory.canGoForward()) { + this.contentReadyContents = null; + this.contentReadyFailure = null; + contents.navigationHistory.goForward(); + } + return this.action(snapshotAfter); + } + + async stop(snapshotAfter = false): Promise { + const contents = this.view?.webContents; + if (contents && !contents.isDestroyed()) contents.stop(); + this.update({ loading: false, progress: 1 }); + return this.action(snapshotAfter); + } + + private async action(snapshotAfter: boolean): Promise { + return { surface: this.state, ...(snapshotAfter ? { postActionSnapshot: await this.snapshot() } : {}) }; + } + + setVisible(visible: boolean): BrowserSurfaceState { + if (visible) { + this.update({ visible: true }); + this.attach(); + } else { + this.detach(); + const contents = this.view?.webContents as (WebContents & { blur?: () => void }) | null; + try { contents?.blur?.(); } catch { /* closing */ } + this.update({ visible: false, focused: "none" }); + } + return this.state; + } + + setBounds(bounds: BrowserBounds): BrowserSurfaceState { + const next = safeBounds(bounds); + this.update({ bounds: next }); + if (this.stateValue.visible) { + try { this.view?.setBounds(copyBounds(next)); } catch { /* window is closing */ } + } + return this.state; + } + + setMuted(muted: boolean): BrowserSurfaceState { + const contents = this.view?.webContents; + if (contents && !contents.isDestroyed()) contents.setAudioMuted(muted); + this.update({ muted }); + return this.state; + } + + setFocused(focused: "address" | "webview" | "none"): BrowserSurfaceState { + if (focused === "webview") { + try { this.view?.webContents.focus(); } catch { /* closing */ } + } + this.update({ focused }); + return this.state; + } + + async title(): Promise<{ title: string; url: string }> { + const contents = this.view?.webContents; + return { title: contents && !contents.isDestroyed() ? contents.getTitle() : this.stateValue.title, url: contents && !contents.isDestroyed() ? contents.getURL() || this.stateValue.url : this.stateValue.url }; + } + + async snapshot(): Promise { + const contents = this.view?.webContents; + let title = this.stateValue.title; + let url = this.stateValue.url; + let readyState: BrowserReadyState = this.stateValue.readyState; + if (contents && !contents.isDestroyed()) { + title = contents.getTitle(); + url = contents.getURL() || url; + try { + const metadata = await contents.executeJavaScript("({title:document.title,url:document.URL,readyState:document.readyState})", true) as { title?: unknown; url?: unknown; readyState?: unknown }; + if (typeof metadata.title === "string") title = metadata.title; + if (typeof metadata.url === "string" && isSafeBrowserUrl(metadata.url, this.allowFileUrls)) url = metadata.url; + if (typeof metadata.readyState === "string") readyState = stateReadyState(metadata.readyState); + } catch { /* page may be gone */ } + } + return { surfaceId: this.surfaceId, handle: this.handle, url, title, readyState, viewport: copyBounds(this.stateValue.bounds), elements: [], capturedAt: Date.now() }; + } + + async evaluate(expression: string, args: readonly BrowserJsonValue[] = []): Promise { + const contents = this.view?.webContents; + if (!contents || contents.isDestroyed()) throw new Error("Surface is unavailable"); + const serialized = JSON.stringify(args); + const script = args.length > 0 ? `((${expression})).apply(null, ${serialized})` : `(${expression})`; + return await contents.executeJavaScript(script, true) as BrowserJsonValue; + } + + async replaceAfterCrash(expectedContents?: WebContents, expectedGeneration?: number): Promise { + if ( + this.closed + || this.replacing + || (expectedContents !== undefined && this.webContents !== expectedContents) + || (expectedGeneration !== undefined && this.viewGeneration !== expectedGeneration) + ) return undefined; + this.replacing = true; + try { + const url = isSafeBrowserUrl(this.stateValue.url, this.allowFileUrls) ? this.stateValue.url : EMPTY_URL; + const visible = this.stateValue.visible; + this.closeView(); + this.update({ lifecycle: "creating", loading: true, readyState: "loading", progress: 0, url }); + this.createView(); + this.applyZoom(); + if (!visible) this.detach(); + return this.automationAdapter(); + } finally { + this.replacing = false; + } + } + + setZoom(zoomFactor: number): BrowserSurfaceState { + this.setZoomFactor(zoomFactor); + return this.state; + } + + canDiscard(): boolean { + return !this.closed && !this.stateValue.visible && !this.stateValue.loading && this.stateValue.focused === "none"; + } + + discard(): boolean { + if (!this.canDiscard()) return false; + this.closeView(); + return true; + } + + async restore(url?: string): Promise { + if (this.closed) throw new Error("Surface is closed"); + if (url !== undefined && !isSafeBrowserUrl(url, this.allowFileUrls)) throw new Error("Unsafe browser URL"); + if (!this.view) this.createView(); + if (url !== undefined) await this.load(url); + } + + close(): BrowserSurfaceState { + if (this.closed) return this.state; + this.closed = true; + this.closeView(); + this.stateValue = { ...this.stateValue, visible: false, loading: false, lifecycle: "closed", updatedAt: Date.now() }; + this.emitState(); + return this.state; + } +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 2503f3b..5cc0b84 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -52,9 +52,17 @@ import { type SpeechResult, type TerminalResult, } from "@t4-code/protocol/desktop-ipc"; +import { + decodeBrowserCall, + decodeBrowserEvent, + decodeBrowserResult, + type BrowserCall, + type BrowserCallResult, + type BrowserEvent, +} from "@t4-code/protocol/browser-ipc"; import type { ServiceManager } from "@t4-code/service-manager"; import { redactedMessage } from "@t4-code/client"; -import { trustedSender, type TrustedRenderer } from "./security.ts"; +import { isTrustedNavigation, trustedSender, type TrustedRenderer } from "./security.ts"; import type { DesktopSpeechService } from "./speech.ts"; import type { LocalTargetManager } from "./target-manager.ts"; import type { LocalProfileRuntime } from "./profile-runtime.ts"; @@ -62,6 +70,9 @@ export interface IpcRuntime { readonly manager: LocalTargetManager; readonly window: BrowserWindow; readonly trustedRenderer: TrustedRenderer; + readonly browser?: { + readonly call: (request: BrowserCall) => Promise; + }; /** Static manager support is retained for narrow unit runtimes. */ readonly serviceManager?: ServiceManager; /** Dynamic lifecycle access keeps IPC valid after rediscovery or window reopen. */ @@ -109,12 +120,40 @@ function decodeRequest(channel: DesktopInvokeChannel, value: unknown): DesktopIn if (request.channel !== channel) throw new Error("channel mismatch"); return request; } +function decodeBrowserCallEnvelope(value: unknown): BrowserCall { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) throw new Error("invalid browser call envelope"); + const input = value as Record; + const keys = Object.keys(input); + if (keys.length !== 2 || !keys.includes("channel") || !keys.includes("payload")) { + throw new Error("invalid browser call envelope"); + } + if (input.channel !== "browser:call") throw new Error("channel mismatch"); + return decodeBrowserCall(input.payload); +} export interface IpcMainLike { handle(channel: string, listener: (event: IpcMainInvokeEvent, payload: unknown) => unknown): void; removeHandler(channel: string): void; } +type BrowserCallTarget = NonNullable; + +export class BrowserTargetUnavailableError extends Error { + readonly code = "invalid_state" as const; + + constructor() { + super("Browser runtime is unavailable"); + this.name = "BrowserTargetUnavailableError"; + Object.defineProperty(this, "stack", { value: undefined, enumerable: false, configurable: true }); + } +} + export class DesktopIpcRegistry { private installed = false; + private browserTarget: BrowserCallTarget | undefined; private readonly runtime: IpcRuntime; private readonly serviceQueue = { tail: Promise.resolve() }; private serviceInspectionPromise: Promise | undefined; @@ -122,12 +161,29 @@ export class DesktopIpcRegistry { private readonly ipc: IpcMainLike; constructor(runtime: IpcRuntime, ipc: IpcMainLike = ipcMain) { this.runtime = runtime; + this.browserTarget = runtime.browser; this.ipc = ipc; } + updateBrowserTarget(target: BrowserCallTarget): void { + this.browserTarget = target; + } + + deactivateBrowserTarget(target?: BrowserCallTarget): void { + if (target === undefined || this.browserTarget === target) this.browserTarget = undefined; + } + install(): void { - this.uninstall(); + if (this.installed) return; this.installed = true; + this.ipc.handle("browser:call", async (event, payload: unknown): Promise => { + this.assertSender(event); + const call = decodeBrowserCallEnvelope(payload); + const target = this.browserTarget; + if (target === undefined) throw new BrowserTargetUnavailableError(); + const result = await target.call(call); + return decodeBrowserResult(call.method, result) as BrowserCallResult; + }); this.ipc.handle("omp:bootstrap", async (event, payload: unknown): Promise => { this.assertSender(event); decodeRequest("omp:bootstrap", payload); @@ -318,9 +374,11 @@ export class DesktopIpcRegistry { return this.runtime.phoneSetup; } uninstall(): void { + if (!this.installed) return; this.updateUnsubscribe?.(); this.updateUnsubscribe = undefined; for (const channel of [ + "browser:call", "omp:bootstrap", "omp:connect", "omp:disconnect", "omp:command", "omp:confirm", "omp:terminal:input", "omp:terminal:resize", "omp:terminal:close", "omp:pair", "omp:speech:speak", "omp:speech:stop", @@ -351,6 +409,14 @@ export class DesktopIpcRegistry { emitOpenUpdateSettings(): void { this.emit("app:update:open", { source: "menu" }); } + emitBrowserEvent(event: BrowserEvent): void { + const decoded = decodeBrowserEvent(event); + const window = this.runtime.window; + if (window.isDestroyed() || window.webContents.isDestroyed()) return; + const mainFrame = window.webContents.mainFrame; + if (mainFrame === null || mainFrame === undefined || !isTrustedNavigation(mainFrame.url, this.runtime.trustedRenderer)) return; + window.webContents.send("browser:event", decoded); + } private assertSender(event: IpcMainInvokeEvent): void { if (!validEvent(event, this.runtime)) throw new Error("untrusted desktop sender"); diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index 15c1818..c59e2cd 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -8,6 +8,7 @@ import type { ServiceManager } from "@t4-code/service-manager"; import type { RemoteTargetRegistry } from "./remote-runtime/registry.ts"; import { createDesktopWindow, type DesktopWindowHandle } from "./window.ts"; import { DesktopIpcRegistry, runtimeError, type IpcRuntime } from "./ipc.ts"; +import { BrowserRuntime, type BrowserRuntimeOptions } from "./browser-runtime.ts"; import { ElectronCursorStore, ElectronRemoteTargetStore, ElectronCredentialCiphertextStore, ElectronLocalProfileStore, ElectronProjectionCacheStore, electronSafeStorage, loadDeviceIdentity, type DeviceIdentity } from "./stores.ts"; import { VersionedRemoteTargetRegistry, DeviceCredentialStore } from "./remote-runtime/index.ts"; import { LocalTargetManager, type TargetManagerOptions } from "./target-manager.ts"; @@ -46,6 +47,7 @@ export interface DesktopLifecycleOptions { readonly app?: typeof app; readonly getAllWindows?: () => readonly BrowserWindow[]; readonly createWindow?: () => DesktopWindowHandle; + readonly createBrowserRuntime?: (options: BrowserRuntimeOptions) => BrowserRuntime; readonly createIpcRegistry?: (runtime: IpcRuntime) => DesktopIpcRegistry; readonly loadIdentity?: () => DeviceIdentity; readonly createCursorStore?: () => CursorStore; @@ -75,6 +77,7 @@ export class DesktopLifecycle { private readonly allWindows: () => readonly BrowserWindow[]; private readonly windowFactory: () => DesktopWindowHandle; private readonly ipcFactory: (runtime: IpcRuntime) => DesktopIpcRegistry; + private readonly browserRuntimeFactory: (options: BrowserRuntimeOptions) => BrowserRuntime; private readonly identityFactory: () => DeviceIdentity; private readonly cursorStoreFactory: () => CursorStore; private readonly remoteRegistryFactory: () => RemoteTargetRegistry; @@ -89,6 +92,7 @@ export class DesktopLifecycle { private readonly updateControllerFactory: () => DesktopUpdateController; private readonly menuInstaller: (options: ApplicationMenuOptions) => void; private mainWindow: BrowserWindow | undefined; + private browserRuntime: BrowserRuntime | undefined; private ipc: DesktopIpcRegistry | undefined; private manager: LocalTargetManager | undefined; private localProfileRegistry: LocalProfileRegistry | undefined; @@ -116,6 +120,7 @@ export class DesktopLifecycle { constructor(options: DesktopLifecycleOptions = {}) { this.electronApp = options.app ?? app; + this.browserRuntimeFactory = options.createBrowserRuntime ?? ((runtimeOptions) => new BrowserRuntime(runtimeOptions)); this.allWindows = options.getAllWindows ?? (() => BrowserWindow.getAllWindows()); this.windowFactory = options.createWindow ?? createDesktopWindow; this.ipcFactory = options.createIpcRegistry ?? ((runtime) => new DesktopIpcRegistry(runtime)); @@ -245,10 +250,13 @@ export class DesktopLifecycle { } private async stopInternal(): Promise { this.stopping = true; + const browser = this.browserRuntime; + const ipc = this.ipc; + this.browserRuntime = undefined; + ipc?.deactivateBrowserTarget(browser); + await this.disposeBrowserRuntime(browser); await this.speechService?.dispose(); this.speechService = undefined; - this.ipc?.uninstall(); - this.ipc = undefined; this.mainWindow = undefined; this.updateController?.dispose(); this.updateController = undefined; @@ -265,6 +273,10 @@ export class DesktopLifecycle { ]); if (this.beforeQuitHandler !== undefined) this.electronApp.removeListener("before-quit", this.beforeQuitHandler); this.beforeQuitHandler = undefined; + if (this.ipc === ipc) { + ipc?.uninstall(); + this.ipc = undefined; + } } private async ensureServiceReady(manager: ServiceManager, executable: string): Promise { this.assertServiceRecoveryActive(); @@ -433,13 +445,86 @@ export class DesktopLifecycle { this.serviceRecoveryPromises.delete(profile); this.serviceAvailabilityIssues.delete(profile); } - private bindWindow(handle: DesktopWindowHandle): void { this.rendererLoaded = false; this.updateRendererReady = false; const manager = this.manager; if (manager === undefined) return; this.mainWindow = handle.window; + this.installIpc(handle, manager); + this.replaceBrowserRuntime(handle); + handle.window.webContents.on("did-start-loading", () => { + if (this.stopping || this.mainWindow !== handle.window || handle.window.isDestroyed()) return; + this.rendererLoaded = false; + this.updateRendererReady = false; + this.replaceBrowserRuntime(handle); + }); + handle.window.webContents.on("did-finish-load", () => { + if (this.stopping || this.mainWindow !== handle.window || handle.window.isDestroyed()) return; + if (this.browserRuntime === undefined) this.replaceBrowserRuntime(handle); + this.rendererLoaded = true; + if (this.startupServiceError !== undefined) { + this.ipc?.emitRuntimeError(runtimeError(this.startupServiceError, "local")); + this.startupServiceError = undefined; + } + const links = this.pendingPairs.drain(); + for (const link of links) this.ipc?.emitPairLink(link); + this.updateController?.schedulePassiveCheck(); + }); + handle.window.on("closed", () => { + if (this.mainWindow !== handle.window) return; + const browser = this.browserRuntime; + const ipc = this.ipc; + this.mainWindow = undefined; + this.rendererLoaded = false; + this.updateRendererReady = false; + this.browserRuntime = undefined; + ipc?.deactivateBrowserTarget(browser); + void this.disposeBrowserRuntime(browser).then(() => { + if (this.stopping || this.mainWindow !== undefined || this.ipc !== ipc) return; + try { + ipc?.uninstall(); + } catch { + // Teardown must not turn a browser failure into a desktop failure. + } + if (this.ipc === ipc) this.ipc = undefined; + }); + }); + } + + private replaceBrowserRuntime(handle: DesktopWindowHandle): BrowserRuntime | undefined { + const previous = this.browserRuntime; + let runtime: BrowserRuntime | undefined; + let created: BrowserRuntime; + try { + created = this.browserRuntimeFactory({ + window: handle.window, + userDataPath: this.electronApp.getPath("userData"), + emit: (event) => { + const staleRuntime = + runtime === undefined || + this.browserRuntime !== runtime || + this.mainWindow !== handle.window || + handle.window.isDestroyed(); + if (staleRuntime) return; + try { + this.ipc?.emitBrowserEvent(event); + } catch { + // Child WebContents events are diagnostic and must remain nonfatal. + } + }, + }); + } catch { + return previous; + } + runtime = created; + this.browserRuntime = created; + this.ipc?.updateBrowserTarget(created); + void this.disposeBrowserRuntime(previous); + return created; + } + + private installIpc(handle: DesktopWindowHandle, manager: LocalTargetManager): void { this.ipc?.uninstall(); this.ipc = this.ipcFactory({ manager, @@ -457,28 +542,15 @@ export class DesktopLifecycle { ...(this.phoneSetup === undefined ? {} : { phoneSetup: this.phoneSetup }), }); this.ipc.install(); - handle.window.webContents.on("did-start-loading", () => { - this.updateRendererReady = false; - }); - handle.window.webContents.once("did-finish-load", () => { - this.rendererLoaded = true; - if (this.startupServiceError !== undefined) { - this.ipc?.emitRuntimeError(runtimeError(this.startupServiceError, "local")); - this.startupServiceError = undefined; - } - const links = this.pendingPairs.drain(); - for (const link of links) this.ipc?.emitPairLink(link); - this.updateController?.schedulePassiveCheck(); - }); - handle.window.on("closed", () => { - if (this.mainWindow === handle.window) { - this.mainWindow = undefined; - this.rendererLoaded = false; - this.updateRendererReady = false; - this.ipc?.uninstall(); - this.ipc = undefined; - } - }); + } + + private async disposeBrowserRuntime(runtime: BrowserRuntime | undefined): Promise { + if (runtime === undefined) return; + try { + await runtime.dispose(); + } catch { + // Browser surfaces are best-effort during renderer and window teardown. + } } private openUpdatesFromMenu(): void { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 40b4138..da65cf7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,16 +1,132 @@ import { app } from "electron"; import { DesktopLifecycle } from "./lifecycle.ts"; -const lifecycle = new DesktopLifecycle(); -void lifecycle.start(); +const MAX_RUNTIME_REJECTION_REPORTS = 10; +const MAX_FAILURE_MESSAGE_LENGTH = 1_024; -app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit(); -}); +type DesktopApp = { + on(event: "window-all-closed", listener: () => void): unknown; + removeListener(event: "window-all-closed", listener: () => void): unknown; + quit(): void; +}; -process.on("uncaughtException", () => { - app.quit(); -}); -process.on("unhandledRejection", () => { - app.quit(); +type MainProcess = { + platform: string; + on(event: "uncaughtException" | "unhandledRejection", listener: (reason: unknown) => void): unknown; + removeListener(event: "uncaughtException" | "unhandledRejection", listener: (reason: unknown) => void): unknown; +}; + +type Lifecycle = { + start(): Promise; +}; + +export interface MainRuntimeOptions { + readonly app: DesktopApp; + readonly lifecycle: Lifecycle; + readonly process: MainProcess; + readonly report?: (message: string) => void; + readonly isRecoverableException?: (error: unknown) => boolean; +} + +interface InstalledProcessPolicy { + readonly uncaughtException: (reason: unknown) => void; + readonly unhandledRejection: (reason: unknown) => void; +} + +const installedProcessPolicies = new WeakMap(); +const windowCloseHandlers = new WeakMap void>(); +const applicationShutdowns = new WeakMap(); + +function failureMessage(reason: unknown): string { + try { + let value: string; + if (reason instanceof Error) value = `${reason.name}: ${reason.message}`; + else if (typeof reason === "string") value = reason; + else value = JSON.stringify(reason) ?? String(reason); + return value.slice(0, MAX_FAILURE_MESSAGE_LENGTH); + } catch { + return "unprintable failure"; + } +} + +function quitOnce(electronApp: DesktopApp): void { + let shutdown = applicationShutdowns.get(electronApp); + if (shutdown === undefined) { + shutdown = { quitting: false }; + applicationShutdowns.set(electronApp, shutdown); + } + if (shutdown.quitting) return; + shutdown.quitting = true; + electronApp.quit(); +} + +function isRecoverable( + error: unknown, + classifier: ((error: unknown) => boolean) | undefined, +): boolean { + try { + return classifier?.(error) === true; + } catch { + return false; + } +} + +function installWindowCloseHandler(electronApp: DesktopApp, platform: string): void { + const previous = windowCloseHandlers.get(electronApp); + if (previous !== undefined) electronApp.removeListener("window-all-closed", previous); + const handler = (): void => { + if (platform !== "darwin") quitOnce(electronApp); + }; + electronApp.on("window-all-closed", handler); + windowCloseHandlers.set(electronApp, handler); +} + +export function bootstrapDesktopMain(options: MainRuntimeOptions): Promise { + const report = options.report ?? console.error; + installWindowCloseHandler(options.app, options.process.platform); + + const previous = installedProcessPolicies.get(options.process); + if (previous !== undefined) { + options.process.removeListener("uncaughtException", previous.uncaughtException); + options.process.removeListener("unhandledRejection", previous.unhandledRejection); + } + + let rejectionReports = 0; + const uncaughtException = (error: unknown): void => { + if (isRecoverable(error, options.isRecoverableException)) { + report(`[desktop] recoverable main exception: ${failureMessage(error)}`); + return; + } + report(`[desktop] fatal main exception: ${failureMessage(error)}`); + quitOnce(options.app); + }; + const unhandledRejection = (reason: unknown): void => { + if (rejectionReports >= MAX_RUNTIME_REJECTION_REPORTS) return; + rejectionReports += 1; + const suffix = rejectionReports === MAX_RUNTIME_REJECTION_REPORTS + ? " (further runtime rejections suppressed)" + : ""; + report(`[desktop] runtime rejection: ${failureMessage(reason)}${suffix}`); + }; + options.process.on("uncaughtException", uncaughtException); + options.process.on("unhandledRejection", unhandledRejection); + installedProcessPolicies.set(options.process, { uncaughtException, unhandledRejection }); + + try { + return Promise.resolve(options.lifecycle.start()).catch((error: unknown) => { + report(`[desktop] fatal startup failure: ${failureMessage(error)}`); + quitOnce(options.app); + }); + } catch (error) { + report(`[desktop] fatal startup failure: ${failureMessage(error)}`); + quitOnce(options.app); + return Promise.resolve(); + } +} + +const lifecycle = new DesktopLifecycle(); +void bootstrapDesktopMain({ + app: app as unknown as DesktopApp, + lifecycle, + process: process as unknown as MainProcess, }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 02fc0f0..e8636f3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -47,6 +47,8 @@ import { type ProjectionCacheSaveRequest, type ProjectionCacheSaveResult, } from "@t4-code/protocol/desktop-ipc"; +import { BROWSER_CHANNELS, decodeBrowserCall, decodeBrowserEvent, decodeBrowserResult, type BrowserCall, type BrowserCallResult, type BrowserEvent } from "@t4-code/protocol/browser-ipc"; +import type { BrowserShellPort } from "@t4-code/client"; export interface OmpShellBridge { readonly kind: "desktop"; @@ -139,6 +141,31 @@ function subscribe ipcRenderer.removeListener(channel, wrapped); } +function createBrowserBridge(): BrowserShellPort { + return { + kind: "desktop-browser", + call: async (request: BrowserCall): Promise => { + const call = decodeBrowserCall(request); + const result = await ipcRenderer.invoke(BROWSER_CHANNELS[0], { + channel: BROWSER_CHANNELS[0], + payload: call, + }); + return decodeBrowserResult(call.method, result) as BrowserCallResult; + }, + subscribe: (listener: (event: BrowserEvent) => void): (() => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, value: unknown) => { + try { + listener(decodeBrowserEvent(value)); + } catch { + // Invalid browser events are dropped at the preload boundary. + } + }; + ipcRenderer.on(BROWSER_CHANNELS[1], wrapped); + return () => ipcRenderer.removeListener(BROWSER_CHANNELS[1], wrapped); + }, + }; +} + const bridge: OmpShellBridge = { kind: "desktop", platform: process.platform === "darwin" ? "darwin" : "linux", @@ -193,3 +220,4 @@ const bridge: OmpShellBridge = { }; contextBridge.exposeInMainWorld("ompShell", bridge); +contextBridge.exposeInMainWorld("t4Browser", createBrowserBridge()); diff --git a/apps/desktop/test/browser-automation.test.ts b/apps/desktop/test/browser-automation.test.ts new file mode 100644 index 0000000..1001926 --- /dev/null +++ b/apps/desktop/test/browser-automation.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import type { IpcMain } from "electron"; + +function ipcMainMock(): Pick { + const ipcMain = { + on: () => ipcMain as unknown as IpcMain, + removeListener: () => ipcMain as unknown as IpcMain, + }; + return ipcMain; +} + +function field(value: unknown, name: string): unknown { + expect(value !== null && typeof value === "object" && name in value).toBe(true); + if (value === null || typeof value !== "object" || !(name in value)) return undefined; + return (value as Record)[name]; +} + +type VitestMockApi = { + readonly vi: { + mock(moduleName: string, factory: () => unknown): void; + useFakeTimers(): void; + useRealTimers(): void; + advanceTimersByTimeAsync(milliseconds: number): Promise; + }; +}; + +// Electron must be mocked before loading the coordinator because native bindings cannot load in Vitest. +const vitest = await import("vitest") as unknown as VitestMockApi; +vitest.vi.mock("electron", () => ({ ipcMain: ipcMainMock() })); + +// This follows the Electron mock above. +const { BrowserAutomationCoordinator } = await import("../src/browser-automation.ts"); + +type EvaluationResult = { readonly ok: boolean; readonly value?: unknown; readonly error?: string }; + +class FakeWebContents { + readonly scripts: string[] = []; + sent = 0; + destroyed = false; + handler: (script: string) => Promise = async () => ({ ok: true, value: null }); + + isDestroyed(): boolean { return this.destroyed; } + send(): void { this.sent += 1; } + executeJavaScript(script: string): Promise { + this.scripts.push(script); + return this.handler(script); + } +} + +function harness(contents = new FakeWebContents()): { readonly contents: FakeWebContents; readonly coordinator: InstanceType; readonly surface: { surfaceId: string; webContents: FakeWebContents; browserSession: object; waitForContentReady: (timeoutMs: number) => Promise } } { + const surface = { + surfaceId: "surface-1", + webContents: contents, + browserSession: {}, + waitForContentReady: async (_timeoutMs: number) => {}, + }; + const coordinator = new BrowserAutomationCoordinator({ + ipcMain: ipcMainMock(), + resolveSurface: () => surface as never, + }); + return { contents, coordinator, surface }; +} + +function call(coordinator: InstanceType, method: "browser.eval" | "browser.wait", request: Record): Promise { + return coordinator.call({ method, request } as never); +} + +describe("BrowserAutomationCoordinator native evaluation", () => { + it("evaluates document.title through the live WebContents despite page CSP", async () => { + const { contents, coordinator } = harness(); + contents.handler = async (script) => { + expect(script).toContain("document.title"); + expect(script).not.toContain("new Function"); + return { ok: true, value: "Example Domain" }; + }; + + const result = await call(coordinator, "browser.eval", { expression: "document.title" }); + expect(field(result, "value")).toBe("Example Domain"); + expect(contents.sent).toBe(0); + }); + + it("bounds cyclic and oversized evaluation results before returning them", async () => { + const { contents, coordinator } = harness(); + contents.handler = async () => ({ ok: true, value: { cycle: "[unavailable]", entries: Array.from({ length: 600 }, () => "value") } }); + + const result = await call(coordinator, "browser.eval", { expression: "window.cyclic" }); + const value = field(result, "value"); + expect(field(value, "cycle")).toBe("[unavailable]"); + const entries = field(value, "entries"); + expect(Array.isArray(entries)).toBe(true); + if (!Array.isArray(entries)) throw new Error("Expected bounded entries"); + expect(entries.length).toBe(256); + expect(entries.every((entry) => entry === "value")).toBe(true); + expect(contents.scripts[0]).toContain('seen.has(value)'); + }); + + it("rejects denied Node and Electron expressions without executing page code", async () => { + const { contents, coordinator } = harness(); + + let failure: unknown; + try { + await call(coordinator, "browser.eval", { expression: 'require("node:fs")' }); + } catch (error) { + failure = error; + } + expect(field(failure, "code")).toBe("security"); + expect(field(failure, "message")).toBe("Node and Electron objects are unavailable"); + expect(contents.scripts).toEqual([]); + }); + + it("fails timed-out and stale WebContents evaluations", async () => { + vitest.vi.useFakeTimers(); + try { + const timeout = harness(); + timeout.contents.handler = () => new Promise(() => {}); + const pending = call(timeout.coordinator, "browser.eval", { expression: "document.title", timeoutMs: 20 }); + const failure = (async () => { + try { + await pending; + } catch (error) { + return error; + } + throw new Error("Expected evaluation to time out"); + })(); + await vitest.vi.advanceTimersByTimeAsync(20); + expect(field(await failure, "code")).toBe("timeout"); + } finally { + vitest.vi.useRealTimers(); + } + + const stale = harness(); + const evaluationStarted = Promise.withResolvers(); + const evaluationResult = Promise.withResolvers(); + stale.contents.handler = () => { + evaluationStarted.resolve(); + return evaluationResult.promise; + }; + const pending = call(stale.coordinator, "browser.eval", { expression: "document.title", timeoutMs: 1_000 }); + const failure = (async () => { + try { + await pending; + } catch (error) { + return error; + } + throw new Error("Expected stale evaluation to fail"); + })(); + await evaluationStarted.promise; + stale.surface.webContents = new FakeWebContents(); + evaluationResult.resolve({ ok: true, value: "Example Domain" }); + expect(field(await failure, "code")).toBe("invalid_state"); + }); + + it("uses the same native evaluator for function waits", async () => { + const { contents, coordinator } = harness(); + contents.handler = async (script) => { + expect(script).toContain("document.readyState === \"complete\""); + return { ok: true, value: true }; + }; + + const result = await call(coordinator, "browser.wait", { kind: "function", value: 'document.readyState === "complete"' }); + expect(field(result, "matched")).toBe(true); + expect(contents.sent).toBe(0); + }); +}); diff --git a/apps/desktop/test/browser-capture.test.ts b/apps/desktop/test/browser-capture.test.ts new file mode 100644 index 0000000..ed3cb3c --- /dev/null +++ b/apps/desktop/test/browser-capture.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; + +type VitestMockApi = { + readonly vi: { + mock(module: string, factory: () => unknown): void; + }; +}; + +type CaptureRect = { readonly x: number; readonly y: number; readonly width: number; readonly height: number }; + +interface CaptureImage { + toPNG(): Uint8Array; + getSize(): { readonly width: number; readonly height: number }; +} + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; +} + +// Electron must be mocked before loading the coordinator because native bindings cannot load in Vitest. +const vitest = await import("vitest") as unknown as VitestMockApi; +vitest.vi.mock("electron", () => ({ + contentTracing: { + startRecording: async () => undefined, + stopRecording: async () => "", + }, +})); + +// This follows the Electron mock above. +const { BrowserCaptureCoordinator } = await import("../src/browser-capture.ts"); + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { resolve = next; }); + return { promise, resolve }; +} + +function image(bytes: number[], width = 1_280, height = 720): CaptureImage { + return { + toPNG: () => Uint8Array.from(bytes), + getSize: () => ({ width, height }), + }; +} + +class FakeWebContents { + readonly captureCalls: (CaptureRect | undefined)[] = []; + readonly emulatedViewports: { readonly width: number; readonly height: number }[] = []; + readonly zoomFactors: number[] = []; + private readonly capture: (rect: CaptureRect | undefined) => Promise; + + constructor(capture: (rect: CaptureRect | undefined) => Promise) { + this.capture = capture; + } + + capturePage(rect?: CaptureRect): Promise { + this.captureCalls.push(rect); + return this.capture(rect); + } + + enableDeviceEmulation(parameters: { readonly viewSize: { readonly width: number; readonly height: number } }): void { + this.emulatedViewports.push(parameters.viewSize); + } + + setZoomFactor(factor: number): void { + this.zoomFactors.push(factor); + } +} + +describe("BrowserCaptureCoordinator surface identity", () => { + it("retains a surface viewport across fresh adapter wrappers", async () => { + const contents = new FakeWebContents(async () => image([1], 640, 480)); + const coordinator = new BrowserCaptureCoordinator(); + const firstAdapter = { surfaceId: "surface-1", webContents: contents }; + const secondAdapter = { surfaceId: "surface-1", webContents: contents }; + + await coordinator.call("browser.viewport.set", { width: 640, height: 480 }, firstAdapter); + await coordinator.call("browser.zoom.set", { zoom: 1.5 }, firstAdapter); + await coordinator.call("surface.screenshot", {}, secondAdapter); + + expect(contents.emulatedViewports).toEqual([{ width: 640, height: 480 }]); + expect(contents.zoomFactors).toEqual([1.5]); + expect(contents.captureCalls).toEqual([{ x: 0, y: 0, width: 640, height: 480 }]); + }); + + it("coalesces identical captures only for the same surface", async () => { + const response = deferred(); + const contents = new FakeWebContents(() => response.promise); + const coordinator = new BrowserCaptureCoordinator(); + const firstAdapter = { surfaceId: "surface-1", webContents: contents }; + const secondAdapter = { surfaceId: "surface-1", webContents: contents }; + + const first = coordinator.call("surface.screenshot", { crop: { x: 0, y: 0, width: 10, height: 10 } }, firstAdapter); + const second = coordinator.call("surface.screenshot", { crop: { width: 10, height: 10, y: 0, x: 0 } }, secondAdapter); + + expect(contents.captureCalls).toEqual([{ x: 0, y: 0, width: 10, height: 10 }]); + response.resolve(image([1], 10, 10)); + expect(await Promise.all([first, second])).toEqual([ + { supported: true, mimeType: "image/png", width: 10, height: 10, data: "AQ==" }, + { supported: true, mimeType: "image/png", width: 10, height: 10, data: "AQ==" }, + ]); + }); + + it("does not coalesce different capture options for one surface", async () => { + const firstResponse = deferred(); + const secondResponse = deferred(); + const contents = new FakeWebContents((rect) => rect?.width === 10 ? firstResponse.promise : secondResponse.promise); + const coordinator = new BrowserCaptureCoordinator(); + const surface = { surfaceId: "surface-1", webContents: contents }; + + const first = coordinator.call("surface.screenshot", { crop: { x: 0, y: 0, width: 10, height: 10 } }, surface); + const second = coordinator.call("surface.screenshot", { crop: { x: 0, y: 0, width: 20, height: 10 } }, surface); + + expect(contents.captureCalls).toEqual([ + { x: 0, y: 0, width: 10, height: 10 }, + { x: 0, y: 0, width: 20, height: 10 }, + ]); + firstResponse.resolve(image([1], 10, 10)); + secondResponse.resolve(image([2], 20, 10)); + expect(await Promise.all([first, second])).toEqual([ + { supported: true, mimeType: "image/png", width: 10, height: 10, data: "AQ==" }, + { supported: true, mimeType: "image/png", width: 20, height: 10, data: "Ag==" }, + ]); + }); + + it("keeps concurrent captures isolated between surfaces", async () => { + const firstResponse = deferred(); + const secondResponse = deferred(); + const firstContents = new FakeWebContents(() => firstResponse.promise); + const secondContents = new FakeWebContents(() => secondResponse.promise); + const coordinator = new BrowserCaptureCoordinator(); + + const first = coordinator.call("surface.screenshot", {}, { surfaceId: "surface-1", webContents: firstContents }); + const second = coordinator.call("surface.screenshot", {}, { surfaceId: "surface-2", webContents: secondContents }); + + expect(firstContents.captureCalls).toEqual([{ x: 0, y: 0, width: 1_280, height: 720 }]); + expect(secondContents.captureCalls).toEqual([{ x: 0, y: 0, width: 1_280, height: 720 }]); + firstResponse.resolve(image([1])); + secondResponse.resolve(image([2])); + expect(await Promise.all([first, second])).toEqual([ + { supported: true, mimeType: "image/png", width: 1_280, height: 720, data: "AQ==" }, + { supported: true, mimeType: "image/png", width: 1_280, height: 720, data: "Ag==" }, + ]); + }); +}); diff --git a/apps/desktop/test/browser-downloads.test.ts b/apps/desktop/test/browser-downloads.test.ts new file mode 100644 index 0000000..4bf1e62 --- /dev/null +++ b/apps/desktop/test/browser-downloads.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { Session, WebContents } from "electron"; +import type { BrowserEvent, SurfaceId } from "@t4-code/protocol/browser-ipc"; + +type VitestMockApi = { + readonly vi: { + mock(moduleName: string, factory: () => unknown): void; + }; +}; + +const vitest = await import("vitest") as unknown as VitestMockApi; + +vitest.vi.mock("electron", () => ({ + app: { getPath: () => "/tmp" }, +})); + +// The controller must load after Electron is mocked; static import would load the native binding first. +const { BrowserDownloadController } = await import("../src/browser-downloads.ts"); + +type DownloadListener = (event: FakeDownloadEvent, item: FakeDownloadItem, contents: FakeWebContents) => void; + +class FakeDownloadEvent { + public defaultPrevented = false; + + public preventDefault(): void { + this.defaultPrevented = true; + } +} + +class FakeWebContents {} + +class FakeSession { + private readonly downloadListeners = new Set(); + + public on(event: string, listener: DownloadListener): this { + if (event === "will-download") this.downloadListeners.add(listener); + return this; + } + + public removeListener(event: string, listener: DownloadListener): this { + if (event === "will-download") this.downloadListeners.delete(listener); + return this; + } + + public emitWillDownload(item: FakeDownloadItem, contents: FakeWebContents): FakeDownloadEvent { + const event = new FakeDownloadEvent(); + for (const listener of this.downloadListeners) listener(event, item, contents); + return event; + } + + public listenerCount(): number { + return this.downloadListeners.size; + } +} + +class FakeDownloadItem { + public savePath: string | undefined; + private readonly url: string; + private readonly filename: string; + + public constructor(url: string, filename: string) { + this.url = url; + this.filename = filename; + } + + public getURL(): string { + return this.url; + } + + public getSuggestedFilename(): string { + return this.filename; + } + + public setSavePath(path: string): void { + this.savePath = path; + } + + public on(): this { + return this; + } + + public once(): this { + return this; + } + + public cancel(): void {} +} + +describe("BrowserDownloadController session routing", () => { + it("uses one listener per session, attributes contents, denies unknown contents, and cleans up", async () => { + const downloadsPath = await mkdtemp(join(tmpdir(), "t4-browser-downloads-")); + const emitted: BrowserEvent[] = []; + const controller = new BrowserDownloadController({ + emit: (event) => { emitted.push(event); }, + downloadsPath, + }); + const firstSession = new FakeSession(); + const secondSession = new FakeSession(); + const firstContents = new FakeWebContents(); + const secondContents = new FakeWebContents(); + const thirdContents = new FakeWebContents(); + const unknownContents = new FakeWebContents(); + + try { + controller.attach(firstContents as unknown as WebContents, "surface:first" as SurfaceId, firstSession as unknown as Session); + controller.attach(secondContents as unknown as WebContents, "surface:second" as SurfaceId, firstSession as unknown as Session); + controller.attach(thirdContents as unknown as WebContents, "surface:third" as SurfaceId, secondSession as unknown as Session); + + expect(firstSession.listenerCount()).toBe(1); + expect(secondSession.listenerCount()).toBe(1); + + const rejected = firstSession.emitWillDownload(new FakeDownloadItem("https://example.test/unknown", "unknown.txt"), unknownContents); + expect(rejected.defaultPrevented).toBe(true); + expect(emitted).toEqual([]); + + const first = firstSession.emitWillDownload(new FakeDownloadItem("https://example.test/first", "first.txt"), firstContents); + const second = firstSession.emitWillDownload(new FakeDownloadItem("https://example.test/second", "second.txt"), secondContents); + const third = secondSession.emitWillDownload(new FakeDownloadItem("https://example.test/third", "third.txt"), thirdContents); + expect(first.defaultPrevented).toBe(true); + expect(second.defaultPrevented).toBe(true); + expect(third.defaultPrevented).toBe(true); + expect(emitted.map((event) => event.type === "download" ? event.download.surfaceId : undefined)).toEqual([ + "surface:first", + "surface:second", + "surface:third", + ]); + + await Promise.resolve(); + await Promise.resolve(); + await controller.dispose(); + expect(firstSession.listenerCount()).toBe(0); + expect(secondSession.listenerCount()).toBe(0); + } finally { + await rm(downloadsPath, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/test/browser-network.test.ts b/apps/desktop/test/browser-network.test.ts new file mode 100644 index 0000000..347cd67 --- /dev/null +++ b/apps/desktop/test/browser-network.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { BrowserNetworkController } from "../src/browser-network.ts"; + +type BeforeRequestDetails = { + readonly id: number; + readonly url: string; + readonly method: string; + readonly resourceType: string; + readonly webContentsId: number; +}; + +type BeforeRequestListener = ( + details: BeforeRequestDetails, + callback: (response: { readonly cancel?: boolean; readonly redirectURL?: string }) => void, +) => void; +type HeaderListener = ( + details: { readonly webContentsId: number; readonly requestHeaders: Record }, + callback: (response: { readonly requestHeaders: Record }) => void, +) => void; +type CompletedListener = (details: BeforeRequestDetails & { readonly statusCode: number }) => void; + +class FakeWebRequest { + beforeRequest: BeforeRequestListener | null = null; + beforeSendHeaders: HeaderListener | null = null; + completed: CompletedListener | null = null; + + onBeforeRequest(_filter: unknown, listener: BeforeRequestListener | null): void { + // Electron keeps only the last listener for a WebRequest event. + this.beforeRequest = listener; + } + + onBeforeSendHeaders(_filter: unknown, listener: HeaderListener | null): void { this.beforeSendHeaders = listener; } + onCompleted(_filter: unknown, listener: CompletedListener | null): void { this.completed = listener; } + onErrorOccurred(_filter: unknown, _listener: unknown): void {} +} + +function headers( + webRequest: FakeWebRequest, + webContentsId: number, +): Record { + let result: Record = { Accept: "text/html" }; + webRequest.beforeSendHeaders?.( + { webContentsId, requestHeaders: result }, + (response) => { result = response.requestHeaders; }, + ); + return result; +} + +class FakeSession { + readonly webRequest = new FakeWebRequest(); + networkEmulationCalls = 0; + + enableNetworkEmulation(): void { + this.networkEmulationCalls += 1; + } +} + +function request( + webRequest: FakeWebRequest, + webContentsId: number, + url: string, +): { readonly cancel?: boolean; readonly redirectURL?: string } { + let response: { readonly cancel?: boolean; readonly redirectURL?: string } = {}; + webRequest.beforeRequest?.( + { id: webContentsId, url, method: "GET", resourceType: "mainFrame", webContentsId }, + (value) => { + response = value; + }, + ); + return response; +} + +describe("BrowserNetworkController session sharing", () => { + it("keeps request logs and routes scoped when two surfaces share one Electron session", async () => { + const session = new FakeSession(); + const first = new BrowserNetworkController({ + session: session as never, + webContents: { id: 11 } as never, + now: () => 1, + }); + const second = new BrowserNetworkController({ + session: session as never, + webContents: { id: 22 } as never, + now: () => 2, + }); + + expect(first.route({ urlPattern: "https://blocked.example/*", action: "abort" }).ok).toBe(true); + expect(first.setHeaders({ headers: { "X-T4-Surface": "first" } }).ok).toBe(true); + expect(second.setHeaders({ headers: { "X-T4-Surface": "second" } }).ok).toBe(true); + expect(request(session.webRequest, 11, "https://blocked.example/first")).toEqual({ cancel: true }); + expect(request(session.webRequest, 22, "https://allowed.example/second")).toEqual({}); + expect(headers(session.webRequest, 11)).toEqual({ Accept: "text/html", "x-t4-surface": "first" }); + expect(headers(session.webRequest, 22)).toEqual({ Accept: "text/html", "x-t4-surface": "second" }); + session.webRequest.completed?.({ + id: 11, + url: "https://blocked.example/first", + method: "GET", + resourceType: "mainFrame", + webContentsId: 11, + statusCode: 403, + }); + + expect(first.listRequests()).toEqual({ + ok: true, + value: [{ + requestId: 11, + method: "GET", + url: "https://blocked.example/first", + resourceType: "mainFrame", + startedAt: 1, + finishedAt: 1, + statusCode: 403, + }], + }); + expect(second.listRequests()).toEqual({ + ok: true, + value: [{ + requestId: 22, + method: "GET", + url: "https://allowed.example/second", + resourceType: "mainFrame", + startedAt: 2, + }], + }); + + await first.dispose(); + expect(request(session.webRequest, 22, "https://allowed.example/after-dispose")).toEqual({}); + expect(second.listRequests().ok).toBe(true); + await second.dispose(); + expect(session.webRequest.beforeRequest).toBeNull(); + }); + + it("fails closed instead of applying session-wide offline mode to sibling tabs", async () => { + const session = new FakeSession(); + const controller = new BrowserNetworkController({ + session: session as never, + webContents: { id: 11 } as never, + }); + + expect(controller.setOffline({ offline: true })).toEqual({ + ok: false, + code: "not_supported", + message: "Electron network emulation is session-wide and cannot be safely scoped to one browser surface", + reason: "Electron network emulation is session-wide and cannot be safely scoped to one browser surface", + }); + expect(session.networkEmulationCalls).toBe(0); + await controller.dispose(); + }); +}); diff --git a/apps/desktop/test/browser-profiles.test.ts b/apps/desktop/test/browser-profiles.test.ts new file mode 100644 index 0000000..186ab82 --- /dev/null +++ b/apps/desktop/test/browser-profiles.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; + +type VitestMockApi = { + readonly vi: { + mock(moduleName: string, factory: () => unknown): void; + }; +}; + +class FakeProfileSession { + clearStorageCalls = 0; + clearCacheCalls = 0; + readonly cookieWrites: unknown[] = []; + readonly cookies = { + set: async (details: unknown): Promise => { + this.cookieWrites.push(details); + }, + }; + + async clearStorageData(): Promise { + this.clearStorageCalls += 1; + } + + async clearCache(): Promise { + this.clearCacheCalls += 1; + } +} + +const electron = (() => { + const sessions = new Map(); + return { + sessions, + fromPartition: (partition: string): FakeProfileSession => { + let profileSession = sessions.get(partition); + if (profileSession === undefined) { + profileSession = new FakeProfileSession(); + sessions.set(partition, profileSession); + } + return profileSession; + }, + reset: (): void => { + sessions.clear(); + }, + }; +})(); + +// Native Electron bindings cannot load in Vitest. +const vitest = await import("vitest") as unknown as VitestMockApi; +vitest.vi.mock("electron", () => ({ session: { fromPartition: electron.fromPartition } })); + +const { BrowserProfileRegistry } = await import("../src/browser-profiles.ts"); +const { BrowserProfileAutomation } = await import("../src/browser-profile-automation.ts"); + +class MemoryProfileStore { + store: unknown = { version: 1, records: [] }; + + set(key: string, value: unknown): void { + this.store = { ...(this.store as Record), [key]: value }; + } +} + +function registry(): InstanceType { + electron.reset(); + return new BrowserProfileRegistry({ + store: new MemoryProfileStore(), + session: { fromPartition: (partition) => electron.fromPartition(partition) as never }, + now: () => 1_700_000_000_000, + }); +} + +function expectSecurity(result: { readonly ok: boolean; readonly code?: string }): void { + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected profile selection to be rejected"); + expect(result.code).toBe("security"); +} + +describe("BrowserProfileRegistry isolated sessions", () => { + it("uses one in-memory Electron session per owning OMP session", () => { + const profiles = registry(); + const isolated = { kind: "isolated-session", profileId: "isolated-session" } as const; + + const first = profiles.getSession(isolated, "workspace-session-a"); + const sameOwner = profiles.getSession(isolated, "workspace-session-a"); + const second = profiles.getSession(isolated, "workspace-session-b"); + + expect(first).toBe(sameOwner); + expect(first).not.toBe(second); + expect(electron.sessions.size).toBe(2); + expect([...electron.sessions.keys()].every((partition) => !partition.includes("workspace-session"))).toBe(true); + }); + + it("continues sharing an explicitly selected authenticated profile", () => { + const profiles = registry(); + const metadata = profiles.create({ profileId: "work" }); + const authenticated = { + kind: "authenticated-profile", + profileId: metadata.profileId, + explicitOptIn: true, + } as const; + + expect(profiles.getSession(authenticated, "workspace-session-a")).toBe( + profiles.getSession(authenticated, "workspace-session-b"), + ); + }); +}); + +describe("BrowserProfileRegistry active profile counts", () => { + it("keeps a profile in use until both tabs release it without underflow", async () => { + const profiles = registry(); + const profile = profiles.create({ profileId: "work" }); + + profiles.markInUse("isolated-session"); + expect(profiles.isInUse("isolated-session")).toBe(false); + + profiles.markInUse(profile.profileId); + profiles.markInUse(profile.profileId); + expect(profiles.isInUse(profile.profileId)).toBe(true); + await expect(profiles.delete(profile.profileId)).rejects.toThrow("browser profile is in use"); + + profiles.release(profile.profileId); + expect(profiles.isInUse(profile.profileId)).toBe(true); + await expect(profiles.delete(profile.profileId)).rejects.toThrow("browser profile is in use"); + + profiles.release(profile.profileId); + profiles.release(profile.profileId); + expect(profiles.isInUse(profile.profileId)).toBe(false); + expect(await profiles.delete(profile.profileId)).toBe(true); + }); +}); + +describe("BrowserProfileAutomation authenticated mutations", () => { + it("requires a matching explicit authenticated profile for every mutation", async () => { + const profiles = registry(); + const profile = profiles.create({ profileId: "work" }); + const matchingProfile = { kind: "authenticated-profile", profileId: profile.profileId, explicitOptIn: true } as const; + const session = electron.fromPartition(profile.partition); + let readCalls = 0; + const automation = new BrowserProfileAutomation({ + registry: profiles, + readFile: async () => { + readCalls += 1; + return JSON.stringify([{ name: "sid", value: "value", domain: "example.test", path: "/", secure: true }]); + }, + }); + const invalidSelections = [ + { profileId: profile.profileId }, + { profileId: profile.profileId, profile: { kind: "authenticated-profile", profileId: "other", explicitOptIn: true } }, + { profileId: profile.profileId, profile: { kind: "authenticated-profile", profileId: profile.profileId, explicitOptIn: false } }, + { profileId: profile.profileId, profile: { kind: "isolated-session", profileId: "isolated-session" } }, + { profileId: profile.profileId, profile: null }, + ]; + + for (const selection of invalidSelections) { + expectSecurity(await automation.clear(selection as never)); + expectSecurity(await automation.delete(selection as never)); + expectSecurity(await automation.importCookies({ ...selection, filePath: "/selected/cookies.json" } as never)); + } + expect(session.clearStorageCalls).toBe(0); + expect(session.clearCacheCalls).toBe(0); + expect(session.cookieWrites).toEqual([]); + expect(readCalls).toBe(0); + expect(profiles.resolve(profile.profileId).profileId).toBe(profile.profileId); + + const cleared = await automation.clear({ profileId: profile.profileId, profile: matchingProfile }); + expect(cleared.ok).toBe(true); + expect(session.clearStorageCalls).toBe(1); + expect(session.clearCacheCalls).toBe(1); + + const imported = await automation.importCookies({ profileId: profile.profileId, profile: matchingProfile, filePath: "/selected/cookies.json" }); + expect(imported.ok).toBe(true); + if (!imported.ok) throw new Error("Expected cookies to import"); + expect(imported.value).toEqual({ profileId: profile.profileId, imported: 1, selected: false }); + expect(readCalls).toBe(1); + expect(session.cookieWrites).toHaveLength(1); + + const deleted = await automation.delete({ profileId: profile.profileId, profile: matchingProfile }); + expect(deleted.ok).toBe(true); + expect(session.clearStorageCalls).toBe(2); + expect(session.clearCacheCalls).toBe(2); + expect(() => profiles.resolve(profile.profileId)).toThrow("authenticated browser profile was not found"); + + const isolated = await automation.clear({ profileId: "isolated-session", profile: { kind: "isolated-session", profileId: "isolated-session" } }); + expectSecurity(isolated); + }); +}); diff --git a/apps/desktop/test/browser-runtime.test.ts b/apps/desktop/test/browser-runtime.test.ts new file mode 100644 index 0000000..1de0622 --- /dev/null +++ b/apps/desktop/test/browser-runtime.test.ts @@ -0,0 +1,678 @@ +import { describe, expect, it } from "vitest"; +import type { BrowserCallResult, BrowserEvent } from "@t4-code/protocol/browser-ipc"; +import type { BrowserSurface as BrowserSurfaceType } from "../src/browser-surface.ts"; + +type VitestMockApi = { + readonly vi: { + mock(moduleName: string, factory: () => unknown): void; + }; +}; + +// The local Vitest declaration intentionally exposes only the common assertion API. +const vitest = await import("vitest") as unknown as VitestMockApi; + +const electron = (() => { + type Listener = (...args: unknown[]) => void; + const loadFailures: unknown[] = []; + + + class FakeWebContents { + readonly listeners = new Map(); + readonly navigationHistory = { + canGoBack: () => this.canGoBack, + canGoForward: () => this.canGoForward, + goBack: () => { this.goBackCount += 1; }, + goForward: () => { this.goForwardCount += 1; }, + }; + canGoBack = false; + canGoForward = false; + goBackCount = 0; + goForwardCount = 0; + closed = false; + url = "about:blank"; + title = ""; + on(event: string, listener: Listener): void { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + } + removeListener(event: string, listener: Listener): void { + const listeners = this.listeners.get(event) ?? []; + this.listeners.set(event, listeners.filter((candidate) => candidate !== listener)); + } + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } + isDestroyed(): boolean { return this.closed; } + close(): void { this.closed = true; this.emit("destroyed"); } + loadURL(url: string): Promise { + this.url = url; + const failure = loadFailures.shift(); + return failure === undefined ? Promise.resolve() : Promise.reject(failure); + } + getURL(): string { return this.url; } + getTitle(): string { return this.title; } + isLoadingMainFrame(): boolean { return false; } + reload(): void {} + stop(): void {} + setAudioMuted(): void {} + setZoomFactor(): void {} + getZoomFactor(): number { return 1; } + focus(): void {} + executeJavaScript(): Promise { return Promise.resolve(undefined); } + } + + class FakeWebContentsView { + readonly webContents = new FakeWebContents(); + bounds: unknown; + setBounds(bounds: unknown): void { this.bounds = bounds; } + } + + const views: FakeWebContentsView[] = []; + class WebContentsView extends FakeWebContentsView { + constructor() { + super(); + views.push(this); + } + } + + return { + WebContentsView, + session: { defaultSession: {} }, + views, + ipcMain: { on: () => {}, removeListener: () => {} }, + contentTracing: { startRecording: async () => {}, stopRecording: async () => "" }, + failNextLoad: (error: unknown) => { loadFailures.push(error); }, + reset: () => { + views.length = 0; + loadFailures.length = 0; + }, + }; +})(); + +vitest.vi.mock("electron", () => ({ + WebContentsView: electron.WebContentsView, + contentTracing: electron.contentTracing, + ipcMain: electron.ipcMain, + session: electron.session, +})); + +// These imports follow the Electron mock; the native Electron binding cannot load in this test process. +const { BrowserRuntime } = await import("../src/browser-runtime.ts"); +const { BrowserSessionStore, decodeBrowserSessionStoreState } = await import("../src/browser-session-store.ts"); +const { BrowserSurface } = await import("../src/browser-surface.ts"); + +class FakeWindow { + closed = false; + readonly contentView = { + children: new Set(), + addChildView: (view: unknown) => { this.contentView.children.add(view); }, + removeChildView: (view: unknown) => { this.contentView.children.delete(view); }, + }; + close(): void { this.closed = true; } +} + +const isolatedProfile = { kind: "isolated-session", profileId: "isolated-session" } as const; +const OWNER_A = "workspace-session-a"; +const OWNER_B = "workspace-session-b"; +const OWNER_C = "workspace-session-c"; + +function browserCall(method: string, request: Record, ownerSessionId = OWNER_A): never { + return { method, request, ownerSessionId } as never; +} + +async function expectContentReadyTimeout(surface: BrowserSurfaceType): Promise { + try { + await surface.waitForContentReady(0); + } catch (error) { + if (typeof error !== "object" || error === null || !("code" in error)) throw error; + expect(error.code).toBe("timeout"); + return; + } + throw new Error("Expected content readiness to time out"); +} + +async function settleBackgroundWork(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("BrowserRuntime native view lifecycle", () => { + it("requests isolated Electron sessions using the owning OMP session id", async () => { + electron.reset(); + const ownerSessions: unknown[] = []; + const runtime = new BrowserRuntime({ + window: new FakeWindow() as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-owner-partitions", + profileRegistry: { + getSession: (_profile, ownerSessionId) => { + ownerSessions.push(ownerSessionId); + return {}; + }, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { save: () => {} }, + downloadController: { attach: () => {}, disposeSurface: () => {}, dispose: () => {} }, + installSecurity: () => ({ + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ ok: false, code: "not_supported", message: "Not used by this test" }), + }), + }); + + await runtime.call(browserCall("surface.create", { profile: isolatedProfile }, OWNER_A)); + await runtime.call(browserCall("surface.create", { profile: isolatedProfile }, OWNER_B)); + + expect(ownerSessions).toEqual([OWNER_A, OWNER_B]); + await runtime.dispose(); + }); + + it("creates and attaches a surface, detaches it while hidden, reattaches it, and disposes without orphaning it", async () => { + electron.reset(); + const window = new FakeWindow(); + const released: string[] = []; + const profileSession = {}; + const downloadAttachments: unknown[][] = []; + const runtime = new BrowserRuntime({ + window: window as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-test", + profileRegistry: { + getSession: () => profileSession, + markInUse: () => {}, + release: (profileId) => { released.push(profileId); }, + }, + sessionStore: { save: () => {} }, + downloadController: { + attach: (...args) => { downloadAttachments.push(args); }, + disposeSurface: () => {}, + dispose: () => {}, + }, + installSecurity: () => ({ + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ ok: false, code: "not_supported", message: "Not used by this test" }), + }), + }); + + const created = await runtime.call(browserCall("surface.create", { + profile: isolatedProfile, + url: "https://example.test/", + })) as BrowserCallResult<"surface.create">; + expect(created.surface.surfaceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(electron.views).toHaveLength(1); + const view = electron.views[0]!; + expect(window.contentView.children).toEqual(new Set([view])); + const surfaceId = created.surface.surfaceId; + expect(created.surface.handle).toBe("surface:1"); + expect(downloadAttachments).toEqual([[view.webContents, surfaceId, profileSession]]); + await runtime.call(browserCall("surface.setBounds", { + surfaceId, + bounds: { x: 1, y: 2, width: 300, height: 200 }, + visible: false, + })); + expect(window.contentView.children).toEqual(new Set()); + expect(view.webContents.closed).toBe(false); + + await runtime.call(browserCall("surface.setBounds", { + surfaceId, + bounds: { x: 1, y: 2, width: 300, height: 200 }, + visible: true, + })); + expect(window.contentView.children).toEqual(new Set([view])); + + await runtime.dispose(); + expect(window.contentView.children).toEqual(new Set()); + expect(view.webContents.closed).toBe(true); + expect(released).toEqual(["isolated-session"]); + }); + + it("keeps surfaces and prewarmed views within their owning workspace session", async () => { + electron.reset(); + const runtime = new BrowserRuntime({ + window: new FakeWindow() as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-ownership", + profileRegistry: { + getSession: () => electron.session.defaultSession, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { save: () => {} }, + downloadController: { attach: () => {}, disposeSurface: () => {}, dispose: () => {} }, + installSecurity: () => ({ + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ ok: false, code: "not_supported", message: "Not used by this test" }), + }), + }); + + const owned = await runtime.call(browserCall("surface.create", { + profile: isolatedProfile, + url: "https://owner-a.example.test/", + }, OWNER_A)) as BrowserCallResult<"surface.create">; + const prewarmed = await runtime.prewarm(OWNER_B as never, isolatedProfile, "https://prewarm-b.example.test/"); + const createdByA = await runtime.call(browserCall("surface.create", { + profile: isolatedProfile, + url: "https://prewarm-b.example.test/", + }, OWNER_A)) as BrowserCallResult<"surface.create">; + + const aList = await runtime.call(browserCall("surface.list", {}, OWNER_A)) as BrowserCallResult<"surface.list">; + const bList = await runtime.call(browserCall("surface.list", {}, OWNER_B)) as BrowserCallResult<"surface.list">; + expect(aList.surfaces.map((surface) => surface.surfaceId)).toEqual([ + owned.surface.surfaceId, + createdByA.surface.surfaceId, + ]); + expect(bList.surfaces.map((surface) => surface.surfaceId)).toEqual([prewarmed.surfaceId]); + + let crossOwnerError: unknown; + try { + await runtime.call(browserCall("surface.get", { surfaceId: prewarmed.surfaceId }, OWNER_A)); + } catch (error) { + crossOwnerError = error; + } + expect((crossOwnerError as { code?: unknown }).code).toBe("not_found"); + + let fallbackError: unknown; + try { + await runtime.call(browserCall("browser.navigate", { url: "https://no-owner.example.test/" }, OWNER_C)); + } catch (error) { + fallbackError = error; + } + expect((fallbackError as { code?: unknown }).code).toBe("not_found"); + await runtime.dispose(); + }); + + it("restores only persisted surfaces with a matching explicit owner", async () => { + electron.reset(); + const persisted = { value: [] as unknown[] }; + const options = { + window: new FakeWindow() as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-restore-ownership", + profileRegistry: { + getSession: () => electron.session.defaultSession, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { + load: () => persisted.value as never, + save: (metadata: unknown) => { persisted.value = metadata as unknown[]; }, + }, + downloadController: { attach: () => {}, disposeSurface: () => {}, dispose: () => {} }, + installSecurity: () => ({ + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ ok: false, code: "not_supported", message: "Not used by this test" } as const), + }), + }; + const initial = new BrowserRuntime(options); + const a = await initial.call(browserCall("surface.create", { + profile: isolatedProfile, + url: "https://persisted-a.example.test/", + }, OWNER_A)) as BrowserCallResult<"surface.create">; + const b = await initial.call(browserCall("surface.create", { + profile: isolatedProfile, + url: "https://persisted-b.example.test/", + }, OWNER_B)) as BrowserCallResult<"surface.create">; + await initial.dispose(); + + const restored = new BrowserRuntime({ ...options, window: new FakeWindow() as never }); + const aList = await restored.call(browserCall("surface.list", {}, OWNER_A)) as BrowserCallResult<"surface.list">; + const bList = await restored.call(browserCall("surface.list", {}, OWNER_B)) as BrowserCallResult<"surface.list">; + expect(aList.surfaces.map((surface) => surface.surfaceId)).toEqual([a.surface.surfaceId]); + expect(bList.surfaces.map((surface) => surface.surfaceId)).toEqual([b.surface.surfaceId]); + await restored.dispose(); + }); + + it("does not restore authenticated pages before fresh user consent", async () => { + electron.reset(); + const authenticated = { + kind: "authenticated-profile", + profileId: "work", + explicitOptIn: true, + } as const; + let sessionRequests = 0; + const runtime = new BrowserRuntime({ + window: new FakeWindow() as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-authenticated-restore", + profileRegistry: { + resolve: () => authenticated, + getSession: () => { + sessionRequests += 1; + return electron.session.defaultSession; + }, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { + load: () => [{ + surfaceId: "11111111-1111-4111-8111-111111111111", + handle: "surface:1", + ownerSessionId: OWNER_A, + profile: authenticated, + url: "https://authenticated.example.test/", + order: 0, + zoom: 1, + }] as never, + save: () => {}, + }, + downloadController: { attach: () => {}, disposeSurface: () => {}, dispose: () => {} }, + }); + + const restored = await runtime.call(browserCall("surface.list", {}, OWNER_A)) as BrowserCallResult<"surface.list">; + + expect(restored.surfaces).toEqual([]); + expect(sessionRequests).toBe(0); + expect(electron.views).toHaveLength(0); + await runtime.dispose(); + }); + + it("creates a hidden managed surface for accepted popups", async () => { + electron.reset(); + let popup: ((request: { readonly url: string; readonly frameName: string; readonly disposition: string; readonly referrer: string }) => boolean) | undefined; + const window = new FakeWindow(); + const runtime = new BrowserRuntime({ + window: window as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-popup", + profileRegistry: { + getSession: () => electron.session.defaultSession, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { save: () => {} }, + downloadController: { attach: () => {}, disposeSurface: () => {}, dispose: () => {} }, + installSecurity: (options) => { + if (options.onPopup !== undefined) popup = options.onPopup; + return { + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ ok: false, code: "not_supported", message: "Not used by this test" }), + }; + }, + }); + await runtime.call(browserCall("surface.create", { + profile: isolatedProfile, + url: "https://origin.example.test/", + }, OWNER_A)); + if (popup === undefined) throw new Error("Expected popup callback to be installed"); + expect(popup({ + url: "https://popup.example.test/", + frameName: "popup", + disposition: "new-window", + referrer: "https://origin.example.test/", + })).toBe(true); + + const listed = await runtime.call(browserCall("surface.list", {}, OWNER_A)) as BrowserCallResult<"surface.list">; + expect(listed.surfaces).toHaveLength(2); + expect(listed.surfaces[1]?.url).toBe("https://popup.example.test/"); + expect(listed.surfaces[1]?.visible).toBe(false); + expect(electron.views).toHaveLength(2); + await runtime.dispose(); + }); + + it("blocks authenticated popups until the user can consent to the new surface", async () => { + electron.reset(); + let popup: ((request: { readonly url: string; readonly frameName: string; readonly disposition: string; readonly referrer: string }) => boolean) | undefined; + const authenticated = { + kind: "authenticated-profile", + profileId: "work", + explicitOptIn: true, + } as const; + const runtime = new BrowserRuntime({ + window: new FakeWindow() as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-authenticated-popup", + profileRegistry: { + resolve: () => authenticated, + getSession: () => electron.session.defaultSession, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { save: () => {} }, + downloadController: { attach: () => {}, disposeSurface: () => {}, dispose: () => {} }, + installSecurity: (options) => { + if (options.onPopup !== undefined) popup = options.onPopup; + return { + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ ok: false, code: "not_supported", message: "Not used by this test" }), + }; + }, + }); + await runtime.call(browserCall("surface.create", { + profile: authenticated, + url: "https://authenticated.example.test/", + }, OWNER_A)); + if (popup === undefined) throw new Error("Expected popup callback to be installed"); + + expect(popup({ + url: "https://popup.example.test/", + frameName: "popup", + disposition: "new-window", + referrer: "https://authenticated.example.test/", + })).toBe(false); + + const listed = await runtime.call(browserCall("surface.list", {}, OWNER_A)) as BrowserCallResult<"surface.list">; + expect(listed.surfaces).toHaveLength(1); + expect(electron.views).toHaveLength(1); + await runtime.dispose(); + }); + + it("contains background browser failures without closing the parent window", async () => { + electron.reset(); + electron.failNextLoad(new Error("initial load failed")); + const window = new FakeWindow(); + const events: BrowserEvent[] = []; + const unhandled: unknown[] = []; + const onUnhandledRejection = (reason: unknown): void => { unhandled.push(reason); }; + process.on("unhandledRejection", onUnhandledRejection); + try { + let installationCount = 0; + const runtime = new BrowserRuntime({ + window: window as never, + emit: (event) => { events.push(event); }, + userDataPath: "/tmp/t4-browser-runtime-failures", + profileRegistry: { + getSession: () => electron.session.defaultSession, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { save: () => Promise.reject(new Error("session persistence failed")) }, + downloadController: { attach: () => {}, disposeSurface: () => {}, dispose: () => {} }, + installSecurity: () => { + installationCount += 1; + if (installationCount > 1) throw new Error("security reinstall failed"); + return { + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ ok: false, code: "not_supported", message: "Not used by this test" }), + }; + }, + }); + + const created = await runtime.call(browserCall("surface.create", { + profile: isolatedProfile, + url: "https://example.test/", + visible: false, + })) as BrowserCallResult<"surface.create">; + const networkControllers = runtime as unknown as { + networkControllers: Map Promise }>; + }; + networkControllers.networkControllers.set(created.surface.surfaceId, { + dispose: async () => { throw new Error("network disposal failed"); }, + }); + electron.failNextLoad(new Error("replacement load failed")); + electron.views[0]!.webContents.emit("render-process-gone", {}, { reason: "crashed" }); + await settleBackgroundWork(); + + const tabs = await runtime.call(browserCall("browser.tab.list", {})); + if ( + typeof tabs !== "object" + || tabs === null + || !("surfaces" in tabs) + || !Array.isArray(tabs.surfaces) + ) { + throw new Error("browser.tab.list must return a surfaces array"); + } + expect(tabs.surfaces).toHaveLength(1); + expect(electron.views).toHaveLength(2); + expect(window.closed).toBe(false); + expect(unhandled).toEqual([]); + const errors = events.filter((event): event is Extract => event.type === "error"); + expect(errors.some(({ error }) => error.code === "load_failed" && error.fatal === false)).toBe(true); + expect(errors.some(({ error }) => error.code === "session_persist_failed" && error.fatal === false)).toBe(true); + expect(errors.some(({ error }) => error.code === "network_dispose_failed" && error.fatal === false)).toBe(true); + expect(errors.some(({ error }) => error.code === "security_install_failed" && error.fatal === false)).toBe(true); + + await runtime.dispose(); + expect(window.closed).toBe(false); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + + it("assigns independent UUID identities and monotonic handles, including persisted session metadata", async () => { + electron.reset(); + const savedSessions: unknown[] = []; + const runtime = new BrowserRuntime({ + window: new FakeWindow() as never, + emit: () => {}, + userDataPath: "/tmp/t4-browser-runtime-identities", + profileRegistry: { + getSession: () => electron.session.defaultSession, + markInUse: () => {}, + release: () => {}, + }, + sessionStore: { save: (metadata) => { savedSessions.push(metadata); } }, + downloadController: { attach: () => {}, disposeSurface: () => {}, dispose: () => {} }, + installSecurity: () => ({ + auth: null, + clearTrustGrants: () => {}, + dispose: () => {}, + grantCertificate: () => false, + setProfile: () => {}, + configureProxy: async () => ({ ok: false, code: "not_supported", message: "Not used by this test" }), + }), + }); + + const first = await runtime.call(browserCall("surface.create", { + profile: isolatedProfile, + url: "https://one.example.test/", + })) as BrowserCallResult<"surface.create">; + const second = await runtime.call(browserCall("surface.create", { + profile: isolatedProfile, + url: "https://two.example.test/", + })) as BrowserCallResult<"surface.create">; + + expect(first.surface.surfaceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(second.surface.surfaceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(first.surface.surfaceId).not.toBe(second.surface.surfaceId); + expect(first.surface.handle).toBe("surface:1"); + expect(second.surface.handle).toBe("surface:2"); + const savedBeforeClose = savedSessions.at(-1); + + const firstLookup = await runtime.call(browserCall("surface.get", { surfaceId: first.surface.surfaceId })) as BrowserCallResult<"surface.get">; + expect(firstLookup.surface.surfaceId).toBe(first.surface.surfaceId); + expect(firstLookup.surface.handle).toBe("surface:1"); + expect(firstLookup.surface.profile).toEqual(isolatedProfile); + await runtime.call(browserCall("surface.close", { surfaceId: first.surface.surfaceId })); + const secondLookup = await runtime.call(browserCall("surface.get", { surfaceId: second.surface.surfaceId })) as BrowserCallResult<"surface.get">; + expect(secondLookup.surface.surfaceId).toBe(second.surface.surfaceId); + expect(secondLookup.surface.handle).toBe("surface:2"); + expect(secondLookup.surface.profile).toEqual(isolatedProfile); + + const restored = new BrowserSessionStore({ + store: { + get store() { return { version: 2, surfaces: savedBeforeClose }; }, + set: () => {}, + }, + }).load(); + expect(restored).toHaveLength(2); + const restoredFirst = restored.find(({ surfaceId }) => surfaceId === first.surface.surfaceId); + if (!restoredFirst) throw new Error("Expected the first surface session to be persisted"); + expect(restoredFirst.handle).toBe("surface:1"); + expect(restoredFirst.ownerSessionId).toBe(OWNER_A); + expect(restoredFirst.profile).toEqual(isolatedProfile); + const restoredSecond = restored.find(({ surfaceId }) => surfaceId === second.surface.surfaceId); + if (!restoredSecond) throw new Error("Expected the second surface session to be persisted"); + expect(restoredSecond.handle).toBe("surface:2"); + expect(restoredSecond.profile).toEqual(isolatedProfile); + expect(decodeBrowserSessionStoreState({ + version: 2, + surfaces: [ + { ...restored[0], handle: "surface:1" }, + { ...restored[1], handle: "surface:1" }, + ], + }).surfaces).toEqual([]); + expect(decodeBrowserSessionStoreState({ + version: 2, + surfaces: [{ ...restored[0], ownerSessionId: undefined, sessionId: "legacy-runtime-owner" }], + }).surfaces).toEqual([]); + + await runtime.dispose(); + }); +}); + +describe("BrowserSurface history navigation", () => { + it("invalidates content readiness synchronously before navigation-history back and forward", async () => { + electron.reset(); + const surface = new BrowserSurface({ + window: new FakeWindow() as never, + surfaceId: "ab12cd34-5678-4abc-8def-0123456789ab" as never, + handle: "surface:1" as never, + profile: isolatedProfile, + session: electron.session.defaultSession as never, + url: "https://example.test/", + bounds: { x: 0, y: 0, width: 300, height: 200 }, + visible: false, + emit: () => {}, + }); + const contents = electron.views[0]!.webContents; + + contents.emit("did-finish-load"); + await surface.waitForContentReady(1); + contents.canGoBack = true; + const back = surface.goBack(); + await expectContentReadyTimeout(surface); + await back; + expect(contents.goBackCount).toBe(1); + + contents.emit("did-finish-load"); + await surface.waitForContentReady(1); + contents.canGoForward = true; + const forward = surface.goForward(); + await expectContentReadyTimeout(surface); + await forward; + expect(contents.goForwardCount).toBe(1); + }); +}); diff --git a/apps/desktop/test/browser-security.test.ts b/apps/desktop/test/browser-security.test.ts new file mode 100644 index 0000000..7bbeafe --- /dev/null +++ b/apps/desktop/test/browser-security.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +type Listener = (...args: unknown[]) => void; +type PermissionRequestHandler = (contents: FakeWebContents, permission: string, callback: (allowed: boolean) => void, details?: { readonly requestingUrl?: string; readonly isMainFrame?: boolean }) => void; +type WindowOpenHandler = (details: { readonly url: string; readonly frameName: string; readonly disposition: string; readonly referrer: { readonly url: string } }) => { readonly action: string }; + +type VitestMockApi = { + readonly vi: { + mock(moduleName: string, factory: () => unknown): void; + }; +}; + +const vitest = await import("vitest") as unknown as VitestMockApi; + +class FakeWebContents { + readonly listeners = new Map(); + windowOpenHandler: WindowOpenHandler | null = null; + url = "https://example.test/"; + + on(event: string, listener: Listener): void { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + } + + off(event: string, listener: Listener): void { + this.listeners.set(event, (this.listeners.get(event) ?? []).filter((candidate) => candidate !== listener)); + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } + + getURL(): string { + return this.url; + } + + setWindowOpenHandler(handler: WindowOpenHandler): void { + this.windowOpenHandler = handler; + } +} + +class FakeSession { + readonly listeners = new Map(); + permissionRequestHandler: PermissionRequestHandler | null = null; + permissionCheckHandler: ((contents: FakeWebContents) => boolean) | null = null; + proxyChanges = 0; + + on(event: string, listener: Listener): void { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + } + + off(event: string, listener: Listener): void { + this.listeners.set(event, (this.listeners.get(event) ?? []).filter((candidate) => candidate !== listener)); + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } + + setPermissionRequestHandler(handler: PermissionRequestHandler | null): void { + this.permissionRequestHandler = handler; + } + + setPermissionCheckHandler(handler: ((contents: FakeWebContents) => boolean) | null): void { + this.permissionCheckHandler = handler; + } + + async setProxy(): Promise { this.proxyChanges += 1; } + + async closeAllConnections(): Promise {} +} + +vitest.vi.mock("electron", () => ({ + app: { on: () => {}, off: () => {} }, +})); + +const { installBrowserSurfaceSecurity } = await import("../src/browser-security.ts"); +const profile = { kind: "isolated-session", profileId: "test-profile" } as never; + +function createController(session: FakeSession, webContents: FakeWebContents, options: { readonly onPopup?: (request: { readonly url: string }) => boolean; readonly onDownload?: (request: { readonly url: string; readonly filename: string }) => boolean; readonly onPermissionPrompt?: () => boolean } = {}) { + return installBrowserSurfaceSecurity({ + session: session as never, + webContents: webContents as never, + profile, + onPopup: options.onPopup as never, + onDownload: options.onDownload as never, + onPermissionPrompt: options.onPermissionPrompt as never, + }); +} + +function cancelableEvent(): { prevented: boolean; preventDefault(): void } { + return { + prevented: false, + preventDefault(): void { this.prevented = true; }, + }; +} + +describe("browser surface security", () => { + it("prevents unsafe main-frame navigations", () => { + const session = new FakeSession(); + const webContents = new FakeWebContents(); + const controller = createController(session, webContents); + const event = cancelableEvent(); + + webContents.emit("will-navigate", event, "file:///private/secret", false, true); + + expect(event.prevented).toBe(true); + controller.dispose(); + }); + + it("creates only a managed popup and denies Electron's original child", () => { + const session = new FakeSession(); + const webContents = new FakeWebContents(); + const requests: string[] = []; + const controller = createController(session, webContents, { + onPopup: (request) => { + requests.push(request.url); + return true; + }, + }); + const handler = webContents.windowOpenHandler; + if (!handler) throw new Error("Popup handler was not installed"); + + const result = handler({ + url: "https://popup.example.test/", + frameName: "report", + disposition: "new-window", + referrer: { url: "https://example.test/" }, + }); + + expect(requests).toEqual(["https://popup.example.test/"]); + expect(result).toEqual({ action: "deny" }); + controller.dispose(); + }); + + it("keeps shared-session permission routing active until its last surface disposes", () => { + const session = new FakeSession(); + const firstContents = new FakeWebContents(); + const secondContents = new FakeWebContents(); + const first = createController(session, firstContents, { onPermissionPrompt: () => false }); + const second = createController(session, secondContents, { onPermissionPrompt: () => true }); + const handler = session.permissionRequestHandler; + if (!handler) throw new Error("Permission request handler was not installed"); + const decisions: boolean[] = []; + + handler(firstContents, "notifications", (allowed) => decisions.push(allowed), { requestingUrl: "https://first.example.test/", isMainFrame: true }); + handler(secondContents, "notifications", (allowed) => decisions.push(allowed), { requestingUrl: "https://second.example.test/", isMainFrame: true }); + first.dispose(); + + expect(decisions).toEqual([false, true]); + expect(session.permissionRequestHandler).toBe(handler); + expect(session.permissionCheckHandler?.(secondContents)).toBe(false); + handler(secondContents, "notifications", (allowed) => decisions.push(allowed), { requestingUrl: "https://second.example.test/", isMainFrame: true }); + expect(decisions).toEqual([false, true, true]); + + second.dispose(); + expect(session.permissionRequestHandler).toBe(null); + expect(session.permissionCheckHandler).toBe(null); + }); + + it("does not cancel a sibling surface download but denies unsafe own downloads", () => { + const session = new FakeSession(); + const firstContents = new FakeWebContents(); + const secondContents = new FakeWebContents(); + const first = createController(session, firstContents, { onDownload: () => true }); + const second = createController(session, secondContents, { onDownload: () => true }); + const siblingDownload = cancelableEvent(); + + session.emit("will-download", siblingDownload, { getURL: () => "https://example.test/report.csv", getFilename: () => "report.csv" }, secondContents); + + expect(siblingDownload.prevented).toBe(false); + const unsafeOwnDownload = cancelableEvent(); + session.emit("will-download", unsafeOwnDownload, { getURL: () => "file:///private/secret", getFilename: () => "secret.txt" }, firstContents); + expect(unsafeOwnDownload.prevented).toBe(true); + + first.dispose(); + second.dispose(); + }); + + it("fails closed instead of replacing the shared session proxy", async () => { + const session = new FakeSession(); + const controller = createController(session, new FakeWebContents()); + + expect(await controller.configureProxy({ mode: "fixed", proxy: "https://proxy.example.test:443" })).toEqual({ + ok: false, + code: "not_supported", + message: "Electron proxy configuration is session-wide and cannot be safely scoped to one browser surface", + }); + controller.dispose(); + await Promise.resolve(); + + expect(session.proxyChanges).toBe(0); + }); +}); diff --git a/apps/desktop/test/lifecycle-runtime.test.ts b/apps/desktop/test/lifecycle-runtime.test.ts index 5baa04a..9e1a661 100644 --- a/apps/desktop/test/lifecycle-runtime.test.ts +++ b/apps/desktop/test/lifecycle-runtime.test.ts @@ -76,10 +76,27 @@ class FakeWindow { finishLoad(): void { this.emit("did-finish-load"); } close(): void { this.destroyed = true; this.emit("closed"); } } + +class FakeBrowserRuntime { + disposeCount = 0; + readonly calls: unknown[] = []; + + async call(call: unknown): Promise<{ surfaces: readonly [] }> { + this.calls.push(call); + return { surfaces: [] }; + } + async dispose(): Promise { + this.disposeCount += 1; + } +} class FakeIpc implements IpcMainLike { readonly handlers = new Map(); + readonly removals = new Map(); handle(channel: string, listener: unknown): void { this.handlers.set(channel, listener); } - removeHandler(channel: string): void { this.handlers.delete(channel); } + removeHandler(channel: string): void { + this.removals.set(channel, (this.removals.get(channel) ?? 0) + 1); + this.handlers.delete(channel); + } } function setup( serviceManager?: ServiceManager, @@ -88,6 +105,7 @@ function setup( readonly discoverExecutable?: () => Promise; readonly createServiceManager?: NonNullable; readonly createProjectionCache?: NonNullable; + readonly createBrowserRuntime?: NonNullable; } = {}, ) { const app = new FakeApp(); @@ -95,6 +113,12 @@ function setup( const ipc = new FakeIpc(); const registries: DesktopIpcRegistry[] = []; const runtimes: unknown[] = []; + const browserRuntimes: FakeBrowserRuntime[] = []; + const createBrowserRuntime = overrides.createBrowserRuntime ?? (() => { + const runtime = new FakeBrowserRuntime(); + browserRuntimes.push(runtime); + return runtime as never; + }); let managerOptions: TargetManagerOptions | undefined; let closeCount = 0; let updateScheduleCount = 0; @@ -128,6 +152,7 @@ function setup( windows.push(next); return { window: next as never, trustedRenderer: { origin: "file://", url: "file:///trusted/index.html" } }; }, + createBrowserRuntime, createIpcRegistry: (runtime) => { runtimes.push(runtime); const registry = new DesktopIpcRegistry(runtime, ipc); registries.push(registry); return registry; }, loadIdentity: () => ({ deviceId: "device-test", deviceName: "Desktop Test" }), createCursorStore: () => ({ load: () => [], save: () => {} }), @@ -144,7 +169,7 @@ function setup( createUpdateController: () => updateController as never, installMenu: (options) => { menuOptions = options; }, }); - return { app, windows, ipc, registries, runtimes, lifecycle, manager, localProfileRegistry, get managerOptions() { return managerOptions; }, get closeCount() { return closeCount; }, get updateScheduleCount() { return updateScheduleCount; }, get updateDisposeCount() { return updateDisposeCount; }, get menuOptions() { return menuOptions; } }; + return { app, windows, ipc, registries, runtimes, browserRuntimes, lifecycle, manager, localProfileRegistry, get managerOptions() { return managerOptions; }, get closeCount() { return closeCount; }, get updateScheduleCount() { return updateScheduleCount; }, get updateDisposeCount() { return updateDisposeCount; }, get menuOptions() { return menuOptions; } }; } describe("desktop Electron lifecycle", () => { @@ -170,18 +195,258 @@ describe("desktop Electron lifecycle", () => { expect(window.focusCount).toBe(1); await fixture.lifecycle.stop(); }); - it("rebinds a fresh trusted window and IPC registry after close and activate", async () => { + it("recreates the browser runtime with each trusted window and disposes it on close and stop", async () => { const fixture = setup(); await fixture.lifecycle.start(); const first = fixture.windows[0]!; first.close(); + expect(fixture.browserRuntimes[0]?.disposeCount).toBe(1); fixture.app.listeners.get("activate")?.(); expect(fixture.windows).toHaveLength(2); expect(fixture.registries).toHaveLength(2); + expect(fixture.browserRuntimes).toHaveLength(2); expect(fixture.runtimes[0]).toMatchObject({ manager: fixture.manager }); expect(fixture.runtimes[1]).toMatchObject({ manager: fixture.manager }); expect(fixture.managerOptions).toBeDefined(); await fixture.lifecycle.stop(); + expect(fixture.browserRuntimes[1]?.disposeCount).toBe(1); + }); + it("keeps browser and speech handlers stable while a renderer reload swaps browser targets", async () => { + const fixture = setup(); + await fixture.lifecycle.start(); + const window = fixture.windows[0]!; + const oldRuntime = fixture.browserRuntimes[0]!; + const browserCall = fixture.ipc.handlers.get("browser:call") as ( + event: unknown, + payload: unknown, + ) => Promise; + const stopSpeaking = fixture.ipc.handlers.get("omp:speech:stop") as ( + event: unknown, + payload: unknown, + ) => Promise; + + window.emit("did-start-loading"); + + const replacement = fixture.browserRuntimes[1]!; + expect(fixture.ipc.handlers.get("browser:call")).toBe(browserCall); + expect(fixture.ipc.handlers.get("omp:speech:stop")).toBe(stopSpeaking); + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }; + const result = await browserCall(event, { + channel: "browser:call", + payload: { version: 1, method: "surface.list", request: {} }, + }); + expect(result).toEqual({ surfaces: [] }); + expect(await stopSpeaking(event, { + channel: "omp:speech:stop", + payload: {}, + })).toEqual({ accepted: true }); + + expect(oldRuntime.disposeCount).toBe(1); + expect(oldRuntime.calls).toEqual([]); + expect(replacement.calls).toHaveLength(1); + await fixture.lifecycle.stop(); + expect(replacement.disposeCount).toBe(1); + }); + it("contains child WebContents event failures", async () => { + let emit: Parameters>[0]["emit"] | undefined; + let browserCreates = 0; + const fixture = setup(undefined, async () => true, { + createBrowserRuntime: (options) => { + browserCreates += 1; + emit = options.emit; + return new FakeBrowserRuntime() as never; + }, + }); + await fixture.lifecycle.start(); + fixture.registries[0]!.emitBrowserEvent = () => { + throw new Error("child browser event failed"); + }; + + expect(emit).toBeDefined(); + expect(() => emit?.({} as never)).not.toThrow(); + const window = fixture.windows[0]!; + window.emit("did-start-loading"); + expect(browserCreates).toBe(2); + await fixture.lifecycle.stop(); + }); + it("keeps the replacement browser IPC target after an earlier runtime finishes disposing", async () => { + const disposals: Array<() => void> = []; + const browserRuntimes: FakeBrowserRuntime[] = []; + const fixture = setup(undefined, async () => true, { + createBrowserRuntime: () => { + const runtime = new FakeBrowserRuntime(); + runtime.dispose = () => { + runtime.disposeCount += 1; + return new Promise((resolve) => { disposals.push(resolve); }); + }; + browserRuntimes.push(runtime); + return runtime as never; + }, + }); + await fixture.lifecycle.start(); + const window = fixture.windows[0]!; + window.emit("did-start-loading"); + const replacement = browserRuntimes[1]!; + const browserCall = fixture.ipc.handlers.get("browser:call") as ( + event: unknown, + payload: unknown, + ) => Promise; + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }; + const payload = { + channel: "browser:call", + payload: { version: 1, method: "surface.list", request: {} }, + }; + + disposals.shift()?.(); + await Promise.resolve(); + const result = await browserCall(event, payload); + expect(result).toEqual({ surfaces: [] }); + expect(replacement.calls).toHaveLength(1); + + const stopping = fixture.lifecycle.stop(); + disposals.shift()?.(); + await stopping; + expect(replacement.disposeCount).toBe(1); + }); + it("keeps close cleanup callable until a deferred browser disposal settles and protects a reopened target", async () => { + const disposals: Array<() => void> = []; + const browserRuntimes: FakeBrowserRuntime[] = []; + const fixture = setup(undefined, async () => true, { + createBrowserRuntime: () => { + const runtime = new FakeBrowserRuntime(); + runtime.dispose = () => { + runtime.disposeCount += 1; + return new Promise((resolve) => { disposals.push(resolve); }); + }; + browserRuntimes.push(runtime); + return runtime as never; + }, + }); + await fixture.lifecycle.start(); + const first = fixture.windows[0]!; + const browserCall = fixture.ipc.handlers.get("browser:call") as ( + event: unknown, + payload: unknown, + ) => Promise; + const stopSpeaking = fixture.ipc.handlers.get("omp:speech:stop") as ( + event: unknown, + payload: unknown, + ) => Promise; + const callPayload = { + channel: "browser:call", + payload: { version: 1, method: "surface.list", request: {} }, + }; + const firstEvent = { sender: first.webContents, senderFrame: first.webContents.mainFrame }; + + first.close(); + + expect(fixture.ipc.handlers.get("browser:call")).toBe(browserCall); + expect(fixture.ipc.handlers.get("omp:speech:stop")).toBe(stopSpeaking); + let unavailableError: unknown; + try { + await browserCall(firstEvent, callPayload); + } catch (error) { + unavailableError = error; + } + if ( + typeof unavailableError !== "object" || + unavailableError === null || + !("code" in unavailableError) || + !("message" in unavailableError) + ) throw unavailableError; + expect(unavailableError.code).toBe("invalid_state"); + expect(unavailableError.message).toBe("Browser runtime is unavailable"); + expect(await stopSpeaking(firstEvent, { + channel: "omp:speech:stop", + payload: {}, + })).toEqual({ accepted: true }); + + fixture.app.listeners.get("activate")?.(); + const second = fixture.windows[1]!; + const reopenedCall = fixture.ipc.handlers.get("browser:call") as ( + event: unknown, + payload: unknown, + ) => Promise; + const secondEvent = { sender: second.webContents, senderFrame: second.webContents.mainFrame }; + expect(reopenedCall).not.toBe(browserCall); + expect(await reopenedCall(secondEvent, callPayload)).toEqual({ surfaces: [] }); + expect(browserRuntimes[1]?.calls).toHaveLength(1); + + disposals.shift()?.(); + await Promise.resolve(); + expect(await reopenedCall(secondEvent, callPayload)).toEqual({ surfaces: [] }); + expect(browserRuntimes[1]?.calls).toHaveLength(2); + + const stopping = fixture.lifecycle.stop(); + disposals.shift()?.(); + await stopping; + }); + it("keeps browser and speech handlers installed during stop, then removes them exactly once", async () => { + const fixture = setup(); + await fixture.lifecycle.start(); + const window = fixture.windows[0]!; + const browserCall = fixture.ipc.handlers.get("browser:call") as ( + event: unknown, + payload: unknown, + ) => Promise; + const stopSpeaking = fixture.ipc.handlers.get("omp:speech:stop") as ( + event: unknown, + payload: unknown, + ) => Promise; + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }; + const stopping = fixture.lifecycle.stop(); + + expect(fixture.ipc.handlers.get("browser:call")).toBe(browserCall); + expect(fixture.ipc.handlers.get("omp:speech:stop")).toBe(stopSpeaking); + let unavailableError: unknown; + try { + await browserCall(event, { + channel: "browser:call", + payload: { version: 1, method: "surface.list", request: {} }, + }); + } catch (error) { + unavailableError = error; + } + if (typeof unavailableError !== "object" || unavailableError === null || !("code" in unavailableError)) { + throw unavailableError; + } + expect(unavailableError.code).toBe("invalid_state"); + expect(await stopSpeaking(event, { + channel: "omp:speech:stop", + payload: {}, + })).toEqual({ accepted: true }); + + await stopping; + expect(fixture.ipc.handlers.get("browser:call")).toBeUndefined(); + expect(fixture.ipc.handlers.get("omp:speech:stop")).toBeUndefined(); + const browserRemovals = fixture.ipc.removals.get("browser:call"); + const speechRemovals = fixture.ipc.removals.get("omp:speech:stop"); + await fixture.lifecycle.stop(); + expect(fixture.ipc.removals.get("browser:call")).toBe(browserRemovals); + expect(fixture.ipc.removals.get("omp:speech:stop")).toBe(speechRemovals); + }); + it("does not recreate a browser runtime when a load completes during shutdown", async () => { + let browserCreates = 0; + let disposeStarts = 0; + let resolveDispose: (() => void) | undefined; + const fixture = setup(undefined, async () => true, { + createBrowserRuntime: () => { + browserCreates += 1; + return { + dispose: () => { + disposeStarts += 1; + return new Promise((resolve) => { resolveDispose = resolve; }); + }, + } as never; + }, + }); + await fixture.lifecycle.start(); + const stopping = fixture.lifecycle.stop(); + expect(disposeStarts).toBe(1); + fixture.windows[0]!.finishLoad(); + expect(browserCreates).toBe(1); + resolveDispose?.(); + await stopping; }); it("creates one projection cache after readiness and injects it into every IPC binding", async () => { const cache = { diff --git a/apps/desktop/test/main-runtime.test.ts b/apps/desktop/test/main-runtime.test.ts new file mode 100644 index 0000000..bb60e8b --- /dev/null +++ b/apps/desktop/test/main-runtime.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +type VitestMockApi = { + readonly vi: { + mock(moduleName: string, factory: () => unknown): void; + }; +}; + +class MockDesktopLifecycle { + start(): Promise { + return Promise.resolve(); + } +} + +class MockElectronApp { + on(): this { + return this; + } + + removeListener(): this { + return this; + } + + quit(): void {} +} + +const vitest = await import("vitest") as unknown as VitestMockApi; +vitest.vi.mock("electron", () => ({ app: new MockElectronApp() })); +vitest.vi.mock("../src/lifecycle.ts", () => ({ DesktopLifecycle: MockDesktopLifecycle })); + +// Main bootstraps at module evaluation, so load it only after mocking native Electron. +const { bootstrapDesktopMain } = await import("../src/main.ts"); + +type ProcessEvent = "uncaughtException" | "unhandledRejection"; +type ProcessListener = (reason: unknown) => void; + +class FakeProcess { + readonly platform = "linux"; + private readonly listeners = new Map>(); + + on(event: ProcessEvent, listener: ProcessListener): this { + let eventListeners = this.listeners.get(event); + if (eventListeners === undefined) { + eventListeners = new Set(); + this.listeners.set(event, eventListeners); + } + eventListeners.add(listener); + return this; + } + + removeListener(event: ProcessEvent, listener: ProcessListener): this { + this.listeners.get(event)?.delete(listener); + return this; + } + + emit(event: ProcessEvent, reason: unknown): void { + for (const listener of this.listeners.get(event) ?? []) listener(reason); + } + + listenerCount(event: ProcessEvent): number { + return this.listeners.get(event)?.size ?? 0; + } +} + +class FakeApp { + quitCalls = 0; + private readonly listeners = new Set<() => void>(); + + on(_event: "window-all-closed", listener: () => void): this { + this.listeners.add(listener); + return this; + } + + removeListener(_event: "window-all-closed", listener: () => void): this { + this.listeners.delete(listener); + return this; + } + + quit(): void { + this.quitCalls += 1; + } +} + +function runtime(start: () => Promise = () => Promise.resolve()): { + readonly app: FakeApp; + readonly process: FakeProcess; + readonly reports: string[]; + readonly lifecycle: { start(): Promise }; +} { + return { + app: new FakeApp(), + process: new FakeProcess(), + reports: [], + lifecycle: { start }, + }; +} + +describe("main runtime failure policy", () => { + it("reports bounded runtime rejections without quitting or duplicating listeners", async () => { + const harness = runtime(); + await bootstrapDesktopMain({ ...harness, report: (message) => harness.reports.push(message) }); + + for (let index = 0; index < 12; index += 1) { + harness.process.emit("unhandledRejection", new Error(`browser failure ${index}`)); + } + + expect(harness.app.quitCalls).toBe(0); + expect(harness.reports).toHaveLength(10); + expect(harness.reports.at(-1)).toContain("further runtime rejections suppressed"); + + await bootstrapDesktopMain({ ...harness, report: (message) => harness.reports.push(message) }); + expect(harness.process.listenerCount("unhandledRejection")).toBe(1); + expect(harness.process.listenerCount("uncaughtException")).toBe(1); + }); + + it("quits once after a fatal lifecycle startup failure", async () => { + const harness = runtime(async () => { + throw new Error("startup failed"); + }); + + await bootstrapDesktopMain({ ...harness, report: (message) => harness.reports.push(message) }); + + expect(harness.app.quitCalls).toBe(1); + expect(harness.reports).toEqual(["[desktop] fatal startup failure: Error: startup failed"]); + }); + + it("keeps uncaught main exceptions fatal", async () => { + const harness = runtime(); + await bootstrapDesktopMain({ ...harness, report: (message) => harness.reports.push(message) }); + + harness.process.emit("uncaughtException", new Error("main invariant violated")); + harness.process.emit("uncaughtException", new Error("another invariant violated")); + + expect(harness.app.quitCalls).toBe(1); + expect(harness.reports).toEqual([ + "[desktop] fatal main exception: Error: main invariant violated", + "[desktop] fatal main exception: Error: another invariant violated", + ]); + }); +}); diff --git a/apps/desktop/test/start-electron.test.ts b/apps/desktop/test/start-electron.test.ts new file mode 100644 index 0000000..98f160b --- /dev/null +++ b/apps/desktop/test/start-electron.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +const launcherUrl = new URL("../scripts/start-electron.mjs", import.meta.url); + +function loadLauncher() { + return import(`${launcherUrl.href}?test=${crypto.randomUUID()}`); +} + +describe("Electron launcher", () => { + it("removes Electron's Node mode without mutating the source environment", async () => { + const { sanitizeEnvironment } = await loadLauncher(); + const environment = { ELECTRON_RUN_AS_NODE: "1", KEEP: "value" }; + + expect(sanitizeEnvironment(environment)).toEqual({ KEEP: "value" }); + expect(environment).toEqual({ ELECTRON_RUN_AS_NODE: "1", KEEP: "value" }); + }); + + it("accepts only loopback HTTP renderer URLs", async () => { + const { validateLoopbackRendererUrl } = await loadLauncher(); + + expect(validateLoopbackRendererUrl("http://127.0.0.1:5173/")?.origin).toBe("http://127.0.0.1:5173"); + expect(validateLoopbackRendererUrl("https://localhost:5173/")?.hostname).toBe("localhost"); + expect(validateLoopbackRendererUrl("http://[::1]:5173/")?.hostname).toBe("[::1]"); + expect(validateLoopbackRendererUrl(undefined)).toBeUndefined(); + + for (const value of ["ftp://localhost", "http://example.com", "not a URL"]) { + expect(() => validateLoopbackRendererUrl(value)).toThrow("OMP_DESKTOP_RENDERER_URL must be a loopback HTTP URL"); + } + }); + + it("waits until the configured renderer responds successfully", async () => { + const { waitForRenderer } = await loadLauncher(); + const requests: Array<{ url: URL; method?: string }> = []; + + await waitForRenderer("http://127.0.0.1:5173/", { + fetchImpl: async (url: URL, init: RequestInit) => { + requests.push({ url, method: init.method ?? "" }); + return { ok: true }; + }, + sleep: async () => { + throw new Error("should not sleep after a successful response"); + }, + }); + + expect(requests).toEqual([{ url: new URL("http://127.0.0.1:5173/"), method: "HEAD" }]); + }); + + it("times out renderer readiness using injected clocks and sleeps", async () => { + const { waitForRenderer } = await loadLauncher(); + let time = 0; + let requests = 0; + + await expect(waitForRenderer("http://localhost:5173/", { + fetchImpl: async () => { + requests += 1; + return { ok: false }; + }, + now: () => time, + sleep: async (milliseconds: number) => { + time += milliseconds; + }, + timeoutMs: 200, + intervalMs: 100, + })).rejects.toThrow("Renderer did not become ready at http://localhost:5173"); + + expect(requests).toBe(3); + }); + + it("does not run the launcher when the module is imported", async () => { + const originalRendererUrl = process.env.OMP_DESKTOP_RENDERER_URL; + process.env.OMP_DESKTOP_RENDERER_URL = "not a URL"; + + try { + const launcher = await loadLauncher(); + expect(typeof launcher.main).toBe("function"); + expect(typeof launcher.startElectron).toBe("function"); + } finally { + if (originalRendererUrl === undefined) delete process.env.OMP_DESKTOP_RENDERER_URL; + else process.env.OMP_DESKTOP_RENDERER_URL = originalRendererUrl; + } + }); +}); diff --git a/apps/desktop/vite.preload.config.ts b/apps/desktop/vite.preload.config.ts index e30d0d3..a67c28e 100644 --- a/apps/desktop/vite.preload.config.ts +++ b/apps/desktop/vite.preload.config.ts @@ -14,10 +14,16 @@ export default defineConfig({ build: { outDir: "dist-electron", emptyOutDir: false, - lib: { entry: "src/preload.ts", formats: ["cjs"] }, + lib: { + entry: { + preload: "src/preload.ts", + "browser-content-preload": "src/browser-content-preload.ts", + }, + formats: ["cjs"], + }, rollupOptions: { external: (id) => external.has(id) || id.startsWith("node:"), - output: { entryFileNames: "preload.cjs", codeSplitting: false }, + output: { entryFileNames: "[name].cjs", chunkFileNames: "preload-[name]-[hash].cjs", codeSplitting: true }, }, }, }); diff --git a/apps/web/src/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx index 3cdf51d..09e7cdd 100644 --- a/apps/web/src/components/SessionScreen.tsx +++ b/apps/web/src/components/SessionScreen.tsx @@ -349,6 +349,16 @@ export function SessionScreen({ Preview{previewCount === 1 ? "" : ` · ${previewCount}`} )} + {rendererPlatform.browser !== null && ( + + Browser + + )} {!archived && !focusMode && ( 0) return surface.title; + if (surface.url === "about:blank" || surface.url.trim().length === 0) return "New tab"; + try { + return new URL(surface.url).hostname || surface.url; + } catch { + return "Browser tab"; + } +} + +function updateSurfaceResult( + model: BrowserWorkspaceModel, + value: unknown, + preferred?: SurfaceId | null, +): BrowserWorkspaceModel { + const surface = surfaceFromBrowserResult(value); + return surface === null + ? model + : reconcileBrowserSurfaces(model, [surface], preferred ?? surface.surfaceId); +} +function discardBrowserWorkspaceCall(call: Promise): void { + void call.catch(() => undefined); +} + +export function settleBrowserWorkspaceCall( + call: Promise, + isCurrent: () => boolean, + onFulfilled: (value: T) => void, + onRejected: (error: unknown) => void, +): Promise { + return call + .then( + (value) => { + if (isCurrent()) onFulfilled(value); + }, + (error: unknown) => { + if (isCurrent()) onRejected(error); + }, + ) + .catch(() => undefined); +} + + +function BrowserUnsupported({ + session, + project, +}: { + readonly session: WorkspaceSession; + readonly project: WorkspaceProject; +}) { + const navigate = useNavigate(); + return ( +
+ + + + + + Native browser unavailable + + Browser workspaces require the T4 Code desktop app. This runtime cannot embed a + native browser view, but the existing host-backed Preview workspace is still + available from the session. + + + + + + +
+ ); +} + +function BrowserHeader({ + session, + project, + status, +}: { + readonly session: WorkspaceSession; + readonly project: WorkspaceProject; + readonly status: string; +}) { + const navigate = useNavigate(); + return ( +
+ +
+

Browser

+

{session.title}

+
+ + {session.title} · {project.name} + + + {session.status === null ? ( + + {session.freshness} + + ) : ( + + )} + + {status} + +
+ ); +} + +export function BrowserWorkspace({ + session, + project, +}: { + readonly session: WorkspaceSession; + readonly project: WorkspaceProject; +}) { + const port = rendererPlatform.browser; + const callBrowser = useCallback( + (method: BrowserMethod, request: Readonly>) => + browserCall(method, request, session.id), + [session.id], + ); + const rememberedSurfaceId = useWorkspace( + (state) => selectSessionView(state, session.id).browserSurfaceId, + ); + const [model, setModel] = useState(initialBrowserWorkspaceModel); + const [profiles, setProfiles] = useState([ + { profileId: "isolated-session", label: "OMP session", kind: "isolated-session" }, + ]); + const [selectedProfile, setSelectedProfile] = useState( + ISOLATED_BROWSER_PROFILE, + ); + const [pendingTrust, setPendingTrust] = useState(null); + const [address, setAddress] = useState(""); + const [actionError, setActionError] = useState(null); + const [busyAction, setBusyAction] = useState(null); + const [automationOpen, setAutomationOpen] = useState(false); + const [automationResult, setAutomationResult] = useState("Run an automation action to inspect its result."); + const [expression, setExpression] = useState("document.title"); + const [designPrompt, setDesignPrompt] = useState(""); + const [designMode, setDesignMode] = useState(false); + const [focusMode, setFocusMode] = useState(false); + const [devtoolsOpen, setDevtoolsOpen] = useState(false); + const [zoomBySurface, setZoomBySurface] = useState>>({}); + const modelRef = useRef(model); + const lifecycleRef = useRef(0); + const boundsLifecycleRef = useRef(0); + const viewportRef = useRef(null); + const lastBoundsRef = useRef(null); + const confirmedProfileIdRef = useRef(null); + const rememberedSurfaceIdRef = useRef(rememberedSurfaceId); + rememberedSurfaceIdRef.current = rememberedSurfaceId; + + const commitModel = useCallback( + (update: (current: BrowserWorkspaceModel) => BrowserWorkspaceModel) => { + setModel((current) => { + const next = update(current); + modelRef.current = next; + return next; + }); + }, + [], + ); + + useEffect(() => { + modelRef.current = model; + }, [model]); + + useEffect(() => { + if (port === null) return; + const generation = ++lifecycleRef.current; + let subscribed = true; + modelRef.current = initialBrowserWorkspaceModel(); + setModel(modelRef.current); + setSelectedProfile(ISOLATED_BROWSER_PROFILE); + confirmedProfileIdRef.current = null; + setPendingTrust(null); + setActionError(null); + workspaceStore.getState().setSessionBrowserProfile(session.id, null); + + const unsubscribe = port.subscribe((event) => { + if (!subscribed || lifecycleRef.current !== generation || event.ownerSessionId !== session.id) return; + if ( + event.type === "state" && + event.surface.profile.kind === "authenticated-profile" && + confirmedProfileIdRef.current !== event.surface.profile.profileId + ) { + commitModel((current) => + applyBrowserEvent(current, { + ...event, + surface: { ...event.surface, visible: false, focused: "none" }, + }), + ); + if (event.surface.visible) { + discardBrowserWorkspaceCall( + port.call( + callBrowser("surface.setBounds", { + surfaceId: event.surface.surfaceId, + bounds: event.surface.bounds, + visible: false, + }), + ), + ); + } + return; + } + commitModel((current) => applyBrowserEvent(current, event)); + if (event.type === "error") setActionError(safeBrowserActionError(event.error)); + }); + + settleBrowserWorkspaceCall( + port.call(callBrowser("browser.profiles.list", {})), + () => subscribed && lifecycleRef.current === generation, + (result) => setProfiles(profileOptionsFromBrowserResult(result)), + (error) => setActionError(safeBrowserActionError(error)), + ); + + settleBrowserWorkspaceCall( + port.call(callBrowser("surface.list", {})), + () => subscribed && lifecycleRef.current === generation, + (result) => { + const surfaces = surfacesFromBrowserResult(result); + const remembered = surfaces.find( + (surface) => + surface.surfaceId === rememberedSurfaceIdRef.current && + surface.lifecycle !== "closed" && + surface.profile.kind === "isolated-session", + ); + const safeSurface = + remembered ?? + surfaces.find( + (surface) => + surface.visible && + surface.lifecycle !== "closed" && + surface.profile.kind === "isolated-session", + ) ?? + surfaces.find( + (surface) => + surface.lifecycle !== "closed" && surface.profile.kind === "isolated-session", + ); + const selectedId = safeSurface?.surfaceId ?? null; + commitModel((current) => reconcileBrowserSurfaces(current, surfaces, selectedId)); + workspaceStore.getState().setSessionBrowserSurface(session.id, selectedId); + + // A restored authenticated surface is host state, not fresh consent. Keep it hidden + // until the user explicitly confirms that exact profile in this workspace mount. + for (const surface of surfaces) { + if (surface.visible && surface.profile.kind === "authenticated-profile") { + discardBrowserWorkspaceCall( + port.call( + callBrowser("surface.setBounds", { + surfaceId: surface.surfaceId, + bounds: surface.bounds, + visible: false, + }), + ), + ); + } + } + }, + (error) => setActionError(safeBrowserActionError(error)), + ); + + return () => { + lifecycleRef.current += 1; + subscribed = false; + confirmedProfileIdRef.current = null; + unsubscribe(); + workspaceStore.getState().setSessionBrowserProfile(session.id, null); + }; + }, [callBrowser, commitModel, port, session.id]); + + const surfaces = liveBrowserSurfaces(model); + const activeSurface = + surfaces.find((surface) => surface.surfaceId === model.activeSurfaceId) ?? null; + + useEffect(() => { + setAddress(activeSurface?.url === "about:blank" ? "" : (activeSurface?.url ?? "")); + }, [activeSurface?.surfaceId, activeSurface?.url]); + + useEffect(() => { + if (port === null || activeSurface === null) return; + const generation = lifecycleRef.current; + const surfaceId = activeSurface.surfaceId; + let disposed = false; + settleBrowserWorkspaceCall( + Promise.all([ + port.call(callBrowser("surface.downloads", { surfaceId })), + port.call(callBrowser("browser.console.list", { surfaceId })), + port.call(callBrowser("browser.errors.list", { surfaceId })), + ]), + () => + !disposed && + lifecycleRef.current === generation && + modelRef.current.activeSurfaceId === surfaceId, + ([downloadResult, consoleResult, errorResult]) => { + const downloads = downloadsFromBrowserResult(downloadResult); + const consoleMessages = consoleFromBrowserResult(consoleResult); + const runtimeErrors = errorsFromBrowserResult(errorResult); + commitModel((current) => ({ + ...current, + downloads: [ + ...current.downloads.filter((entry) => entry.surfaceId !== surfaceId), + ...downloads, + ].slice(-64), + consoleMessages: [ + ...current.consoleMessages.filter((entry) => entry.surfaceId !== surfaceId), + ...consoleMessages, + ].slice(-100), + runtimeErrors: [ + ...current.runtimeErrors.filter((entry) => entry.surfaceId !== surfaceId), + ...runtimeErrors, + ].slice(-50), + })); + }, + (error) => setActionError(safeBrowserActionError(error)), + ); + return () => { + disposed = true; + }; + }, [activeSurface?.surfaceId, callBrowser, commitModel, port]); + + useEffect(() => { + if (port === null || activeSurface === null || viewportRef.current === null) return; + const generation = lifecycleRef.current; + const boundsGeneration = ++boundsLifecycleRef.current; + const surfaceId = activeSurface.surfaceId; + const element = viewportRef.current; + let frame = 0; + let disposed = false; + + const isCurrent = () => + !disposed && + lifecycleRef.current === generation && + boundsLifecycleRef.current === boundsGeneration; + const sendBounds = (visible: boolean) => { + if (!isCurrent()) return; + const measured = nativeBoundsFromRect(element.getBoundingClientRect(), { + width: window.innerWidth, + height: window.innerHeight, + }); + const bounds = measured ?? lastBoundsRef.current ?? activeSurface.bounds; + lastBoundsRef.current = bounds; + settleBrowserWorkspaceCall( + port.call( + callBrowser("surface.setBounds", { surfaceId, bounds, visible: visible && measured !== null }), + ), + isCurrent, + () => undefined, + (error) => setActionError(safeBrowserActionError(error)), + ); + }; + const scheduleBounds = () => { + if (frame !== 0) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = 0; + sendBounds(!document.hidden); + }); + }; + const onVisibilityChange = () => sendBounds(!document.hidden); + const observer = new ResizeObserver(scheduleBounds); + observer.observe(element); + window.addEventListener("resize", scheduleBounds); + window.addEventListener("scroll", scheduleBounds, true); + document.addEventListener("visibilitychange", onVisibilityChange); + scheduleBounds(); + + return () => { + boundsLifecycleRef.current += 1; + disposed = true; + if (frame !== 0) cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener("resize", scheduleBounds); + window.removeEventListener("scroll", scheduleBounds, true); + document.removeEventListener("visibilitychange", onVisibilityChange); + const bounds = lastBoundsRef.current ?? activeSurface.bounds; + discardBrowserWorkspaceCall( + port.call(callBrowser("surface.setBounds", { surfaceId, bounds, visible: false })), + ); + }; + }, [activeSurface?.surfaceId, callBrowser, port]); + + const runAction = useCallback( + async ( + label: string, + method: BrowserMethod, + request: Readonly>, + ): Promise => { + if (port === null) return null; + const generation = lifecycleRef.current; + setBusyAction(label); + setActionError(null); + try { + const result = await port.call(callBrowser(method, request)); + if (lifecycleRef.current !== generation) return null; + const surface = surfaceFromBrowserResult(result); + if (surface !== null) { + commitModel((current) => + reconcileBrowserSurfaces(current, [surface], surface.surfaceId), + ); + } + return result; + } catch (error) { + if (lifecycleRef.current === generation) setActionError(safeBrowserActionError(error)); + return null; + } finally { + if (lifecycleRef.current === generation) setBusyAction(null); + } + }, + [callBrowser, commitModel, port], + ); + + const switchSurface = useCallback( + async (surface: BrowserSurfaceState) => { + const result = await runAction("Switching tab", "browser.tab.switch", { + surfaceId: surface.surfaceId, + }); + if (result === null) return; + commitModel((current) => selectBrowserSurface(current, surface.surfaceId)); + workspaceStore.getState().setSessionBrowserSurface(session.id, surface.surfaceId); + }, + [commitModel, runAction, session.id], + ); + + const activateSurface = useCallback( + async (surface: BrowserSurfaceState) => { + if ( + surface.profile.kind === "authenticated-profile" && + selectedProfile.profileId !== surface.profile.profileId + ) { + const option = profiles.find((entry) => entry.profileId === surface.profile.profileId) ?? { + profileId: surface.profile.profileId, + label: "Authenticated profile", + kind: "authenticated-profile" as const, + }; + setPendingTrust({ option, surface }); + return; + } + await switchSurface(surface); + }, + [profiles, selectedProfile.profileId, switchSurface], + ); + + const createTab = useCallback(async () => { + const bounds = lastBoundsRef.current ?? { x: 0, y: 0, width: 800, height: 600 }; + const result = await runAction("Opening tab", "surface.create", { + profile: selectedProfile, + url: "about:blank", + bounds, + visible: true, + }); + if (result === null) return; + const surface = surfaceFromBrowserResult(result); + if (surface === null) return; + commitModel((current) => updateSurfaceResult(current, result, surface.surfaceId)); + workspaceStore.getState().setSessionBrowserSurface(session.id, surface.surfaceId); + }, [commitModel, runAction, selectedProfile, session.id]); + + const closeSurface = useCallback( + async (surface: BrowserSurfaceState) => { + const index = surfaces.findIndex((entry) => entry.surfaceId === surface.surfaceId); + const fallback = surfaces[index + 1] ?? surfaces[index - 1] ?? null; + const result = await runAction("Closing tab", "surface.close", { + surfaceId: surface.surfaceId, + }); + if (result === null) return; + commitModel((current) => updateSurfaceResult(current, result, fallback?.surfaceId ?? null)); + if (fallback === null) { + workspaceStore.getState().setSessionBrowserSurface(session.id, null); + } else { + await activateSurface(fallback); + } + }, + [activateSurface, commitModel, runAction, session.id, surfaces], + ); + + const navigate = async (event: FormEvent) => { + event.preventDefault(); + if (activeSurface === null) return; + let url: string; + try { + url = normalizeBrowserAddress(address); + } catch (error) { + setActionError(safeBrowserActionError(error)); + return; + } + await runAction("Navigating", "surface.navigate", { + surfaceId: activeSurface.surfaceId, + url, + }); + }; + + const runAutomation = async ( + label: string, + method: BrowserMethod, + request: Readonly>, + ): Promise => { + if (activeSurface === null) return null; + const generation = lifecycleRef.current; + const surfaceId = activeSurface.surfaceId; + const result = await runAction(label, method, { + surfaceId, + ...request, + }); + if ( + result === null || + lifecycleRef.current !== generation || + modelRef.current.activeSurfaceId !== surfaceId + ) { + return null; + } + setAutomationResult(formatBrowserResult(result)); + return result; + }; + + const setZoom = async (next: number) => { + if (activeSurface === null) return; + const generation = lifecycleRef.current; + const surfaceId = activeSurface.surfaceId; + const zoom = Math.max(0.25, Math.min(5, Math.round(next * 4) / 4)); + const result = await runAction("Changing zoom", "browser.zoom.set", { + surfaceId, + zoom, + }); + if ( + result !== null && + lifecycleRef.current === generation && + modelRef.current.activeSurfaceId === surfaceId + ) { + setZoomBySurface((current) => ({ ...current, [surfaceId]: zoom })); + } + }; + + const activeDownloads = useMemo( + () => model.downloads.filter((entry) => entry.surfaceId === activeSurface?.surfaceId), + [activeSurface?.surfaceId, model.downloads], + ); + const activeConsole = useMemo( + () => model.consoleMessages.filter((entry) => entry.surfaceId === activeSurface?.surfaceId), + [activeSurface?.surfaceId, model.consoleMessages], + ); + const activeErrors = useMemo( + () => model.runtimeErrors.filter((entry) => entry.surfaceId === activeSurface?.surfaceId), + [activeSurface?.surfaceId, model.runtimeErrors], + ); + const currentZoom = activeSurface === null ? 1 : (zoomBySurface[activeSurface.surfaceId] ?? 1); + + if (port === null) return ; + + return ( +
+ + +
+
) => { + if (surfaces.length === 0) return; + const currentIndex = surfaces.findIndex( + (surface) => surface.surfaceId === activeSurface?.surfaceId, + ); + let nextIndex: number | null = null; + if (event.key === "ArrowRight") nextIndex = (currentIndex + 1) % surfaces.length; + else if (event.key === "ArrowLeft") { + nextIndex = (currentIndex - 1 + surfaces.length) % surfaces.length; + } else if (event.key === "Home") nextIndex = 0; + else if (event.key === "End") nextIndex = surfaces.length - 1; + if (nextIndex === null) return; + event.preventDefault(); + const next = surfaces[nextIndex]; + if (next === undefined) return; + void activateSurface(next) + .then(() => { + document + .querySelector(`[data-browser-tab="${next.surfaceId}"]`) + ?.focus(); + }) + .catch(() => undefined); + }} + role="tablist" + > + {surfaces.map((surface) => { + const active = surface.surfaceId === activeSurface?.surfaceId; + return ( +
+ + void closeSurface(surface)} + size="icon-xs" + > + +
+ ); + })} +
+ void createTab()} + size="icon-sm" + variant="ghost" + > + +
+ +
+
+ + activeSurface === null + ? undefined + : void runAction("Going back", "surface.goBack", { + surfaceId: activeSurface.surfaceId, + }) + } + size="icon-lg" + > + + + activeSurface === null + ? undefined + : void runAction("Going forward", "surface.goForward", { + surfaceId: activeSurface.surfaceId, + }) + } + size="icon-lg" + > + + + activeSurface === null + ? undefined + : void runAction( + activeSurface.loading ? "Stopping" : "Reloading", + activeSurface.loading ? "surface.stop" : "surface.reload", + { surfaceId: activeSurface.surfaceId }, + ) + } + size="icon-lg" + > + {activeSurface?.loading ? +
+ +
void navigate(event)}> + + setAddress(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + placeholder="Search or enter address" + spellCheck={false} + type="text" + value={address} + /> + +
+ +
+ + + + {browserProfileTrustLabel(selectedProfile)} + +
+
+ +
+
+
+ {activeSurface === null ? ( +
+
+ ) : ( + Native browser content is displayed in this area. + )} +
+
+ + {automationOpen && ( +