返回 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 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
302 lines TYPESCRIPT