| 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 |