返回 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 on(name: string, cb: (...args: unknown[]) => void): () => void;
66 native: NativePerformanceActions & {
67 processDiagnostics?(): Promise<ProcessDiagnosticsSnapshot | null>;
68 openExternal(url: string): Promise<void>;
69 clipboard: { writeText(text: string): Promise<boolean>; readText(): Promise<string> };
70 window: {
71 setTheme(theme: WindowTheme): void;
72 setBackgroundColour(r: number, g: number, b: number, a: number): void;
73 getBounds(): Promise<WindowBounds>;
74 isMaximised(): Promise<boolean>;
75 minimise(): Promise<void>;
76 toggleMaximise(): Promise<void>;
77 close(): Promise<void>;
78 getAppZoom(): Promise<number>;
79 setAppZoom(factor: number): Promise<number>;
80 resetAppZoom(): Promise<number>;
81 };
82 graphics: { get(): Promise<GraphicsSettingsState>; setHardwareAcceleration(enabled: boolean): Promise<GraphicsSettingsState> };
83 browserControl: BrowserControlApi;
84 getPathForFile(file: File): string;
85 onServiceState(cb: (state: ServiceState) => void): () => void;
86 recordRendererDiagnostic?(event: RendererDiagnosticPayload): Promise<void>;
87 };
88 browser: DesktopBrowserHost;
89 }
90
91 declare global {
92 interface Window {
93 reasonixDesktop?: ReasonixDesktopHost;
94 }
95 }
96
97 export interface DesktopHost {
98 kind: DesktopHostKind;
99 app: AppBindings | undefined;
100 events: { on(name: string, cb: (...args: unknown[]) => void): () => void };
101 native: NativePerformanceActions & {
102 processDiagnostics?(): Promise<ProcessDiagnosticsSnapshot | null>;
103 openExternal(url: string): void;
104 clipboardWriteText(text: string): Promise<boolean>;
105 clipboardReadText(): Promise<string>;
106 setWindowTheme(theme: WindowTheme): void;
107 setWindowBackground(r: number, g: number, b: number, a: number): void;
108 getWindowBounds(): Promise<WindowBounds> | undefined;
109 isWindowMaximised(): Promise<boolean>;
110 minimiseWindow(): Promise<void>;
111 toggleMaximiseWindow(): Promise<void>;
112 closeWindow(): Promise<void>;
113 getAppZoom(): Promise<number>;
114 setAppZoom(factor: number): Promise<number>;
115 resetAppZoom(): Promise<number>;
116 graphics: { get(): Promise<GraphicsSettingsState>; setHardwareAcceleration(enabled: boolean): Promise<GraphicsSettingsState> };
117 browserControl: BrowserControlApi;
118 onFilesDropped(cb: (paths: string[]) => void): () => void;
119 getPathForFile?(file: File): string;
120 onServiceState(cb: (state: ServiceState) => void): () => void;
121 recordRendererDiagnostic(event: RendererDiagnosticPayload): Promise<void>;
122 };
123 /** Native website views; only the Electron shell provides them. */
124 browser?: DesktopBrowserHost;
125 }
126
127 function dataTransferLooksLikeFileDrag(dt: DataTransfer | null): boolean {
128 if (!dt) return false;
129 if (dt.files?.length > 0) return true;
130 return Array.from(dt.types ?? []).includes("Files");
131 }
132
133 const noop = () => {};
134 const asyncNoop = async (): Promise<void> => {};
135 const win = () => (typeof window === "undefined" ? undefined : window);
136
137 // The bare browser has no shell, so the browser-control page degrades to its
138 // "desktop only" notice instead of pretending the actions exist.
139 const unavailableBrowserControl: BrowserControlApi = {
140 get: async () => null,
141 setEnabled: async () => { throw new Error("browser control settings unavailable"); },
142 setIgnoreCertificateErrors: async () => { throw new Error("browser control settings unavailable"); },
143 clearCache: async () => { throw new Error("browser control settings unavailable"); },
144 clearAllData: async () => { throw new Error("browser control settings unavailable"); },
145 importChromeLogin: async () => ({ ok: false, reason: "unsupported-platform" }),
146 };
147
148 // The bare browser (Serve product, dev server, tests) has no shell: every
149 // native call degrades to a no-op and there are no bound commands.
150 const serverHost: DesktopHost = {
151 kind: "none",
152 app: undefined,
153 events: { on: () => noop },
154 native: {
155 openExternal: (url) => {
156 win()?.open(url, "_blank", "noopener");
157 },
158 clipboardWriteText: async () => false,
159 clipboardReadText: async () => "",
160 setWindowTheme: noop,
161 setWindowBackground: noop,
162 getWindowBounds: () => undefined,
163 isWindowMaximised: async () => false,
164 minimiseWindow: asyncNoop,
165 toggleMaximiseWindow: asyncNoop,
166 closeWindow: asyncNoop,
167 onFilesDropped: () => noop,
168 onServiceState: () => noop,
169 recordRendererDiagnostic: asyncNoop,
170 getAppZoom: async () => 1,
171 setAppZoom: async () => 1,
172 resetAppZoom: async () => 1,
173 graphics: { get: async () => ({ hardwareAcceleration: true, startupEnabled: true, override: "none", restartRequired: false, writable: false, warning: null }), setHardwareAcceleration: async () => { throw new Error("graphics settings unavailable"); } },
174 browserControl: unavailableBrowserControl,
175 },
176 };
177
178 let electronHost: DesktopHost | undefined;
179 let electronHostFor: ReasonixDesktopHost | undefined;
180 const dropListeners = new Set<(paths: string[]) => void>();
181
182 const insideDropTarget = (target: EventTarget | null) =>
183 typeof (target as Element | null)?.closest === "function" &&
184 (target as Element).closest("[data-native-drop-target]") !== null;
185
186 // Chromium hands the renderer real File objects; paths come from the preload.
187 // The handlers install per document (a reloaded renderer gets a fresh one) and
188 // resolve the host at dispatch time so a service restart never goes stale.
189 let dropHandlersInstalledOn: Document | undefined;
190 const installElectronDropHandlers = () => {
191 const doc = win()?.document;
192 if (!doc || dropHandlersInstalledOn === doc) return;
193 dropHandlersInstalledOn = doc;
194 doc.addEventListener("dragover", (e) => {
195 if (!dataTransferLooksLikeFileDrag(e.dataTransfer)) return;
196 e.preventDefault();
197 if (!insideDropTarget(e.target) && e.dataTransfer) e.dataTransfer.dropEffect = "none";
198 });
199 doc.addEventListener("drop", (e) => {
200 const host = win()?.reasonixDesktop;
201 if (!host || !dataTransferLooksLikeFileDrag(e.dataTransfer)) return;
202 e.preventDefault();
203 if (!insideDropTarget(e.target) || !e.dataTransfer) return;
204 const paths = Array.from(e.dataTransfer.files).map((file) => host.native.getPathForFile(file)).filter((path) => path !== "");
205 if (paths.length > 0) for (const cb of [...dropListeners]) cb(paths);
206 });
207 };
208
209 const electronHostFrom = (host: ReasonixDesktopHost): DesktopHost => {
210 if (electronHost && electronHostFor === host) return electronHost;
211 electronHostFor = host;
212 const native = host.native;
213 const nativeWindow = native.window;
214 electronHost = {
215 kind: "electron",
216 // contract.commands is read live so a replaced preload (service restart)
217 // never leaves the proxy pointing at a stale command set.
218 app: new Proxy({} as AppBindings, {
219 get: (_target, prop) =>
220 typeof prop === "string" && host.contract.commands.includes(prop) ? (...args: unknown[]) => host.invoke(prop, args) : undefined,
221 }),
222 events: { on: (name, cb) => host.on(name, cb) },
223 native: {
224 openExternal: (url) => void native.openExternal(url).catch((err: unknown) => console.warn("openExternal failed", err)),
225 clipboardWriteText: (text) => native.clipboard.writeText(text),
226 clipboardReadText: () => native.clipboard.readText(),
227 setWindowTheme: (theme) => nativeWindow.setTheme(theme),
228 setWindowBackground: (r, g, b, a) => nativeWindow.setBackgroundColour(r, g, b, a),
229 getWindowBounds: () => nativeWindow.getBounds(),
230 isWindowMaximised: () => nativeWindow.isMaximised(),
231 minimiseWindow: () => nativeWindow.minimise(),
232 toggleMaximiseWindow: () => nativeWindow.toggleMaximise(),
233 closeWindow: () => nativeWindow.close(),
234 getAppZoom: () => nativeWindow.getAppZoom(),
235 setAppZoom: (factor) => nativeWindow.setAppZoom(factor),
236 resetAppZoom: () => nativeWindow.resetAppZoom(),
237 graphics: native.graphics,
238 ...(native.processDiagnostics ? { processDiagnostics: () => native.processDiagnostics!() } : {}),
239 ...(native.captureRendererProfile ? { captureRendererProfile: (id?: string) => native.captureRendererProfile!(id) } : {}),
240 ...(native.cancelRendererProfile ? { cancelRendererProfile: (id?: string) => native.cancelRendererProfile!(id) } : {}),
241 ...(native.exportHeapSnapshot ? { exportHeapSnapshot: () => native.exportHeapSnapshot!() } : {}),
242 browserControl: native.browserControl,
243 onFilesDropped: (cb) => {
244 installElectronDropHandlers();
245 dropListeners.add(cb);
246 return () => {
247 dropListeners.delete(cb);
248 };
249 },
250 getPathForFile: (file) => native.getPathForFile(file),
251 onServiceState: (cb) => native.onServiceState(cb),
252 recordRendererDiagnostic: (event) => native.recordRendererDiagnostic?.(event) ?? asyncNoop(),
253 },
254 browser: host.browser,
255 };
256 return electronHost;
257 };
258
259 // Resolved at call time, never cached by callers: the preload may install
260 // window.reasonixDesktop after this module first evaluates, and the browser
261 // dev mock must only win when no shell is present.
262 export function desktopHost(): DesktopHost {
263 if (typeof window === "undefined") return serverHost;
264 const electron = window.reasonixDesktop;
265 if (electron) return electronHostFrom(electron);
266 return serverHost;
267 }
268
268 lines TYPESCRIPT