返回 DeepSeek-Reasonix
README.md
根目录 / desktop / electron / README.md
1 # Reasonix Desktop shell (Electron)
2
3 The Electron process that hosts the React UI and supervises the Go desktop
4 service. The wire contract between the two is
5 [`docs/DESKTOP_HOST_PROTOCOL.md`](../../docs/DESKTOP_HOST_PROTOCOL.md); this
6 package implements the shell side of it and nothing else. Business logic stays
7 in Go, the UI stays in `../frontend`.
8
9 ```text
10 renderer (reasonix://app) ──preload (window.reasonixDesktop)──▶ main process ──NDJSON JSON-RPC over stdio──▶ reasonix-desktop --host-rpc
11 ```
12
13 ## Layout
14
15 | Path | Concern |
16 | --- | --- |
17 | `src/main/index.ts` | bootstrap: data home, single instance, privileged scheme, wiring |
18 | `src/main/service.ts` | Go service supervisor: spawn, stderr log, restart budget, shutdown |
19 | `src/main/rpc.ts` | NDJSON JSON-RPC 2.0 client (64 MiB frames, timeouts, reverse requests) |
20 | `src/main/handshake.ts` | `desktop/hello` params, result validation, failure descriptions |
21 | `src/main/window.ts` | main `BrowserWindow`, `host/window.*`, close and crash handling |
22 | `src/main/protocol.ts` | `reasonix://app` file serving and resource-origin forwarding |
23 | `src/main/ipc.ts` | renderer IPC: sender check, contract allowlist, native calls |
24 | `src/main/hostCalls.ts` | `host/*` dispatch table |
25 | `src/main/lifecycle.ts` | quit sequencing (`beforeClose` → `shutdown` → stdin close → exit) |
26 | `src/main/menu.ts`, `tray.ts`, `dialogs.ts`, `remoteWindows.ts` | native surfaces |
27 | `src/main/browser/` | in-app browser: website views, snapshots, actions, downloads, grants |
28 | `src/preload/index.ts` | the single `window.reasonixDesktop` object |
29 | `src/shared/ipc.ts` | channel names and types shared by main and preload |
30
31 ## Browser surface
32
33 ## Hardware acceleration recovery
34
35 The desktop UI exposes **Settings → General → System → Hardware acceleration**.
36 The preference is stored in the Electron shell profile and only takes effect
37 after a full application restart. If rendering fails before Settings can open,
38 fully quit Reasonix and start it once with `REASONIX_DISABLE_GPU=1`; this is a
39 temporary override and does not change the saved preference. The override is
40 supported on Windows, macOS, and Linux.
41
42 The shell records GPU child-process failures separately from renderer failures
43 and memory growth. Two GPU failures within 60 seconds offer a native recovery
44 dialog; a renderer gets at most one reload in that interval. Renderer OOM and
45 startup-page failures go directly to recovery. A continuously unresponsive
46 window is offered recovery after 15 seconds, without claiming a GPU cause.
47 Normal exits and externally killed processes do not trigger GPU recovery.
48 After a healthy minute, recovery prompts are rearmed for later independent
49 failures. Acknowledging a dialog cannot clear a newer GPU failure that arrived
50 while it was open; transient stalls are rechecked before a delayed prompt.
51
52 **Restart in compatibility mode** disables hardware acceleration for that launch
53 only. After the renderer reports a healthy startup, a native dialog lets the user
54 keep acceleration disabled in the existing setting. Ordinary subsequent launches
55 otherwise use the saved preference. Restart interrupts active work, drains the Go
56 service, and attempts to save the draft. If draft saving fails or exceeds five
57 seconds during recovery, proceeding requires explicit confirmation of possible
58 unsaved draft loss. Ordinary exit retains its existing save requirements.
59
60 Recent explicit GPU failures are stored locally in `graphics-fault.json` in the
61 shell profile. The next launch of the same build can offer recovery if the user
62 has not acknowledged the failure and it is less than 24 hours old. A healthy
63 minute clears pending recovery. An unclean exit alone never counts as GPU
64 evidence. The versioned record preserves unknown fields; unsupported or corrupt
65 records are left untouched. `shell.log` includes graphics state, device details,
66 process exit reasons and process metrics; these additions do not upload data.
67
68 中文:默认仍开启硬件加速。GPU 进程在 60 秒内连续异常两次时,提供原生恢复
69 对话框;页面进程在同一时间窗口内最多自动刷新一次。页面内存不足、启动页崩溃
70 直接进入恢复;持续 15 秒无响应只报告界面故障,不直接归因于 GPU。
71 “以兼容模式重启”仅对本次启动关闭加速;成功启动后可选择保持关闭。
72 重启会中断任务,并先保存草稿、关闭后台服务。恢复时保存失败或超过 5 秒,
73 必须由用户确认可能丢失未保存草稿后才能继续。已保存的会话不会被清理。
74 本地 `graphics-fault.json` 仅保存明确的 GPU 故障;同版本、24 小时内未确认
75 的故障可在下次启动时提示,正常运行一分钟后解除待恢复状态。
76 恢复稳定一分钟后,后续独立故障仍可再次提示;关闭旧弹窗不会清除弹窗期间
77 发生的新 GPU 故障,已经恢复响应的短暂卡顿也不会触发排队的过期提示。
78
79 Validation: `pnpm --dir desktop/electron test:graphics-recovery` exercises real
80 Electron renderer termination and software rendering with scripted native-dialog
81 responses; GPU child-process events are injected, not a real driver crash.
82
83 The shell can host real websites next to the app UI (contract:
84 [`docs/DESKTOP_BROWSER.md`](../../docs/DESKTOP_BROWSER.md)). Every tab is a
85 sandboxed `WebContentsView` managed by `browser/surfaceManager.ts`; the React
86 panel drives it through `reasonixDesktop.browser.*` (user surface, no grant),
87 and Go drives it through the `host/browser.*` host calls
88 (`browser/hostCalls.ts`), which require a per-task grant that dies with the
89 service generation.
90
91 | Module | Concern |
92 | --- | --- |
93 | `guestView.ts`, `electronGuestViews.ts` | the `WebContentsView` behind injected interfaces; tests use fakes |
94 | `surfaceManager.ts` | tabs, layout/overlay visibility, take-over and crash recovery |
95 | `grants.ts`, `errors.ts` | per-task grants and the `-32010/-32011/-32012` contract codes |
96 | `snapshotScript.ts`, `snapshot.ts`, `pageScripts.ts` | serialised page walkers: aria-style snapshot, ref resolve/locate/select |
97 | `documents.ts`, `refResolver.ts` | document tokens; a navigation or take-over stales every earlier ref |
98 | `actions.ts`, `keys.ts`, `upload.ts` | trusted input dispatch: click, type, press, scroll, select, upload |
99 | `screenshot.ts` | element/full-page captures into the task scratch directory |
100 | `downloads.ts` | `will-download` routing, progress events, per-tab waits |
101 | `guestPreload.ts` | website-view preload; only reports user input for take-over |
102 | `fakeGuestViews.ts` | in-memory views so all of the above runs under plain `node --test` |
103
104 User input in a website view flips the tab to human mode (take-over), bumps
105 its epoch and is reported to Go as `browser.takeover`; `browser.resume` hands
106 it back. Agent-dispatched input is marked so its echo is not a take-over.
107 Downloads land in the task's scratch directory when one is registered by a
108 `browser.act`/`browser.screenshot` call, otherwise in
109 `userData/downloads/<taskId>`; the renderer hears about them through
110 `reasonixDesktop.browser.onDownload`.
111
112 ## Build
113
114 Prerequisites: Node 24+, pnpm 10, Go. Install from the workspace root once:
115
116 ```sh
117 cd desktop
118 pnpm install
119 ```
120
121 `pnpm install` also downloads the Electron binary (`allowBuilds: electron` in
122 `pnpm-workspace.yaml`). If `node_modules/electron/dist` is missing afterwards,
123 run `node node_modules/electron/install.js` inside `desktop/electron`.
124
125 Build the Go service and the UI, then the shell:
126
127 ```sh
128 cd desktop
129 go build -o build/bin/reasonix-desktop-service . # accepts --host-rpc
130 go run . -emit-contract frontend/src/generated # desktopContract.generated.{ts,json}
131 pnpm --filter reasonix-desktop-frontend build # frontend/dist
132 pnpm --filter reasonix-desktop-shell build # electron/dist/{main,preload}.cjs + desktopContract.json
133 ```
134
135 The shell build reads `frontend/src/generated/desktopContract.generated.json`,
136 recomputes its digest the way `hostrpc.Contract.Canonical` defines it
137 (sorted keys, compact, no HTML escaping), checks it against the
138 `DESKTOP_CONTRACT_DIGEST` the generator emitted, and writes the contract plus
139 `digest` to `dist/desktopContract.json`. A missing contract fails the build;
140 set `REASONIX_ELECTRON_ALLOW_MISSING_CONTRACT=1` to build without it (every
141 `desktop/invoke` is then rejected and the hello digest is empty).
142
143 Packaged shells read the full version tag, channel and commit from
144 `resources/build.json` for `desktop/hello`. `app.getVersion()` and
145 `package.json.version` are numeric native metadata and must not identify the
146 RPC build. The packaged startup smoke runs without development overrides and
147 requires the renderer's `Version` command to match that manifest; the service
148 used by CI must also be linked with the same non-development version.
149
150 ## Run
151
152 ```sh
153 cd desktop/electron
154 pnpm start # electron . against ../build/bin/reasonix-desktop-service
155 REASONIX_DESKTOP_SERVICE=/path/to/binary pnpm start
156 ```
157
158 Development against the Vite dev server instead of the packaged UI:
159
160 ```sh
161 cd desktop/frontend && pnpm dev # http://127.0.0.1:5173
162 cd desktop/electron && pnpm dev # REASONIX_DEV=1, loads REASONIX_ELECTRON_DEV_URL
163 ```
164
165 Environment:
166
167 | Variable | Effect |
168 | --- | --- |
169 | `REASONIX_DESKTOP_SERVICE` | path of the Go service binary (packaged default: `resources/service/reasonix-desktop[.exe]`) |
170 | `REASONIX_HOME` | data home, resolved exactly like `internal/config.ReasonixHomeDir` and sent in `hello.instance.home` |
171 | `REASONIX_DEV` | skips the single-instance lock and marks the instance as `dev` |
172 | `REASONIX_ELECTRON_DEV_URL` | loads this URL instead of `reasonix://app/index.html` |
173 | `REASONIX_FRONTEND_DIST` | overrides the directory served under `reasonix://app/` |
174 | `REASONIX_CHANNEL`, `REASONIX_COMMIT` | build identity in `hello.build` (default `dev`) |
175
176 Logs live under `<home>/desktop-shell/logs/`: `shell.log` (main process) and
177 `service.log` (the Go service's stderr), each rotating at 5 MB. In dev both
178 are echoed to the terminal.
179
180 ## Verify
181
182 ```sh
183 pnpm typecheck # main + preload tsconfigs
184 pnpm test # node --test; pure modules only, Electron is injected through interfaces
185 ```
186
187 ## Security boundaries
188
189 - The application window runs with `sandbox: true`, `contextIsolation: true`,
190 `nodeIntegration: false`, no spellcheck, and loads only `reasonix://app`.
191 Every navigation away from the app origin is blocked; popups are denied;
192 `<webview>` is refused.
193 - The preload exposes exactly one object, `window.reasonixDesktop`, shaped as
194 the protocol document's `ReasonixDesktopHost`. IPC replies are envelopes, so
195 a Go error reaches the renderer as `Error(<Go message>)` with no Electron
196 prefix.
197 - `ipcMain` handlers accept calls only from the main window's top frame
198 (`event.sender` and `event.senderFrame` are both checked); any other sender
199 is rejected and logged.
200 - `desktop/invoke` names are validated against the embedded contract before
201 they reach Go; unknown names fail with a `-32601` error.
202 - `reasonix://app` serves files strictly under the frontend dist (no `..`,
203 no absolute escapes, no directory index fallback except `/`). Only the
204 three resource prefixes are forwarded to the loopback origin, and the bearer
205 token is attached in the main process; it never reaches any renderer.
206 - Remote Serve windows use their own `persist:remote-<hostKey>` session, no
207 preload, sandbox on, popups denied, navigation pinned to the page origin.
208 - Website views are sandboxed `WebContentsView`s on the `persist:browser`
209 partition (`temp:<id>` for temporary tabs) with a preload that only reports
210 user input. `host/browser.*` calls need a grant scoped to one task and one
211 service generation; reads and writes refuse a tab in human mode.
212 - `shell.openExternal` from the renderer accepts `http:`, `https:` and
213 `mailto:` only.
214 - The service is restarted automatically at most three times per five
215 minutes after an unexpected exit; afterwards the failure page offers a
216 manual restart, the logs folder, and quit. There is no mock fallback.
217
218 Packaging (`electron-builder`) is intentionally not part of this package yet.
219
219 lines MARKDOWN