| 1 | import type { IpcMain, IpcMainEvent, IpcMainInvokeEvent } from "electron"; |
| 2 | import { |
| 3 | IPC, |
| 4 | type BrowserLayoutRect, |
| 5 | type BrowserNavigateTarget, |
| 6 | type BrowserOpenOptions, |
| 7 | type BrowserTabView, |
| 8 | type IpcResult, |
| 9 | type ServiceState, |
| 10 | type WindowBounds, |
| 11 | type WindowTheme, |
| 12 | } from "../shared/ipc.js"; |
| 13 | import { isAllowedCommand, type LoadedContract } from "./contract.js"; |
| 14 | import { errorText, type Logger } from "./log.js"; |
| 15 | import { bool, finite, record, str, type Params } from "./params.js"; |
| 16 | import { RpcError } from "./rpc.js"; |
| 17 | import type { GraphicsSettingsStore } from "./graphics.js"; |
| 18 | import type { BrowserControlApi } from "./browserControlHost.js"; |
| 19 | import type { PerformanceHost } from "./performanceHost.js"; |
| 20 | |
| 21 | export interface RendererWindowApi { |
| 22 | isTrustedSender(sender: IpcMainEvent["sender"], frame: IpcMainEvent["senderFrame"]): boolean; |
| 23 | minimise(): void; |
| 24 | toggleMaximise(): void; |
| 25 | isMaximised(): boolean; |
| 26 | close(): void; |
| 27 | bounds(): WindowBounds; |
| 28 | setTheme(theme: WindowTheme): void; |
| 29 | setBackgroundColour(r: number, g: number, b: number, a: number): void; |
| 30 | getAppZoom(): Promise<number>; |
| 31 | setAppZoom(factor: number): Promise<number>; |
| 32 | resetAppZoom(): Promise<number>; |
| 33 | } |
| 34 | |
| 35 | // The user-driven browser panel: no grant is involved because the user is |
| 36 | // the one acting, but every call is still gated on the trusted sender. |
| 37 | export interface BrowserRendererApi { |
| 38 | list(): BrowserTabView[]; |
| 39 | open(url: string, options: Required<BrowserOpenOptions>): Promise<BrowserTabView>; |
| 40 | close(tabId: string): void; |
| 41 | activate(tabId: string | null): void; |
| 42 | navigate(tabId: string, target: BrowserNavigateTarget): Promise<void>; |
| 43 | setZoom(tabId: string, factor: number): void; |
| 44 | toggleDevTools(tabId: string): void; |
| 45 | resume(tabId: string): void; |
| 46 | takeover(tabId: string): void; |
| 47 | setLayout(rect: BrowserLayoutRect | null): void; |
| 48 | setOverlay(active: boolean): void; |
| 49 | } |
| 50 | |
| 51 | export interface RendererIpcDeps { |
| 52 | ipcMain: IpcMain; |
| 53 | contract: LoadedContract; |
| 54 | window: RendererWindowApi; |
| 55 | invoke(method: string, args: unknown[]): Promise<unknown>; |
| 56 | serviceState(): ServiceState; |
| 57 | processDiagnostics?(): unknown; |
| 58 | performance?: PerformanceHost; |
| 59 | clipboard: { writeText(text: string): Promise<void> | void; readText(): Promise<string> | string }; |
| 60 | graphics?: GraphicsSettingsStore; |
| 61 | browserControl?: BrowserControlApi; |
| 62 | openExternal(url: string): Promise<void>; |
| 63 | browser?: BrowserRendererApi; |
| 64 | log: Logger; |
| 65 | } |
| 66 | |
| 67 | const NAVIGATE_ACTIONS = new Set(["back", "forward", "reload", "stop"]); |
| 68 | |
| 69 | function diagnosticRequestId(value: unknown): string | undefined { |
| 70 | if (value === undefined) return undefined; |
| 71 | if (typeof value !== "string" || !/^[a-zA-Z0-9-]{1,96}$/.test(value)) throw new Error("invalid diagnostic request identity"); |
| 72 | return value; |
| 73 | } |
| 74 | |
| 75 | const TRANSCRIPT_DIAGNOSTIC_EVENTS = new Set(["failure", "summary", "recovered", "stopped"]); |
| 76 | const TRANSCRIPT_DIAGNOSTIC_STAGES = new Set(["none", "baseline_read", "baseline_validate", "snapshot_install", "delta_read", "delta_validate", "delta_apply"]); |
| 77 | const TRANSCRIPT_DIAGNOSTIC_REASONS = new Set([ |
| 78 | "transport_rejected", "protocol_version", "snapshot_missing", "history_not_ready", "revision_regressed", |
| 79 | "revision_gap", "business_gap", "frame_cut_mismatch", "sampling_identity_missing", "sampling_gap", |
| 80 | "settlement_not_committed", "settlement_identity_mismatch", "resync_required", "consumer_error", |
| 81 | "service_stopping", "unknown", |
| 82 | ]); |
| 83 | const TRANSCRIPT_DIAGNOSTIC_TRANSPORTS = new Set(["local", "remote"]); |
| 84 | const TRANSCRIPT_DIAGNOSTIC_ERROR_TYPES = new Set(["classified", "error", "string", "object", "unknown"]); |
| 85 | const TRANSCRIPT_DIAGNOSTIC_KEYS = new Set([ |
| 86 | "kind", "event", "stage", "reason", "transport", "errorType", "revision", "commit", "attempts", "failures", "durationMs", |
| 87 | ]); |
| 88 | |
| 89 | type RendererTranscriptDiagnostic = { |
| 90 | kind: "transcript"; |
| 91 | event: string; |
| 92 | stage: string; |
| 93 | reason: string; |
| 94 | transport: string; |
| 95 | errorType: string; |
| 96 | revision: number; |
| 97 | commit: number; |
| 98 | attempts: number; |
| 99 | failures: number; |
| 100 | durationMs: number; |
| 101 | }; |
| 102 | |
| 103 | function diagnosticCount(input: Params, key: string): number { |
| 104 | const value = input[key]; |
| 105 | if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > 1_000_000_000_000) { |
| 106 | throw new Error(`invalid renderer diagnostic ${key}`); |
| 107 | } |
| 108 | return value as number; |
| 109 | } |
| 110 | |
| 111 | export function parseRendererDiagnostic(value: unknown): RendererTranscriptDiagnostic { |
| 112 | let encoded: string | undefined; |
| 113 | try { encoded = JSON.stringify(value); } catch { throw new Error("invalid renderer diagnostic payload"); } |
| 114 | if (typeof encoded !== "string") throw new Error("invalid renderer diagnostic payload"); |
| 115 | if (Buffer.byteLength(encoded, "utf8") > 2048) throw new Error("renderer diagnostic payload too large"); |
| 116 | const input = record(value); |
| 117 | if (Object.keys(input).some(key => !TRANSCRIPT_DIAGNOSTIC_KEYS.has(key))) throw new Error("invalid renderer diagnostic field"); |
| 118 | const event = str(input, "event"), stage = str(input, "stage"), reason = str(input, "reason"), transport = str(input, "transport"); |
| 119 | const errorType = str(input, "errorType"); |
| 120 | if (input.kind !== "transcript" || !TRANSCRIPT_DIAGNOSTIC_EVENTS.has(event) || !TRANSCRIPT_DIAGNOSTIC_STAGES.has(stage) |
| 121 | || !TRANSCRIPT_DIAGNOSTIC_REASONS.has(reason) || !TRANSCRIPT_DIAGNOSTIC_TRANSPORTS.has(transport) |
| 122 | || !TRANSCRIPT_DIAGNOSTIC_ERROR_TYPES.has(errorType)) { |
| 123 | throw new Error("invalid renderer diagnostic value"); |
| 124 | } |
| 125 | return { |
| 126 | kind: "transcript", event, stage, reason, transport, errorType, |
| 127 | revision: diagnosticCount(input, "revision"), |
| 128 | commit: diagnosticCount(input, "commit"), |
| 129 | attempts: diagnosticCount(input, "attempts"), |
| 130 | failures: diagnosticCount(input, "failures"), |
| 131 | durationMs: diagnosticCount(input, "durationMs"), |
| 132 | }; |
| 133 | } |
| 134 | |
| 135 | export function parseNavigateTarget(value: unknown): BrowserNavigateTarget { |
| 136 | const target = record(value); |
| 137 | const action = str(target, "action"); |
| 138 | if (NAVIGATE_ACTIONS.has(action)) return { action: action as BrowserNavigateTarget["action"] }; |
| 139 | return { url: str(target, "url") }; |
| 140 | } |
| 141 | |
| 142 | export function parseLayout(value: unknown): BrowserLayoutRect | null { |
| 143 | if (value === null || value === undefined) return null; |
| 144 | const rect = record(value); |
| 145 | return { x: finite(rect.x, Number.NaN), y: finite(rect.y, Number.NaN), width: finite(rect.width, Number.NaN), height: finite(rect.height, Number.NaN) }; |
| 146 | } |
| 147 | |
| 148 | const EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); |
| 149 | |
| 150 | export function isOpenableExternalURL(value: unknown): value is string { |
| 151 | if (typeof value !== "string") return false; |
| 152 | try { |
| 153 | return EXTERNAL_PROTOCOLS.has(new URL(value).protocol); |
| 154 | } catch { |
| 155 | return false; |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | export function registerRendererIpc(deps: RendererIpcDeps): void { |
| 160 | const trusted = (event: IpcMainEvent | IpcMainInvokeEvent): boolean => { |
| 161 | const ok = deps.window.isTrustedSender(event.sender, event.senderFrame); |
| 162 | if (!ok) deps.log.warn(`rejected IPC from untrusted sender (webContents ${event.sender.id})`); |
| 163 | return ok; |
| 164 | }; |
| 165 | const handle = (channel: string, run: (...args: unknown[]) => Promise<unknown> | unknown) => { |
| 166 | deps.ipcMain.handle(channel, async (event, ...args: unknown[]): Promise<IpcResult> => { |
| 167 | if (!trusted(event)) return { ok: false, message: "untrusted sender" }; |
| 168 | try { |
| 169 | return { ok: true, value: await run(...args) }; |
| 170 | } catch (error) { |
| 171 | return { ok: false, message: errorText(error) }; |
| 172 | } |
| 173 | }); |
| 174 | }; |
| 175 | let diagnosticWindowStartedAt = 0; |
| 176 | let diagnosticWindowCount = 0; |
| 177 | let diagnosticDropped = 0; |
| 178 | let diagnosticDropTimer: ReturnType<typeof setTimeout> | undefined; |
| 179 | const reportDiagnosticDrops = () => { |
| 180 | if (diagnosticDropped > 0) deps.log.warn(`renderer diagnostics rate limited dropped=${diagnosticDropped}`); |
| 181 | diagnosticDropped = 0; |
| 182 | diagnosticDropTimer = undefined; |
| 183 | }; |
| 184 | |
| 185 | deps.ipcMain.on(IPC.contract, (event) => { |
| 186 | event.returnValue = trusted(event) |
| 187 | ? { protocolVersion: deps.contract.protocolVersion, digest: deps.contract.digest, commands: [...deps.contract.commands] } |
| 188 | : null; |
| 189 | }); |
| 190 | |
| 191 | handle(IPC.invoke, (method, args) => { |
| 192 | if (!isAllowedCommand(deps.contract, method)) { |
| 193 | throw new RpcError(-32601, `-32601 method not found: ${typeof method === "string" ? method : typeof method}`); |
| 194 | } |
| 195 | // Older renderers reached native title-bar controls through generated Go |
| 196 | // bindings. Keep those command names compatible while the Electron shell |
| 197 | // owns window lifetime and can accept repeated close requests after the Go |
| 198 | // service has begun shutting down. |
| 199 | if (method === "MinimiseMainWindow") return deps.window.minimise(); |
| 200 | if (method === "ToggleMaximiseMainWindow") return deps.window.toggleMaximise(); |
| 201 | if (method === "IsMainWindowMaximised") return deps.window.isMaximised(); |
| 202 | if (method === "CloseMainWindow") return deps.window.close(); |
| 203 | return deps.invoke(method, Array.isArray(args) ? args : []); |
| 204 | }); |
| 205 | handle(IPC.serviceStateGet, () => deps.serviceState()); |
| 206 | handle(IPC.processDiagnostics, () => deps.processDiagnostics?.() ?? null); |
| 207 | handle(IPC.captureRendererProfile, (id) => deps.performance?.captureRendererProfile(diagnosticRequestId(id)) ?? { status: "unavailable" }); |
| 208 | handle(IPC.cancelRendererProfile, (id) => { |
| 209 | const requestId = diagnosticRequestId(id); |
| 210 | if (requestId) deps.performance?.cancelRendererProfile(requestId); |
| 211 | }); |
| 212 | handle(IPC.exportHeapSnapshot, () => deps.performance?.exportHeapSnapshot() ?? { status: "unavailable" }); |
| 213 | handle(IPC.rendererDiagnostic, (value) => { |
| 214 | const diagnostic = parseRendererDiagnostic(value); |
| 215 | const now = Date.now(); |
| 216 | if (now - diagnosticWindowStartedAt >= 1000) { |
| 217 | if (diagnosticDropTimer) clearTimeout(diagnosticDropTimer); |
| 218 | reportDiagnosticDrops(); |
| 219 | diagnosticWindowStartedAt = now; |
| 220 | diagnosticWindowCount = 0; |
| 221 | } |
| 222 | if (diagnosticWindowCount >= 10) { |
| 223 | diagnosticDropped++; |
| 224 | if (!diagnosticDropTimer) { |
| 225 | diagnosticDropTimer = setTimeout(reportDiagnosticDrops, 1000); |
| 226 | diagnosticDropTimer.unref(); |
| 227 | } |
| 228 | return; |
| 229 | } |
| 230 | diagnosticWindowCount++; |
| 231 | const service = deps.serviceState(); |
| 232 | deps.log.info( |
| 233 | `renderer transcript event=${diagnostic.event} stage=${diagnostic.stage} reason=${diagnostic.reason} type=${diagnostic.errorType} transport=${diagnostic.transport} revision=${diagnostic.revision} commit=${diagnostic.commit} attempts=${diagnostic.attempts} failures=${diagnostic.failures} duration_ms=${diagnostic.durationMs} service=${service.phase} generation=${service.generation}`, |
| 234 | ); |
| 235 | }); |
| 236 | handle(IPC.openExternal, (url) => { |
| 237 | if (!isOpenableExternalURL(url)) throw new Error(`refusing to open ${typeof url === "string" ? url : typeof url}`); |
| 238 | return deps.openExternal(url); |
| 239 | }); |
| 240 | handle(IPC.clipboardWrite, async (text) => { |
| 241 | await deps.clipboard.writeText(typeof text === "string" ? text : ""); |
| 242 | return true; |
| 243 | }); |
| 244 | handle(IPC.clipboardRead, () => deps.clipboard.readText()); |
| 245 | handle(IPC.windowMinimise, () => deps.window.minimise()); |
| 246 | handle(IPC.windowToggleMaximise, () => deps.window.toggleMaximise()); |
| 247 | handle(IPC.windowIsMaximised, () => deps.window.isMaximised()); |
| 248 | handle(IPC.windowClose, () => deps.window.close()); |
| 249 | handle(IPC.windowGetBounds, () => deps.window.bounds()); |
| 250 | handle(IPC.windowSetTheme, (theme) => deps.window.setTheme(theme === "light" || theme === "dark" ? theme : "system")); |
| 251 | handle(IPC.windowSetBackground, (r, g, b, a) => deps.window.setBackgroundColour(finite(r), finite(g), finite(b), finite(a, 255))); |
| 252 | handle(IPC.appZoomGet, () => deps.window.getAppZoom()); |
| 253 | handle(IPC.appZoomSet, (factor) => deps.window.setAppZoom(finite(factor, Number.NaN))); |
| 254 | handle(IPC.appZoomReset, () => deps.window.resetAppZoom()); |
| 255 | handle(IPC.graphicsGet, () => deps.graphics?.current ?? { hardwareAcceleration: true, startupEnabled: true, override: "none", restartRequired: false, writable: false, warning: null }); |
| 256 | handle(IPC.graphicsSet, (enabled) => { |
| 257 | if (typeof enabled !== "boolean") throw new Error("hardwareAcceleration must be boolean"); |
| 258 | if (!deps.graphics) throw new Error("graphics settings unavailable"); |
| 259 | return deps.graphics.setHardwareAcceleration(enabled); |
| 260 | }); |
| 261 | |
| 262 | const browserControl = deps.browserControl; |
| 263 | const browserFlag = (value: unknown, name: string): boolean => { |
| 264 | if (typeof value !== "boolean") throw new Error(`${name} must be boolean`); |
| 265 | return value; |
| 266 | }; |
| 267 | const requireBrowserControl = (): BrowserControlApi => { |
| 268 | if (!browserControl) throw new Error("browser control settings unavailable"); |
| 269 | return browserControl; |
| 270 | }; |
| 271 | handle(IPC.browserControlGet, () => browserControl?.state() ?? null); |
| 272 | handle(IPC.browserControlSetEnabled, (enabled) => requireBrowserControl().setControlEnabled(browserFlag(enabled, "controlEnabled"))); |
| 273 | handle(IPC.browserControlSetIgnoreCertificateErrors, (enabled) => |
| 274 | requireBrowserControl().setIgnoreCertificateErrors(browserFlag(enabled, "ignoreCertificateErrors")), |
| 275 | ); |
| 276 | handle(IPC.browserControlClearCache, () => requireBrowserControl().clearCache()); |
| 277 | handle(IPC.browserControlClearAll, () => requireBrowserControl().clearAllData()); |
| 278 | handle(IPC.browserControlImportChrome, () => requireBrowserControl().importChromeLogin()); |
| 279 | |
| 280 | const browser = deps.browser; |
| 281 | if (!browser) return; |
| 282 | const tabId = (value: unknown): string => { |
| 283 | if (typeof value !== "string" || value === "") throw new Error("tabId must be a non-empty string"); |
| 284 | return value; |
| 285 | }; |
| 286 | handle(IPC.browserList, () => browser.list()); |
| 287 | handle(IPC.browserOpen, (url, options) => { |
| 288 | if (typeof url !== "string") throw new Error("url must be a string"); |
| 289 | const opts = record(options); |
| 290 | return browser.open(url, { temporary: bool(opts, "temporary"), taskId: str(opts, "taskId", "user") || "user" }); |
| 291 | }); |
| 292 | handle(IPC.browserClose, (id) => browser.close(tabId(id))); |
| 293 | handle(IPC.browserActivate, (id) => browser.activate(id === null || id === undefined ? null : tabId(id))); |
| 294 | handle(IPC.browserNavigate, (id, target) => browser.navigate(tabId(id), parseNavigateTarget(target))); |
| 295 | handle(IPC.browserSetZoom, (id, factor) => browser.setZoom(tabId(id), finite(factor, Number.NaN))); |
| 296 | handle(IPC.browserToggleDevTools, (id) => browser.toggleDevTools(tabId(id))); |
| 297 | handle(IPC.browserResume, (id) => browser.resume(tabId(id))); |
| 298 | handle(IPC.browserUserTakeover, (id) => browser.takeover(tabId(id))); |
| 299 | handle(IPC.browserSetLayout, (rect) => browser.setLayout(parseLayout(rect))); |
| 300 | handle(IPC.browserSetOverlay, (active) => browser.setOverlay(active === true)); |
| 301 | } |
| 302 |