返回 DeepSeek-Reasonix
desktopHost.ts
根目录 / desktop / frontend / src / lib / desktopHost.ts
1 // desktopHost is the only module allowed to touch the shell global: Electron's
2 // window.reasonixDesktop (preload). scripts/check-desktop-host-boundary.mjs
3 // enforces that boundary.
4 import type { AppBindings } from "./bridge";
5 import type { DesktopBrowserHost } from "./browserHost";
6 import type { NativePerformanceActions, ProcessDiagnosticsSnapshot } from "./processDiagnostics";
7
8 export type DesktopHostKind = "electron" | "none";
9 export type WindowTheme = "system" | "light" | "dark";
10
11 export interface WindowBounds {
12 x: number;
13 y: number;
14 width: number;
15 height: number;
16 maximised: boolean;
17 }
18
19 export interface ServiceState {
20 phase: "starting" | "ready" | "restarting" | "stopping" | "failed" | "exited";
21 generation: string;
22 error?: string;
23 }
24 export type RendererDiagnosticPayload = Record<string, string | number>;
25 export interface GraphicsSettingsState {
26 hardwareAcceleration: boolean; startupEnabled: boolean;
27 override: "none" | "environment" | "command-line"; restartRequired: boolean;
28 writable: boolean; warning: "invalid-config" | "unreadable-config" | "unsupported-version" | null;
29 }
30
31 export interface BrowserControlState {
32 controlEnabled: boolean;
33 ignoreCertificateErrors: boolean;
34 writable: boolean;
35 warning: "invalid-config" | "unreadable-config" | "unsupported-version" | null;
36 }
37
38 export type ChromeImportFailure =
39 | "chrome-missing"
40 | "profile-not-found"
41 | "cookies-unreadable"
42 | "safe-storage-denied"
43 | "safe-storage-unavailable"
44 | "unsupported-platform";
45
46 export type ChromeImportOutcome =
47 | { ok: true; profile: string; cookies: number; skipped: number }
48 | { ok: false; reason: ChromeImportFailure };
49
50 export interface BrowserControlApi {
51 get(): Promise<BrowserControlState | null>;
52 setEnabled(enabled: boolean): Promise<BrowserControlState>;
53 setIgnoreCertificateErrors(enabled: boolean): Promise<BrowserControlState>;
54 clearCache(): Promise<void>;
55 clearAllData(): Promise<void>;
56 importChromeLogin(): Promise<ChromeImportOutcome>;
57 }
58
59 // Mirrors docs/DESKTOP_HOST_PROTOCOL.md "Renderer preload API".
60 export interface ReasonixDesktopHost {
61 readonly kind: "electron";
62 readonly contract: { protocolVersion: number; digest: string; commands: readonly string[] };
63 readonly platform: { os: "darwin" | "windows" | "linux"; arch: string; versions: Record<string, string> };
64 invoke(method: string, args: unknown[]): Promise<unknown>;
65 invokeResult?(method:string,args:unknown[]):Promise<{ok:true;value:unknown}|{ok:false;message:string;code?:number;data?:unknown}>;
66 on(name: string, cb: (...args: unknown[]) => void): () => void;
67 native: NativePerformanceActions & {
68 processDiagnostics?(): Promise<ProcessDiagnosticsSnapshot | null>;
69 openExternal(url: string): Promise<void>;
70 clipboard: { writeText(text: string): Promise<boolean>; readText(): Promise<string> };
71 window: {
72 setTheme(theme: WindowTheme): void;
73 setBackgroundColour(r: number, g: number, b: number, a: number): void;
74 getBounds(): Promise<WindowBounds>;
75 isMaximised(): Promise<boolean>;
76 minimise(): Promise<void>;
77 toggleMaximise(): Promise<void>;
78 close(): Promise<void>;
79 getAppZoom(): Promise<number>;
80 setAppZoom(factor: number): Promise<number>;
81 resetAppZoom(): Promise<number>;
82 };
83 graphics: { get(): Promise<GraphicsSettingsState>; setHardwareAcceleration(enabled: boolean): Promise<GraphicsSettingsState> };
84 browserControl: BrowserControlApi;
85 getPathForFile(file: File): string;
86 onServiceState(cb: (state: ServiceState) => void): () => void;
87 recordRendererDiagnostic?(event: RendererDiagnosticPayload): Promise<void>;
88 };
89 browser: DesktopBrowserHost;
90 }
91
92 declare global {
93 interface Window {
94 reasonixDesktop?: ReasonixDesktopHost;
95 }
96 }
97
98 export interface DesktopHost {
99 kind: DesktopHostKind;
100 app: AppBindings | undefined;
101 events: { on(name: string, cb: (...args: unknown[]) => void): () => void };
102 native: NativePerformanceActions & {
103 processDiagnostics?(): Promise<ProcessDiagnosticsSnapshot | null>;
104 openExternal(url: string): void;
105 clipboardWriteText(text: string): Promise<boolean>;
106 clipboardReadText(): Promise<string>;
107 setWindowTheme(theme: WindowTheme): void;
108 setWindowBackground(r: number, g: number, b: number, a: number): void;
109 getWindowBounds(): Promise<WindowBounds> | undefined;
110 isWindowMaximised(): Promise<boolean>;
111 minimiseWindow(): Promise<void>;
112 toggleMaximiseWindow(): Promise<void>;
113 closeWindow(): Promise<void>;
114 getAppZoom(): Promise<number>;
115 setAppZoom(factor: number): Promise<number>;
116 resetAppZoom(): Promise<number>;
117 graphics: { get(): Promise<GraphicsSettingsState>; setHardwareAcceleration(enabled: boolean): Promise<GraphicsSettingsState> };
118 browserControl: BrowserControlApi;
119 onFilesDropped(cb: (paths: string[]) => void): () => void;
120 getPathForFile?(file: File): string;
121 onServiceState(cb: (state: ServiceState) => void): () => void;
122 recordRendererDiagnostic(event: RendererDiagnosticPayload): Promise<void>;
123 };
124 /** Native website views; only the Electron shell provides them. */
125 browser?: DesktopBrowserHost;
126 }
127
128 function dataTransferLooksLikeFileDrag(dt: DataTransfer | null): boolean {
129 if (!dt) return false;
130 if (dt.files?.length > 0) return true;
131 return Array.from(dt.types ?? []).includes("Files");
132 }
133
134 const noop = () => {};
135 const asyncNoop = async (): Promise<void> => {};
136 const win = () => (typeof window === "undefined" ? undefined : window);
137
138 // The bare browser has no shell, so the browser-control page degrades to its
139 // "desktop only" notice instead of pretending the actions exist.
140 const unavailableBrowserControl: BrowserControlApi = {
141 get: async () => null,
142 setEnabled: async () => { throw new Error("browser control settings unavailable"); },
143 setIgnoreCertificateErrors: async () => { throw new Error("browser control settings unavailable"); },
144 clearCache: async () => { throw new Error("browser control settings unavailable"); },
145 clearAllData: async () => { throw new Error("browser control settings unavailable"); },
146 importChromeLogin: async () => ({ ok: false, reason: "unsupported-platform" }),
147 };
148
149 // The bare browser (Serve product, dev server, tests) has no shell: every
150 // native call degrades to a no-op and there are no bound commands.
151 const serverHost: DesktopHost = {
152 kind: "none",
153 app: undefined,
154 events: { on: () => noop },
155 native: {
156 openExternal: (url) => {
157 win()?.open(url, "_blank", "noopener");
158 },
159 clipboardWriteText: async () => false,
160 clipboardReadText: async () => "",
161 setWindowTheme: noop,
162 setWindowBackground: noop,
163 getWindowBounds: () => undefined,
164 isWindowMaximised: async () => false,
165 minimiseWindow: asyncNoop,
166 toggleMaximiseWindow: asyncNoop,
167 closeWindow: asyncNoop,
168 onFilesDropped: () => noop,
169 onServiceState: () => noop,
170 recordRendererDiagnostic: asyncNoop,
171 getAppZoom: async () => 1,
172 setAppZoom: async () => 1,
173 resetAppZoom: async () => 1,
174 graphics: { get: async () => ({ hardwareAcceleration: true, startupEnabled: true, override: "none", restartRequired: false, writable: false, warning: null }), setHardwareAcceleration: async () => { throw new Error("graphics settings unavailable"); } },
175 browserControl: unavailableBrowserControl,
176 },
177 };
178
179 let electronHost: DesktopHost | undefined;
180 let electronHostFor: ReasonixDesktopHost | undefined;
181 const dropListeners = new Set<(paths: string[]) => void>();
182
183 const insideDropTarget = (target: EventTarget | null) =>
184 typeof (target as Element | null)?.closest === "function" &&
185 (target as Element).closest("[data-native-drop-target]") !== null;
186
187 // Chromium hands the renderer real File objects; paths come from the preload.
188 // The handlers install per document (a reloaded renderer gets a fresh one) and
189 // resolve the host at dispatch time so a service restart never goes stale.
190 let dropHandlersInstalledOn: Document | undefined;
191 const installElectronDropHandlers = () => {
192 const doc = win()?.document;
193 if (!doc || dropHandlersInstalledOn === doc) return;
194 dropHandlersInstalledOn = doc;
195 doc.addEventListener("dragover", (e) => {
196 if (!dataTransferLooksLikeFileDrag(e.dataTransfer)) return;
197 e.preventDefault();
198 if (!insideDropTarget(e.target) && e.dataTransfer) e.dataTransfer.dropEffect = "none";
199 });
200 doc.addEventListener("drop", (e) => {
201 const host = win()?.reasonixDesktop;
202 if (!host || !dataTransferLooksLikeFileDrag(e.dataTransfer)) return;
203 e.preventDefault();
204 if (!insideDropTarget(e.target) || !e.dataTransfer) return;
205 const paths = Array.from(e.dataTransfer.files).map((file) => host.native.getPathForFile(file)).filter((path) => path !== "");
206 if (paths.length > 0) for (const cb of [...dropListeners]) cb(paths);
207 });
208 };
209
210 const electronHostFrom = (host: ReasonixDesktopHost): DesktopHost => {
211 if (electronHost && electronHostFor === host) return electronHost;
212 electronHostFor = host;
213 const native = host.native;
214 const nativeWindow = native.window;
215 electronHost = {
216 kind: "electron",
217 // contract.commands is read live so a replaced preload (service restart)
218 // never leaves the proxy pointing at a stale command set.
219 app: new Proxy({} as AppBindings, {
220 get: (_target, prop) =>
221 typeof prop === "string" && host.contract.commands.includes(prop) ? async (...args: unknown[]) => {
222 if(!host.invokeResult) return host.invoke(prop,args);
223 const result=await host.invokeResult(prop,args);
224 if(result.ok) return result.value;
225 throw Object.assign(new Error(result.message),{code:result.code,data:result.data});
226 } : undefined,
227 }),
228 events: { on: (name, cb) => host.on(name, cb) },
229 native: {
230 openExternal: (url) => void native.openExternal(url).catch((err: unknown) => console.warn("openExternal failed", err)),
231 clipboardWriteText: (text) => native.clipboard.writeText(text),
232 clipboardReadText: () => native.clipboard.readText(),
233 setWindowTheme: (theme) => nativeWindow.setTheme(theme),
234 setWindowBackground: (r, g, b, a) => nativeWindow.setBackgroundColour(r, g, b, a),
235 getWindowBounds: () => nativeWindow.getBounds(),
236 isWindowMaximised: () => nativeWindow.isMaximised(),
237 minimiseWindow: () => nativeWindow.minimise(),
238 toggleMaximiseWindow: () => nativeWindow.toggleMaximise(),
239 closeWindow: () => nativeWindow.close(),
240 getAppZoom: () => nativeWindow.getAppZoom(),
241 setAppZoom: (factor) => nativeWindow.setAppZoom(factor),
242 resetAppZoom: () => nativeWindow.resetAppZoom(),
243 graphics: native.graphics,
244 ...(native.processDiagnostics ? { processDiagnostics: () => native.processDiagnostics!() } : {}),
245 ...(native.captureRendererProfile ? { captureRendererProfile: (id?: string) => native.captureRendererProfile!(id) } : {}),
246 ...(native.cancelRendererProfile ? { cancelRendererProfile: (id?: string) => native.cancelRendererProfile!(id) } : {}),
247 ...(native.exportHeapSnapshot ? { exportHeapSnapshot: () => native.exportHeapSnapshot!() } : {}),
248 browserControl: native.browserControl,
249 onFilesDropped: (cb) => {
250 installElectronDropHandlers();
251 dropListeners.add(cb);
252 return () => {
253 dropListeners.delete(cb);
254 };
255 },
256 getPathForFile: (file) => native.getPathForFile(file),
257 onServiceState: (cb) => native.onServiceState(cb),
258 recordRendererDiagnostic: (event) => native.recordRendererDiagnostic?.(event) ?? asyncNoop(),
259 },
260 browser: host.browser,
261 };
262 return electronHost;
263 };
264
265 // Resolved at call time, never cached by callers: the preload may install
266 // window.reasonixDesktop after this module first evaluates, and the browser
267 // dev mock must only win when no shell is present.
268 export function desktopHost(): DesktopHost {
269 if (typeof window === "undefined") return serverHost;
270 const electron = window.reasonixDesktop;
271 if (electron) return electronHostFrom(electron);
272 return serverHost;
273 }
274
274 lines TYPESCRIPT