返回 DeepSeek-Reasonix
index.ts
根目录 / desktop / electron / src / main / index.ts
1 import { app, clipboard, dialog, ipcMain, nativeImage, net, protocol, screen, session, shell } from "electron";
2 import { readdirSync } from "node:fs";
3 import { mkdir } from "node:fs/promises";
4 import { homedir } from "node:os";
5 import { join } from "node:path";
6 import { IPC, type BrowserTakeoverKind } from "../shared/ipc.js";
7 import { ActionExecutor } from "./browser/actions.js";
8 import { DocumentRegistry, randomToken } from "./browser/documents.js";
9 import { DownloadTracker } from "./browser/downloads.js";
10 import { ElectronGuestViewFactory } from "./browser/electronGuestViews.js";
11 import { GrantRegistry } from "./browser/grants.js";
12 import { buildBrowserHostCalls } from "./browser/hostCalls.js";
13 import { browserLayoutInDIP } from "./browser/layout.js";
14 import { BrowserSurfaceManager, SHARED_PARTITION, type BrowserTab } from "./browser/surfaceManager.js";
15 import { BrowserDiagnosticExport } from "./browser/diagnosticExport.js";
16 import { BrowserRecoveryStore } from "./browser/recoveryStore.js";
17 import { BrowserRecorder } from "./browser/recorder.js";
18 import { captureScreenshot } from "./browser/screenshot.js";
19 import { CaptureQueue } from "./browser/captureQueue.js";
20 import { scriptCall as browserScriptCall } from "./browser/pageScripts.js";
21 import { ISOLATED_WORLD as BROWSER_WORLD, REGISTRY_KEY as BROWSER_REGISTRY } from "./browser/snapshot.js";
22 import { BrowserControlStore, loadBrowserControlBootstrap, type BrowserSession } from "./browserControl.js";
23 import { BrowserControlHost } from "./browserControlHost.js";
24 import { applyAppUserModelId, registerTaskbarRelaunch } from "./appIdentity.js";
25 import { loadBuildIdentity } from "./buildIdentity.js";
26 import type { CookieSink } from "./chromeImport.js";
27 import { emptyContract, loadContract, type LoadedContract } from "./contract.js";
28 import { DialogHost } from "./dialogs.js";
29 import { renderFailurePage, type ShellAction } from "./failurePage.js";
30 import { buildHelloParams, describeHandshakeFailure, validateHelloResult, type HelloResult, type HandshakeFailure } from "./handshake.js";
31 import { reasonixHome } from "./home.js";
32 import { buildHostCallTable, dispatchHostCall, type ScreenInfo } from "./hostCalls.js";
33 import { firstExisting, iconCandidates } from "./icons.js";
34 import { registerRendererIpc } from "./ipc.js";
35 import { ProcessDiagnostics } from "./processDiagnostics.js";
36 import { createPerformanceHost } from "./performanceHost.js";
37 import { QuitSequencer } from "./lifecycle.js";
38 import { createLogger, errorText, RotatingFile } from "./log.js";
39 import { installApplicationMenu } from "./menu.js";
40 import { record } from "./params.js";
41 import { APP_INDEX_URL, APP_SCHEME, registerAppProtocol, resolveDistRoot } from "./protocol.js";
42 import { RemoteWindowHost } from "./remoteWindows.js";
43 import { ServiceSupervisor } from "./service.js";
44 import { ShellLifecycle } from "./shellLifecycle.js";
45 import { resolveServiceBinary } from "./serviceBinary.js";
46 import { stripPackageGrants, unpaintedWindowCause } from "./packageGrants.js";
47 import { claimShellInstance } from "./singleInstance.js";
48 import { TrayHost } from "./tray.js";
49 import { DEFAULT_GEOMETRY, MainWindow } from "./window.js";
50 import { AppZoomStore } from "./zoomStore.js";
51 import { consumeGraphicsRecoveryArg, GraphicsSettingsStore, loadGraphicsBootstrap } from "./graphics.js";
52 import { confirmRecoveryDraftLoss, installGraphicsRecovery } from "./graphicsRecoveryHost.js";
53 import { initialShellStatus, listenShellStatus, QUIT_REQUEST } from "./shellStatus.js";
54 import { supersededLauncher } from "./recovery.js";
55 import { startupLifecycle, startupPresentation, type StartupPresentReason } from "./startupPresentation.js";
56 import { StartupDelay, renderStartupPage } from "./startupDelay.js";
57
58 const MAIN_WINDOW_PERMISSIONS = new Set(["clipboard-read", "clipboard-sanitized-write", "fullscreen", "notifications"]);
59 const TAKEOVER_KINDS = new Set<string>(["mousedown", "keydown", "wheel", "touchstart", "pointerdown"]);
60
61 function safeDirName(value: string): string {
62 const name = value.replace(/[^A-Za-z0-9._-]+/g, "_");
63 return name === "" ? "user" : name;
64 }
65
66 app.setName("Reasonix");
67 // Must precede the first BrowserWindow: the taskbar reads the identity once.
68 applyAppUserModelId(app, process.platform);
69 registerTaskbarRelaunch(app, process.platform, process.execPath, app.isPackaged);
70 const dev = (process.env.REASONIX_DEV ?? "").trim() !== "";
71 const home = reasonixHome({ env: process.env, platform: process.platform, homedir, cwd: () => process.cwd(), });
72 if (home === "") {
73 console.error("reasonix-desktop-shell: cannot resolve the Reasonix data home (set REASONIX_HOME)");
74 app.exit(1);
75 } else if (!claimShellInstance(app, home, dev)) {
76 app.quit();
77 } else if (process.argv.includes(QUIT_REQUEST)) {
78 app.quit();
79 } else {
80 const graphics = loadGraphicsBootstrap(app.getPath("userData"), process.env, process.argv);
81 if (graphics.shouldDisable) app.disableHardwareAcceleration();
82 bootstrap(home);
83 }
84
85 function bootstrap(dataHome: string): void {
86 const graphicsBootstrap = loadGraphicsBootstrap(app.getPath("userData"), process.env, process.argv);
87 const graphicsTemporary = consumeGraphicsRecoveryArg(process.argv);
88 const graphics = new GraphicsSettingsStore(graphicsBootstrap.configPath, graphicsBootstrap);
89 const logsDir = join(app.getPath("userData"), "logs");
90 const log = createLogger(new RotatingFile(join(logsDir, "shell.log")), !app.isPackaged);
91 log.info(
92 `graphics acceleration: saved=${graphics.current.hardwareAcceleration} startup=${graphics.current.startupEnabled} override=${graphics.current.override} recovery=${graphicsTemporary} warning=${graphics.current.warning ?? "none"}`,
93 );
94 app.on("gpu-info-update", () => {
95 try {
96 log.info(`graphics feature status: ${JSON.stringify(app.getGPUFeatureStatus())}`);
97 } catch (error) {
98 log.warn(`graphics status unavailable: ${errorText(error)}`);
99 }
100 });
101 const serviceLog = new RotatingFile(join(logsDir, "service.log"));
102 let buildVersion = "unknown";
103 try {
104 buildVersion = loadBuildIdentity(app.isPackaged, process.resourcesPath, process.env).version;
105 } catch {
106 /* Handshake owns the visible metadata error. */
107 }
108 const status = initialShellStatus(app.getPath("userData"), buildVersion);
109 let firstHeartbeat = 0;
110 const startingPage = { code: null, name: "starting", title: "Reasonix is starting / 正在启动", detail: "Please wait. / 请稍候。" };
111 let lastFailure: HandshakeFailure = startingPage;
112 let startupTimer: ReturnType<typeof setTimeout> | undefined;
113 const startupDelay = new StartupDelay();
114 process.on("uncaughtException", (error) => log.error(`uncaught exception: ${errorText(error)}`));
115 process.on("unhandledRejection", (reason) => log.error(`unhandled rejection: ${errorText(reason)}`));
116
117 protocol.registerSchemesAsPrivileged([
118 { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: false, stream: true } },
119 ]);
120
121 const contractPath = join(__dirname, "desktopContract.json");
122 let contract: LoadedContract;
123 try {
124 contract = loadContract(contractPath);
125 } catch (error) {
126 log.warn(`desktop contract unavailable (${errorText(error)}); every desktop/invoke will be rejected`);
127 contract = emptyContract();
128 }
129 const distRoot = resolveDistRoot({ env: process.env, appPath: app.getAppPath(), resourcesPath: process.resourcesPath, packaged: app.isPackaged });
130 const devURL = (process.env.REASONIX_ELECTRON_DEV_URL ?? "").trim();
131 const appURL = devURL !== "" ? devURL : APP_INDEX_URL;
132 const zoomStore = new AppZoomStore(join(dataHome, "electron-app-zoom.json"), join(dataHome, "desktop-zoom.json"));
133 const icons = iconCandidates({ platform: process.platform, appPath: app.getAppPath(), resourcesPath: process.resourcesPath, packaged: app.isPackaged });
134 const windowIcon = process.platform === "darwin" ? undefined : (firstExisting(icons.window) ?? undefined);
135 const serviceLookup = resolveServiceBinary({
136 env: process.env,
137 platform: process.platform,
138 execPath: process.execPath,
139 resourcesPath: process.resourcesPath,
140 });
141 const serviceBinary = serviceLookup.binary;
142 const packageGrants = stripPackageGrants(
143 serviceBinary,
144 { platform: process.platform, packaged: app.isPackaged, execPath: process.execPath },
145 (line) => log.warn(line),
146 );
147 if (packageGrants?.stripped.length) {
148 log.warn(`removed app-package grants that stop sandboxed processes loading: ${packageGrants.stripped.join(", ")}`);
149 }
150 let grantCauseShown = false;
151
152 let shellBuild = { version: buildVersion, channel: "", commit: "" };
153 try {
154 const identity = loadBuildIdentity(app.isPackaged, process.resourcesPath, process.env);
155 shellBuild = {
156 version: identity.version,
157 channel: identity.channel,
158 commit: identity.commit,
159 };
160 } catch {
161 /* Handshake reports invalid packaged identity. */
162 }
163 const shellLifecycle = new ShellLifecycle(dataHome, shellBuild);
164 log.info(
165 `startup ${status.generation}: shell pid=${process.pid} version=${shellBuild.version} channel=${shellBuild.channel || "unknown"} commit=${shellBuild.commit || "unknown"}`,
166 );
167
168 let domReadyGeneration = "";
169
170 let mainWindow: MainWindow;
171 let browser: BrowserSurfaceManager;
172 let lifecycle: QuitSequencer;
173 let graphicsRecovery: ReturnType<typeof installGraphicsRecovery>;
174 let guestViews: ElectronGuestViewFactory;
175 mainWindow = new MainWindow({
176 isQuitting: () => lifecycle.isQuitting,
177 preloadPath: join(__dirname, "preload.cjs"),
178 appURL,
179 platform: process.platform,
180 icon: windowIcon,
181 log,
182 onRendererFailure: (details, canReload) => {
183 const cause = mainWindow.browserWindow?.isVisible() || details.reason !== "crashed" || grantCauseShown ? null : unpaintedWindowCause(packageGrants, app.getLocale());
184 if (cause) {
185 grantCauseShown = true;
186 dialog.showErrorBox(cause.title, cause.detail);
187 }
188 return graphicsRecovery.recovery.fault({ role: "renderer", ...details }, canReload);
189 },
190 onUnresponsive: () => graphicsRecovery.recovery.unresponsive(),
191 onResponsive: () => graphicsRecovery.recovery.responsive(),
192 onRendererLost: (reason) => {
193 status.healthy = false;
194 status.rendererVersion = "";
195 firstHeartbeat = 0;
196 browser.pauseForRendererLoss(reason);
197 for (const tab of browser.all()) documents.invalidateTab(tab.id);
198 },
199 onAppDomReady: (rendererGeneration) => {
200 const generation = service.generation;
201 if (generation === "") return;
202 const attach = () =>
203 service.request("desktop/rendererAttached", { rendererGeneration }).catch((error: unknown) => {
204 log.warn(`rendererAttached failed: ${errorText(error)}`);
205 });
206 if (domReadyGeneration === generation) {
207 void attach();
208 return;
209 }
210 domReadyGeneration = generation;
211 void service
212 .request("desktop/domReady", {})
213 .catch((error: unknown) => log.warn(`domReady failed: ${errorText(error)}`))
214 .then(attach);
215 },
216 onCloseRequested: () => lifecycle.requestWindowClose(),
217 onShellAction: (action: ShellAction) => {
218 if (action === "open-logs") void shell.openPath(logsDir);
219 else if (action === "restart") {
220 if (lifecycle.isQuitting) return;
221 const launcher = process.platform === "win32" ? supersededLauncher(process.execPath, buildVersion) : undefined;
222 if (launcher) lifecycle.relaunch(process.argv.slice(1), launcher);
223 else void service.restart().catch(() => undefined);
224 } else lifecycle.approve();
225 },
226 zoomStore,
227 });
228
229 const browserControlBootstrap = loadBrowserControlBootstrap(app.getPath("userData"));
230 const browserControlStore = new BrowserControlStore(browserControlBootstrap.configPath, browserControlBootstrap);
231 const browserControl = new BrowserControlHost({
232 store: browserControlStore,
233 sharedSession: () => session.fromPartition(SHARED_PARTITION) as unknown as BrowserSession & { cookies: CookieSink },
234 log,
235 platform: process.platform,
236 home: homedir(),
237 env: process.env,
238 list: (path) => readdirSync(path),
239 onControlEnabled: (enabled) => {
240 // The Go host reads this when it builds a session's tool set, so the new
241 // value reaches new sessions without disturbing a running turn.
242 void service.request("desktop/browserControl", { enabled }).catch((error: unknown) => {
243 log.warn(`browser control push failed: ${errorText(error)}`);
244 });
245 },
246 });
247 log.info(
248 `browser control: enabled=${browserControlBootstrap.state.controlEnabled} ignoreCertificateErrors=${browserControlBootstrap.state.ignoreCertificateErrors} warning=${browserControlBootstrap.state.warning ?? "none"}`,
249 );
250
251 const downloads = new DownloadTracker({
252 tabForWebContents: (id) => {
253 const tab = browser.all().find((entry: BrowserTab) => entry.view.page.id === id);
254 return tab ? { id: tab.id, taskId: tab.taskId } : undefined;
255 },
256 defaultDirectory: (taskId) => join(app.getPath("userData"), "downloads", safeDirName(taskId)),
257 onUpdate: (download) => mainWindow.send(IPC.browserDownload, download),
258 log,
259 });
260 guestViews = new ElectronGuestViewFactory({
261 window: () => mainWindow.browserWindow,
262 preloadPath: join(__dirname, "guest-preload.cjs"),
263 log,
264 onSession: (partition, guestSession) => {
265 browserControl.trackSession(partition, guestSession);
266 guestSession.on("will-download", (_event, item, contents) => downloads.handleWillDownload(item, contents.id));
267 },
268 });
269 browser = new BrowserSurfaceManager({
270 views: guestViews,
271 contentSize: () => mainWindow.contentSize(),
272 onTakeover: (tab, reason) =>
273 void service.hostEvent("browser.takeover", {
274 tabId: tab.id,
275 epoch: tab.epoch,
276 reason,
277 }),
278 onCrash: (tab, reason) =>
279 void service.hostEvent("browser.crash", {
280 tabId: tab.id,
281 epoch: tab.epoch,
282 reason,
283 }),
284 log,
285 });
286 browser.subscribe((tabs) => mainWindow.send(IPC.browserTabs, tabs));
287 const browserRecovery = new BrowserRecoveryStore(join(app.getPath("userData"), "browser-tabs-v1.json"), message => log.warn(message));
288 browser.restore(browserRecovery.tabs);
289 const stopBrowserRecovery = browser.subscribe(() => browserRecovery.save(browser.recoveryTabs()));
290 const grants = new GrantRegistry({ generation: () => service.generation });
291 const browserDiagnosticExport = new BrowserDiagnosticExport(browser, grants, { build: shellBuild.commit, version: shellBuild.version, platform: process.platform });
292 const browserRecorder = new BrowserRecorder(browser, grants, __dirname);
293 const browserCaptureQueue = new CaptureQueue();
294 const documents = new DocumentRegistry();
295 const actions = new ActionExecutor({ surfaces: browser, documents });
296
297 const remote = new RemoteWindowHost({
298 platform: process.platform,
299 icon: windowIcon,
300 log,
301 onClosed: (hostKey) => void service.hostEvent("remoteWindow.closed", { hostKey }),
302 });
303 const tray = new TrayHost({
304 platform: process.platform,
305 iconPath: firstExisting(icons.tray),
306 onOpen: () => {
307 mainWindow.show("tray");
308 void service.hostEvent("tray.open", {});
309 },
310 onQuit: () => void service.hostEvent("tray.quit", {}),
311 log,
312 });
313 const dialogs = new DialogHost(dialog, () => mainWindow.browserWindow ?? undefined);
314
315 lifecycle = new QuitSequencer({
316 service: {
317 beforeClose: async (reason) => record(await service.request("desktop/beforeClose", { reason })).prevent === true,
318 shutdown: (reason, onProgress) => service.shutdown(reason, onProgress),
319 shutdownRequestIdentity: () => service.shutdownRequestIdentity,
320 },
321 app: {
322 quit: () => app.quit(),
323 exit: (code) => app.exit(code),
324 relaunch: (args: string[], execPath?: string) => {
325 if (execPath) delete process.env.REASONIX_DESKTOP_SERVICE;
326 app.relaunch({ args, ...(execPath ? { execPath } : {}) });
327 },
328 },
329 flushRenderer: () => mainWindow.flushSessionDraft(),
330 resumeRenderer: () => mainWindow.resumeSessionDraftEditing(),
331 confirmRecoveryDraftLoss,
332 onWindowClosePrevented: () => mainWindow.hide(),
333 onPrepareFailed: async (message) => {
334 const parent = mainWindow.browserWindow;
335 const options = {
336 type: "error" as const,
337 title: "Close paused / 关闭已暂停",
338 message: "Reasonix could not save the current draft. / Reasonix 无法保存当前草稿。",
339 detail: `${message}\n\nThe window will remain open so you can retry. / 窗口将保持打开,你可以重试。`,
340 buttons: ["OK / 确定"],
341 noLink: true,
342 };
343 if (parent) await dialog.showMessageBox(parent, options);
344 else await dialog.showMessageBox(options);
345 },
346 onShutdownFailed: async (message) => {
347 const options = {
348 type: "error" as const,
349 title: "Exit incomplete / 退出未完成",
350 message: "Reasonix could not safely finish saving and closing. / Reasonix 未能安全完成保存与收尾。",
351 detail: `${message}\n\nYou can retry the remaining steps or keep this window open. / 你可以重试未完成的步骤,或保留当前窗口。`,
352 buttons: ["Retry exit / 重试退出", "Keep open / 保留窗口"],
353 defaultId: 0,
354 cancelId: 1,
355 noLink: true,
356 };
357 const parent = mainWindow.browserWindow;
358 const result = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options);
359 return result.response === 0;
360 },
361 // Website views go first: a WebContents closing after its window is
362 // gone is the ordering that left orphaned renderers in the prototype.
363 onCloseAllowed: () => mainWindow.allowClose(),
364 cleanup: [
365 { name: "browser views", run: async () => { await browserRecorder.close(); stopBrowserRecovery(); browserRecovery.save(browser.recoveryTabs()); browser.destroyAll(); browserDiagnosticExport.dispose(); } },
366 { name: "remote windows", run: () => remote.closeAll() },
367 { name: "main window", run: () => mainWindow.close() },
368 { name: "tray", run: () => tray.destroy() },
369 { name: "startup deadline", run: () => clearTimeout(startupTimer) },
370 { name: "startup presentation", run: () => startupDelay.cancel() },
371 { name: "shell lifecycle", run: () => shellLifecycle.complete() },
372 ],
373 log,
374 });
375
376 const hostCalls = buildHostCallTable({
377 window: mainWindow,
378 dialogs,
379 tray,
380 remote,
381 lifecycle,
382 openExternal: (url) => {
383 new URL(url);
384 return shell.openExternal(url);
385 },
386 hideApp: () => {
387 if (process.platform === "darwin") app.hide();
388 else mainWindow.hide();
389 },
390 screens: (): ScreenInfo[] => {
391 const primary = screen.getPrimaryDisplay().id;
392 return screen.getAllDisplays().map((display) => ({
393 x: display.bounds.x,
394 y: display.bounds.y,
395 width: display.bounds.width,
396 height: display.bounds.height,
397 scale: display.scaleFactor,
398 primary: display.id === primary,
399 }));
400 },
401 browser: buildBrowserHostCalls({
402 surfaces: browser,
403 grants,
404 documents,
405 actions,
406 downloads,
407 recorder: browserRecorder,
408 captureQueue: browserCaptureQueue,
409 diagnosticExport: browserDiagnosticExport,
410 trace: event => log.info(`browser operation ${JSON.stringify({ ...event, build: shellBuild.commit, version: shellBuild.version, platform: process.platform })}`),
411 screenshotDeps: { decodePNG: (data) => nativeImage.createFromBuffer(data).getSize() },
412 }),
413 });
414
415 const service = new ServiceSupervisor(
416 {
417 binary: serviceBinary,
418 args: ["--host-rpc"],
419 env: process.env,
420 onStderr: (chunk) => {
421 serviceLog.write(chunk);
422 if (!app.isPackaged) process.stderr.write(chunk);
423 },
424 log,
425 },
426 {
427 hello: async (client) =>
428 validateHelloResult(
429 await client.request(
430 "desktop/hello",
431 buildHelloParams({
432 protocolVersion: contract.protocolVersion,
433 contractDigest: contract.digest,
434 ...loadBuildIdentity(app.isPackaged, process.resourcesPath, process.env),
435 hostVersion: process.versions.electron,
436 chromeVersion: process.versions.chrome,
437 platform: process.platform,
438 arch: process.arch,
439 home: dataHome,
440 dev,
441 }),
442 10_000,
443 ),
444 contract.protocolVersion,
445 ),
446 onRequest: (method, params) => {
447 if (lifecycle.isQuitting) return Promise.reject(new Error("Reasonix is shutting down"));
448 return dispatchHostCall(hostCalls, method, params);
449 },
450 onEvent: (frame) => {
451 if (frame.name === "topic:activation") {
452 const phase = (frame.args[0] as { phase?: unknown } | undefined)?.phase;
453 if (["starting", "ready", "failed", "cancelled"].includes(String(phase))) log.info(`session activation phase=${phase}`);
454 }
455 mainWindow.send(IPC.event, frame);
456 },
457 onState: (state) => {
458 shellLifecycle.mark(`service_${state.phase}`);
459 log.info(`startup ${status.generation}: service=${state.phase} generation=${state.generation}`);
460 status.service = state.phase;
461 status.healthy = false;
462 status.rendererVersion = "";
463 firstHeartbeat = 0;
464 if (state.phase === "starting" || state.phase === "restarting") {
465 status.lifecycle = "starting";
466 startupDelay.start(() => {
467 if (lifecycle.isQuitting || status.lifecycle !== "starting" || mainWindow.browserWindow) return;
468 mainWindow.create(DEFAULT_GEOMETRY);
469 void mainWindow.showStartup(renderStartupPage());
470 });
471 clearTimeout(startupTimer);
472 startupTimer = setTimeout(() => {
473 if (lifecycle.isQuitting || status.healthy || status.lifecycle === "failed") return;
474 lastFailure = {
475 code: null,
476 name: "startup_timeout",
477 title: "Startup incomplete / 启动未完成",
478 detail: "Reasonix did not become ready within 30 seconds. Open logs or retry. / 30 秒内未完成启动,请打开日志或重试。",
479 };
480 status.lifecycle = "failed";
481 startupDelay.cancel();
482 log.error(`startup ${status.generation}: readiness timeout`);
483 if (!mainWindow.browserWindow) mainWindow.create(DEFAULT_GEOMETRY);
484 void mainWindow.showFailure(renderFailurePage(lastFailure, logsDir));
485 }, 30_000);
486 startupTimer.unref();
487 }
488 mainWindow.send(IPC.serviceState, state);
489 grants.observeGeneration(state.generation);
490 if (state.phase !== "ready") {
491 browser.pauseForRendererLoss(`service ${state.phase}`);
492 documents.clear();
493 }
494 },
495 onReady: async (hello: HelloResult) => {
496 shellLifecycle.start(hello);
497 startupDelay.cancel();
498 if (lifecycle.isQuitting) return;
499 status.lifecycle = "ready";
500 status.servicePID = hello.service.pid;
501 const identityLog = hello.instance
502 ? `, path identity v${hello.instance.identityVersion} ${hello.instance.identityDigest}`
503 : ", legacy path identity";
504 log.info(
505 `desktop service ready: generation ${hello.runtimeGeneration}, pid ${hello.service.pid}, version=${hello.service.version} channel=${hello.service.channel || "unknown"} commit=${hello.service.commit || "unknown"}${identityLog}`,
506 );
507 try {
508 await zoomStore.load();
509 } catch (error) {
510 log.warn(`app zoom initialization failed: ${errorText(error)}`);
511 }
512 if (lifecycle.isQuitting || service.generation !== hello.runtimeGeneration) return;
513 mainWindow.prepareApp(hello.window);
514 // A restarted service starts with the capability on, so the persisted
515 // switch is replayed before any session can be built.
516 void service
517 .request("desktop/browserControl", {
518 enabled: browserControl.state().controlEnabled,
519 })
520 .catch((error: unknown) => log.warn(`browser control push failed: ${errorText(error)}`));
521 if (!mainWindow.browserWindow) {
522 try {
523 await zoomStore.load();
524 mainWindow.create(hello.window);
525 } catch (error) {
526 log.warn(`app zoom initialization failed: ${errorText(error)}`);
527 mainWindow.create(DEFAULT_GEOMETRY);
528 }
529 }
530 // Reattach the surviving renderer after a service restart. Reloading
531 // would destroy unsent composer drafts; desktop:resync repairs reads.
532 if (!mainWindow.reattachApp()) void mainWindow.loadApp();
533 },
534 onFailed: (error) => {
535 startupDelay.cancel();
536 if (lifecycle.isQuitting) return;
537 const failure = describeHandshakeFailure(error);
538 lastFailure = failure;
539 status.lifecycle = "failed";
540 log.error(`desktop service failed: ${failure.name}: ${failure.detail}`);
541 if (!mainWindow.browserWindow) mainWindow.create(DEFAULT_GEOMETRY);
542 void mainWindow.showFailure(renderFailurePage(failure, logsDir));
543 },
544 },
545 );
546
547 // Session end and scripted shutdowns deliver SIGTERM; quit through the same
548 // sequence as the menu so Go snapshots sessions before the process ends.
549 process.on("SIGTERM", () => lifecycle.requestQuit("system_signal"));
550 app.on("second-instance", (_event, argv) => {
551 if (argv.includes(QUIT_REQUEST)) {
552 lifecycle.requestQuit();
553 return;
554 }
555 presentInstance(argv, "second-instance");
556 });
557 function presentInstance(argv: string[] = [], reason: StartupPresentReason = "second-instance"): void {
558 if (lifecycle.isQuitting) return;
559 if (!app.isReady()) {
560 void app.whenReady().then(() => presentInstance(argv, reason));
561 return;
562 }
563 const action = startupPresentation({
564 serviceReady: service.ready,
565 hasWindow: Boolean(mainWindow.browserWindow),
566 lifecycle: startupLifecycle(status.lifecycle),
567 });
568 if (action === "diagnostic") {
569 if (!mainWindow.browserWindow) mainWindow.create(DEFAULT_GEOMETRY);
570 void mainWindow.showFailure(renderFailurePage(lastFailure, logsDir));
571 }
572 if (action !== "none") mainWindow.focusForSecondInstance();
573 if (service.ready) void service.hostEvent("secondInstance", { argv });
574 }
575 app.on("activate", () => presentInstance([], "activate"));
576 app.on("before-quit", (event) => {
577 startupDelay.cancel();
578 if (!lifecycle.onBeforeQuit()) event.preventDefault();
579 });
580 app.on("window-all-closed", () => {
581 if (!service.ready) lifecycle.requestQuit();
582 });
583 const statusServer =
584 process.platform === "win32"
585 ? listenShellStatus(
586 () => ({
587 ...status,
588 lifecycle: lifecycle.currentPhase === "completed" ? "done" : lifecycle.isQuitting ? "quitting" : status.lifecycle,
589 visible: mainWindow.browserWindow?.isVisible() ?? false,
590 }),
591 log,
592 )
593 : undefined;
594 app.on("will-quit", () => statusServer?.close());
595
596 graphicsRecovery = installGraphicsRecovery({ graphics, lifecycle, log, logsDir, build: buildVersion, temporary: graphicsTemporary });
597
598 void app.whenReady().then(() => {
599 if (lifecycle.isQuitting) return;
600 if (process.platform === "darwin") {
601 const dockIcon = firstExisting(icons.dock);
602 if (dockIcon && app.dock) app.dock.setIcon(dockIcon);
603 }
604 registerAppProtocol({
605 protocol,
606 fetch: (input, init) => net.fetch(input, init),
607 distRoot,
608 resources: () => service.helloResult?.resources ?? null,
609 log,
610 });
611 session.defaultSession.setPermissionRequestHandler((contents, permission, callback) => {
612 callback(mainWindow.isTrustedSender(contents, contents.mainFrame) && MAIN_WINDOW_PERMISSIONS.has(permission));
613 });
614 const diagnostics = new ProcessDiagnostics(
615 () => app.getAppMetrics(),
616 undefined,
617 () => Boolean(mainWindow.browserWindow?.isVisible() && mainWindow.browserWindow?.isFocused()),
618 );
619 const performanceHost = createPerformanceHost({
620 window: () => mainWindow.browserWindow,
621 dialog,
622 workerPath: join(__dirname, "profile-analysis.cjs"),
623 locale: () => app.getLocale(),
624 });
625 diagnostics.sample();
626 const diagnosticsTimer = setInterval(() => diagnostics.sample(), 30_000);
627 diagnosticsTimer.unref();
628 app.once("will-quit", () => {
629 clearInterval(diagnosticsTimer);
630 performanceHost.dispose();
631 });
632 registerRendererIpc({
633 processDiagnostics: () => diagnostics.snapshot(),
634 performance: performanceHost,
635 ipcMain,
636 contract,
637 window: mainWindow,
638 invoke: async (method, args) => {
639 const generation = service.generation;
640 const recovery = ["PrepareSession", "GetSessionPreparation", "StartTopicActivation"].includes(method);
641 if (recovery) log.info(`session recovery method=${method} stage=request`);
642 const result = await service.invoke(method, args);
643 if (generation !== service.generation || lifecycle.isQuitting) return result;
644 if (recovery) {
645 const state = result as { status?: unknown; meta?: { ready?: boolean } } | undefined;
646 const phase = ["queued", "preparing", "ready", "blocked", "failed", "cancelled"].includes(String(state?.status)) ? state?.status : "ticket";
647 log.info(`session recovery method=${method} stage=response phase=${phase} ready=${state?.meta?.ready === true}`);
648 }
649 // Keep recovery evidence content-free: distinguish an empty producer
650 // cut from rows lost later in renderer hydration. Poll replies have
651 // no snapshot and do not produce a log entry.
652 if (method === "TranscriptFollowForTab" && result && typeof result === "object") {
653 const cut = result as { snapshot?: { totalRecords?: number; coveredThroughSeq?: number }; history?: { status?: string; messages?: unknown[] } };
654 if (cut.snapshot) log.info(`transcript baseline records=${cut.snapshot.totalRecords ?? 0} sequence=${cut.snapshot.coveredThroughSeq ?? 0} history_status=${cut.history?.status ?? "missing"} history_messages=${cut.history?.messages?.length ?? 0}`);
655 }
656 if (method === "Version" && typeof result === "string") status.rendererVersion = result;
657 if (method === "ReportDesktopWebViewReady") {
658 if (!firstHeartbeat) firstHeartbeat = Date.now();
659 else if (Date.now() - firstHeartbeat >= 2000 && status.lifecycle === "ready") {
660 status.healthy = true;
661 graphicsRecovery.healthy();
662 clearTimeout(startupTimer);
663 }
664 if (status.rendererVersion === "")
665 void mainWindow.browserWindow?.webContents.executeJavaScript('window.reasonixDesktop.invoke("Version", [])').catch(() => undefined);
666 }
667 return result;
668 },
669 serviceState: () => service.current,
670 clipboard,
671 graphics,
672 browserControl,
673 openExternal: (url) => shell.openExternal(url),
674 browser: {
675 list: () => browser.list(),
676 open: async (url, options) => browser.view(await browser.open(url, options)),
677 close: (tabId) => browser.close(tabId),
678 activate: (tabId) => browser.activate(tabId),
679 navigate: async (tabId, target) => {
680 await browser.navigate(tabId, target);
681 },
682 setZoom: (tabId, factor) => browser.setZoom(tabId, factor),
683 setViewport: (tabId, viewport) => browser.setViewport(tabId, viewport),
684 record: async (tabId, action) => {
685 const tab = browser.require(tabId);
686 if (action === "start") { browser.takeover(tabId, "user recording"); await tab.view.ensureLoaded?.(); }
687 const directory = join(app.getPath("userData"), "browser-recordings");
688 await mkdir(directory, { recursive: true });
689 return browserRecorder.userRequest(tab, action, directory);
690 },
691 diagnostics: tabId => browser.require(tabId).view.diagnostics?.read() ?? { available: false },
692 restorePreview: async tabId => {
693 const tab = browser.require(tabId), generation = service.generation;
694 if (!tab.fileReference || !tab.view.isPlaceholder?.()) throw new Error("not a restored file preview");
695 const tasks = await service.invoke("ListTabs", []) as { id: string; sessionPath?: string; sessionGeneration?: number }[];
696 const task = tasks.find(task => task.id === tab.taskId && task.sessionPath === tab.sessionId);
697 if (!task?.sessionGeneration || service.generation !== generation) throw new Error("open the original task and session before restoring its preview");
698 const result = await service.invoke("OpenFileBrowserPreviewForTab", [tab.taskId, { ...tab.fileReference, expectedSessionGeneration: task.sessionGeneration, operationId: `restore-${randomToken()}`, userInitiated: true }]) as { tabId?: string; error?: string };
699 if (service.generation !== generation || browser.get(tabId) !== tab) throw new Error("preview recovery was interrupted");
700 if (!result.tabId || result.error) throw new Error(result.error || "preview recovery failed");
701 browser.close(tabId); browser.activate(result.tabId);
702 },
703 screenshot: async tabId => {
704 const tab = browser.require(tabId);
705 browser.takeover(tabId, "user screenshot");
706 await tab.view.ensureLoaded?.();
707 const epoch = tab.epoch, revision = tab.viewportRevision;
708 const controller = new AbortController();
709 const verify = () => { controller.signal.throwIfAborted(); if (browser.get(tabId) !== tab || tab.epoch !== epoch || tab.viewportRevision !== revision) throw new Error("page changed during screenshot"); };
710 const off = browser.subscribe(() => { try { verify(); } catch { controller.abort(); } });
711 const timer = setTimeout(() => controller.abort(), 9000);
712 let release: (() => void) | undefined;
713 try {
714 const directory = join(app.getPath("userData"), "browser-captures");
715 await mkdir(directory, { recursive: true });
716 return await browserCaptureQueue.run(controller.signal, async () => {
717 verify();
718 release = await tab.view.prepareCapture?.(controller.signal);
719 try {
720 return await captureScreenshot(tab.view.page, null, tab.view.inputScale?.() ?? 1, { directory, ref: "", fullPage: false }, { viewport: tab.viewport, pixelRatio: tab.view.capturePixelRatio?.(), verify, decodePNG: data => nativeImage.createFromBuffer(data).getSize() });
721 } finally { release?.(); release = undefined; }
722 });
723 } finally { clearTimeout(timer); off(); release?.(); }
724 },
725 pickElement: async tabId => {
726 const tab = browser.require(tabId);
727 browser.takeover(tabId, "element picker");
728 await tab.view.ensureLoaded?.();
729 const identity = { taskId: tab.taskId, sessionId: tab.sessionId, tabId, epoch: tab.epoch, url: tab.view.page.getURL(), time: Date.now() };
730 const operation = randomToken(8);
731 if (!browser.view(tab).active) throw new Error("show the original page before selecting an element");
732 browser.setOperation(tab, { id: operation, phase: "picking" });
733 // This focus is only for the explicit user picker action. Without
734 // it Escape remains in the application renderer and cannot cancel.
735 tab.view.page.focus();
736 const offPicker = browser.subscribe(() => {
737 if ((browser.get(tabId) !== tab || !browser.view(tab).active) && !tab.view.page.isDestroyed()) {
738 void tab.view.page.executeJavaScriptInIsolatedWorld(BROWSER_WORLD, [{ code: browserScriptCall("pageCancelPicker", {}) }]).catch(() => {});
739 }
740 });
741 try {
742 const element = await tab.view.page.executeJavaScriptInIsolatedWorld(BROWSER_WORLD, [{ code: browserScriptCall("pagePickElement", { key: BROWSER_REGISTRY }) }]);
743 return element ? { ...identity, element } : null;
744 } finally { offPicker(); if (tab.operation?.id === operation) browser.setOperation(tab, { id: operation, phase: "completed" }); }
745 },
746 toggleDevTools: (tabId) => browser.toggleDevTools(tabId),
747 resume: (tabId) => browser.resume(tabId),
748 takeover: (tabId) => browser.takeover(tabId, "user takeover"),
749 setLayout: (rect) => browser.setLayout(browserLayoutInDIP(rect, mainWindow.browserWindow?.webContents.getZoomFactor() ?? 1)),
750 setOverlay: (active) => browser.setOverlay(active),
751 },
752 log,
753 });
754 // Reports from the guest preload: the sender must be one of our website
755 // views, which takeoverFromSender checks by WebContents id.
756 ipcMain.on(IPC.browserTakeover, (event, payload: unknown) => {
757 const kind = typeof payload === "object" && payload !== null ? (payload as { kind?: unknown }).kind : undefined;
758 if (typeof kind !== "string" || !TAKEOVER_KINDS.has(kind)) return;
759 browser.takeoverFromSender(event.sender.id, kind as BrowserTakeoverKind);
760 });
761 installApplicationMenu({
762 platform: process.platform,
763 openSettings: () => mainWindow.sendShellEvent("app:open-settings", service.generation),
764 toggleDevTools: () => mainWindow.toggleDevTools(),
765 showWindow: () => mainWindow.show("menu"),
766 stopBrowserRecording: () => { void browserRecorder.stopCurrent().catch(error => log.warn(`Stop browser recording failed: ${String(error)}`)); },
767 quit: () => lifecycle.requestQuit(),
768 zoomIn: () => {
769 void mainWindow.stepAppZoom(1);
770 },
771 zoomOut: () => {
772 void mainWindow.stepAppZoom(-1);
773 },
774 resetZoom: () => {
775 void mainWindow.resetAppZoom();
776 },
777 });
778 const probed = serviceLookup.probed.length > 0 ? ` (probed ${serviceLookup.probed.join(", ")})` : "";
779 log.info(`shell starting: service ${serviceBinary}${probed}, ui ${appURL}, dist ${distRoot}, home ${dataHome}`);
780 return service.start().catch(() => undefined);
781 })
782 .catch((error: unknown) => {
783 log.error(`shell bootstrap failed: ${errorText(error)}`);
784 app.exit(1);
785 });
786 }
787
787 lines TYPESCRIPT