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