返回 DeepSeek-Reasonix
index.ts
根目录 / desktop / electron / src / preload / index.ts
1 import { contextBridge, ipcRenderer, webUtils } from "electron";
2 import { DesktopEventStream, MissedEventSubscriptions } from "../shared/eventStream.js";
3 import {
4 hostOS,
5 IPC,
6 type BrowserControlState,
7 type BrowserDownloadView,
8 type BrowserLayoutRect,
9 type BrowserNavigateTarget,
10 type BrowserOpenOptions,
11 type BrowserTabView,
12 type ChromeImportOutcome,
13 type ContractInfo,
14 type IpcResult,
15 type ServiceState,
16 type WindowTheme,
17 } from "../shared/ipc.js";
18 import type { GraphicsSettingsState } from "../main/graphics.js";
19
20 type Listener = (...args: unknown[]) => void;
21
22 function isResult(value: unknown): value is IpcResult {
23 return typeof value === "object" && value !== null && typeof (value as { ok?: unknown }).ok === "boolean";
24 }
25
26 function unwrap(value: unknown): unknown {
27 if (!isResult(value)) throw new Error("malformed reply from the desktop shell");
28 if (value.ok) return value.value;
29 throw new Error(value.message);
30 }
31
32 async function call(channel: string, ...args: unknown[]): Promise<unknown> {
33 return unwrap(await ipcRenderer.invoke(channel, ...args));
34 }
35
36 function fire(channel: string, ...args: unknown[]): void {
37 void call(channel, ...args).catch((error: unknown) => console.warn(`[reasonixDesktop] ${channel} failed`, error));
38 }
39
40 function readContract(): ContractInfo {
41 const raw = ipcRenderer.sendSync(IPC.contract) as unknown;
42 if (typeof raw !== "object" || raw === null) return { protocolVersion: 1, digest: "", commands: Object.freeze([]) };
43 const record = raw as { protocolVersion?: unknown; digest?: unknown; commands?: unknown };
44 const commands = Array.isArray(record.commands) ? record.commands.filter((name): name is string => typeof name === "string") : [];
45 return Object.freeze({
46 protocolVersion: typeof record.protocolVersion === "number" ? record.protocolVersion : 1,
47 digest: typeof record.digest === "string" ? record.digest : "",
48 commands: Object.freeze(commands),
49 });
50 }
51
52 const listeners = new Map<string, Set<Listener>>();
53 const missedSubscriptions = new MissedEventSubscriptions();
54
55 function emit(name: string, args: unknown[]): void {
56 const set = listeners.get(name);
57 if (!set) { if (name !== "desktop:resync") missedSubscriptions.add(name); return; }
58 for (const listener of [...set]) {
59 try {
60 listener(...args);
61 } catch (error) {
62 console.error(`[reasonixDesktop] listener for ${name} failed`, error);
63 }
64 }
65 }
66
67 const eventStream = new DesktopEventStream((frame) => emit(frame.name, frame.args), (recovery) => {
68 if (recovery.reason === "generation") missedSubscriptions.clear();
69 emit("desktop:resync", [recovery]);
70 });
71 // Bind before React mounts, so unsubscribed event names still advance the
72 // transport cursor and cannot hide missing frames from later subscribers.
73 ipcRenderer.on(IPC.event, (_event, frame: unknown) => eventStream.accept(frame));
74
75 function on(name: string, listener: Listener): () => void {
76 let set = listeners.get(name);
77 if (!set) {
78 set = new Set();
79 listeners.set(name, set);
80 }
81 set.add(listener);
82 if (name === "desktop:resync" && eventStream.recovery) {
83 queueMicrotask(() => {
84 if (set.has(listener) && eventStream.recovery) listener(eventStream.recovery);
85 });
86 } else if (missedSubscriptions.consume(name)) {
87 eventStream.requestRecovery("subscription");
88 }
89 return () => {
90 set.delete(listener);
91 if (set.size === 0) listeners.delete(name);
92 };
93 }
94
95 let lastServiceState: ServiceState | null = null;
96 const serviceStateListeners = new Set<(state: ServiceState) => void>();
97 ipcRenderer.on(IPC.serviceState, (_event, state: ServiceState) => {
98 lastServiceState = state;
99 eventStream.observeState(state);
100 for (const listener of [...serviceStateListeners]) listener(state);
101 });
102
103 // The stream must know its generation even when the app has no service-state
104 // subscriber. A newer push wins over this initial asynchronous snapshot.
105 void call(IPC.serviceStateGet).then((state) => {
106 if (lastServiceState) return;
107 lastServiceState = state as ServiceState;
108 eventStream.observeState(lastServiceState);
109 for (const listener of [...serviceStateListeners]) listener(lastServiceState);
110 }).catch(() => undefined);
111
112 // A renderer that mounts after the service became ready never saw the push;
113 // the first subscriber pulls the current state so nobody waits on a past event.
114 function onServiceState(listener: (state: ServiceState) => void): () => void {
115 serviceStateListeners.add(listener);
116 if (lastServiceState) listener(lastServiceState);
117 else {
118 void call(IPC.serviceStateGet).then((state) => {
119 if (lastServiceState || !serviceStateListeners.has(listener)) return;
120 lastServiceState = state as ServiceState;
121 eventStream.observeState(lastServiceState);
122 listener(lastServiceState);
123 }).catch(() => undefined);
124 }
125 return () => {
126 serviceStateListeners.delete(listener);
127 };
128 }
129
130 let lastTabs: BrowserTabView[] | null = null;
131 const tabListeners = new Set<(tabs: BrowserTabView[]) => void>();
132 ipcRenderer.on(IPC.browserTabs, (_event, tabs: BrowserTabView[]) => {
133 lastTabs = Array.isArray(tabs) ? tabs : [];
134 for (const listener of [...tabListeners]) listener(lastTabs);
135 });
136
137 // Fires immediately with the current list so a panel that mounts late never
138 // waits for the next change.
139 function onTabs(listener: (tabs: BrowserTabView[]) => void): () => void {
140 tabListeners.add(listener);
141 if (lastTabs) listener(lastTabs);
142 else {
143 void call(IPC.browserList).then((tabs) => {
144 if (lastTabs || !tabListeners.has(listener)) return;
145 lastTabs = Array.isArray(tabs) ? (tabs as BrowserTabView[]) : [];
146 listener(lastTabs);
147 }).catch(() => undefined);
148 }
149 return () => {
150 tabListeners.delete(listener);
151 };
152 }
153
154 const downloadListeners = new Set<(download: BrowserDownloadView) => void>();
155 ipcRenderer.on(IPC.browserDownload, (_event, download: BrowserDownloadView) => {
156 for (const listener of [...downloadListeners]) listener(download);
157 });
158
159 function onDownload(listener: (download: BrowserDownloadView) => void): () => void {
160 downloadListeners.add(listener);
161 return () => {
162 downloadListeners.delete(listener);
163 };
164 }
165
166 const browser = {
167 list: () => call(IPC.browserList) as Promise<BrowserTabView[]>,
168 open: (url: string, opts?: BrowserOpenOptions) => call(IPC.browserOpen, url, opts ?? {}) as Promise<BrowserTabView>,
169 close: (tabId: string) => call(IPC.browserClose, tabId).then(() => undefined),
170 activate: (tabId: string | null) => call(IPC.browserActivate, tabId).then(() => undefined),
171 navigate: (tabId: string, target: BrowserNavigateTarget) => call(IPC.browserNavigate, tabId, target).then(() => undefined),
172 setZoom: (tabId: string, factor: number) => call(IPC.browserSetZoom, tabId, factor).then(() => undefined),
173 toggleDevTools: (tabId: string) => call(IPC.browserToggleDevTools, tabId).then(() => undefined),
174 resume: (tabId: string) => call(IPC.browserResume, tabId).then(() => undefined),
175 takeover: (tabId: string) => call(IPC.browserUserTakeover, tabId).then(() => undefined),
176 setLayout: (rect: BrowserLayoutRect | null) => fire(IPC.browserSetLayout, rect),
177 setOverlay: (active: boolean) => fire(IPC.browserSetOverlay, active),
178 onTabs,
179 onDownload,
180 };
181
182 contextBridge.exposeInMainWorld("reasonixDesktop", {
183 kind: "electron",
184 contract: readContract(),
185 platform: {
186 os: hostOS(process.platform),
187 arch: process.arch,
188 versions: { electron: process.versions.electron ?? "", chrome: process.versions.chrome ?? "", node: process.versions.node ?? "" },
189 },
190 invoke: (method: string, args: unknown[]) => call(IPC.invoke, method, Array.isArray(args) ? args : []),
191 on,
192 native: {
193 processDiagnostics: () => call(IPC.processDiagnostics),
194 captureRendererProfile: (requestId?: string) => call(IPC.captureRendererProfile, requestId),
195 cancelRendererProfile: (requestId?: string) => call(IPC.cancelRendererProfile, requestId),
196 exportHeapSnapshot: () => call(IPC.exportHeapSnapshot),
197 recordRendererDiagnostic: (event: Record<string, string | number>) => call(IPC.rendererDiagnostic, event).then(() => undefined),
198 openExternal: (url: string) => call(IPC.openExternal, url).then(() => undefined),
199 clipboard: {
200 writeText: (text: string) => call(IPC.clipboardWrite, text).then((ok) => ok === true),
201 readText: () => call(IPC.clipboardRead).then((text) => (typeof text === "string" ? text : "")),
202 },
203 window: {
204 setTheme: (theme: WindowTheme) => fire(IPC.windowSetTheme, theme),
205 setBackgroundColour: (r: number, g: number, b: number, a: number) => fire(IPC.windowSetBackground, r, g, b, a),
206 getBounds: () => call(IPC.windowGetBounds),
207 isMaximised: () => call(IPC.windowIsMaximised).then((value) => value === true),
208 minimise: () => call(IPC.windowMinimise).then(() => undefined),
209 toggleMaximise: () => call(IPC.windowToggleMaximise).then(() => undefined),
210 close: () => call(IPC.windowClose).then(() => undefined),
211 getAppZoom: () => call(IPC.appZoomGet),
212 setAppZoom: (factor: number) => call(IPC.appZoomSet, factor),
213 resetAppZoom: () => call(IPC.appZoomReset),
214 },
215 graphics: {
216 get: () => call(IPC.graphicsGet) as Promise<GraphicsSettingsState>,
217 setHardwareAcceleration: (enabled: boolean) => call(IPC.graphicsSet, enabled) as Promise<GraphicsSettingsState>,
218 },
219 browserControl: {
220 get: () => call(IPC.browserControlGet) as Promise<BrowserControlState | null>,
221 setEnabled: (enabled: boolean) => call(IPC.browserControlSetEnabled, enabled) as Promise<BrowserControlState>,
222 setIgnoreCertificateErrors: (enabled: boolean) =>
223 call(IPC.browserControlSetIgnoreCertificateErrors, enabled) as Promise<BrowserControlState>,
224 clearCache: () => call(IPC.browserControlClearCache).then(() => undefined),
225 clearAllData: () => call(IPC.browserControlClearAll).then(() => undefined),
226 importChromeLogin: () => call(IPC.browserControlImportChrome) as Promise<ChromeImportOutcome>,
227 },
228 getPathForFile: (file: File) => webUtils.getPathForFile(file),
229 onServiceState,
230 },
231 browser,
232 });
233
233 lines TYPESCRIPT