| 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 can host real websites next to the app UI (contract: |
| 43 | [`docs/DESKTOP_BROWSER.md`](../../docs/DESKTOP_BROWSER.md)). Every tab is a |
| 44 | sandboxed `WebContentsView` managed by `browser/surfaceManager.ts`; the React |
| 45 | panel drives it through `reasonixDesktop.browser.*` (user surface, no grant), |
| 46 | and Go drives it through the `host/browser.*` host calls |
| 47 | (`browser/hostCalls.ts`), which require a per-task grant that dies with the |
| 48 | service generation. |
| 49 | |
| 50 | | Module | Concern | |
| 51 | | --- | --- | |
| 52 | | `guestView.ts`, `electronGuestViews.ts` | the `WebContentsView` behind injected interfaces; tests use fakes | |
| 53 | | `surfaceManager.ts` | tabs, layout/overlay visibility, take-over and crash recovery | |
| 54 | | `grants.ts`, `errors.ts` | per-task grants and the `-32010/-32011/-32012` contract codes | |
| 55 | | `snapshotScript.ts`, `snapshot.ts`, `pageScripts.ts` | serialised page walkers: aria-style snapshot, ref resolve/locate/select | |
| 56 | | `documents.ts`, `refResolver.ts` | document tokens; a navigation or take-over stales every earlier ref | |
| 57 | | `actions.ts`, `keys.ts`, `upload.ts` | trusted input dispatch: click, type, press, scroll, select, upload | |
| 58 | | `screenshot.ts` | element/full-page captures into the task scratch directory | |
| 59 | | `downloads.ts` | `will-download` routing, progress events, per-tab waits | |
| 60 | | `guestPreload.ts` | website-view preload; only reports user input for take-over | |
| 61 | | `fakeGuestViews.ts` | in-memory views so all of the above runs under plain `node --test` | |
| 62 | |
| 63 | User input in a website view flips the tab to human mode (take-over), bumps |
| 64 | its epoch and is reported to Go as `browser.takeover`; `browser.resume` hands |
| 65 | it back. Agent-dispatched input is marked so its echo is not a take-over. |
| 66 | Downloads land in the task's scratch directory when one is registered by a |
| 67 | `browser.act`/`browser.screenshot` call, otherwise in |
| 68 | `userData/downloads/<taskId>`; the renderer hears about them through |
| 69 | `reasonixDesktop.browser.onDownload`. |
| 70 | |
| 71 | ## Build |
| 72 | |
| 73 | Prerequisites: Node 24+, pnpm 10, Go. Install from the workspace root once: |
| 74 | |
| 75 | ```sh |
| 76 | cd desktop |
| 77 | pnpm install |
| 78 | ``` |
| 79 | |
| 80 | `pnpm install` also downloads the Electron binary (`allowBuilds: electron` in |
| 81 | `pnpm-workspace.yaml`). If `node_modules/electron/dist` is missing afterwards, |
| 82 | run `node node_modules/electron/install.js` inside `desktop/electron`. |
| 83 | |
| 84 | Build the Go service and the UI, then the shell: |
| 85 | |
| 86 | ```sh |
| 87 | cd desktop |
| 88 | go build -o build/bin/reasonix-desktop-service . # accepts --host-rpc |
| 89 | go run . -emit-contract frontend/src/generated # desktopContract.generated.{ts,json} |
| 90 | pnpm --filter reasonix-desktop-frontend build # frontend/dist |
| 91 | pnpm --filter reasonix-desktop-shell build # electron/dist/{main,preload}.cjs + desktopContract.json |
| 92 | ``` |
| 93 | |
| 94 | The shell build reads `frontend/src/generated/desktopContract.generated.json`, |
| 95 | recomputes its digest the way `hostrpc.Contract.Canonical` defines it |
| 96 | (sorted keys, compact, no HTML escaping), checks it against the |
| 97 | `DESKTOP_CONTRACT_DIGEST` the generator emitted, and writes the contract plus |
| 98 | `digest` to `dist/desktopContract.json`. A missing contract fails the build; |
| 99 | set `REASONIX_ELECTRON_ALLOW_MISSING_CONTRACT=1` to build without it (every |
| 100 | `desktop/invoke` is then rejected and the hello digest is empty). |
| 101 | |
| 102 | Packaged shells read the full version tag, channel and commit from |
| 103 | `resources/build.json` for `desktop/hello`. `app.getVersion()` and |
| 104 | `package.json.version` are numeric native metadata and must not identify the |
| 105 | RPC build. The packaged startup smoke runs without development overrides and |
| 106 | requires the renderer's `Version` command to match that manifest; the service |
| 107 | used by CI must also be linked with the same non-development version. |
| 108 | |
| 109 | ## Run |
| 110 | |
| 111 | ```sh |
| 112 | cd desktop/electron |
| 113 | pnpm start # electron . against ../build/bin/reasonix-desktop-service |
| 114 | REASONIX_DESKTOP_SERVICE=/path/to/binary pnpm start |
| 115 | ``` |
| 116 | |
| 117 | Development against the Vite dev server instead of the packaged UI: |
| 118 | |
| 119 | ```sh |
| 120 | cd desktop/frontend && pnpm dev # http://127.0.0.1:5173 |
| 121 | cd desktop/electron && pnpm dev # REASONIX_DEV=1, loads REASONIX_ELECTRON_DEV_URL |
| 122 | ``` |
| 123 | |
| 124 | Environment: |
| 125 | |
| 126 | | Variable | Effect | |
| 127 | | --- | --- | |
| 128 | | `REASONIX_DESKTOP_SERVICE` | path of the Go service binary (packaged default: `resources/service/reasonix-desktop[.exe]`) | |
| 129 | | `REASONIX_HOME` | data home, resolved exactly like `internal/config.ReasonixHomeDir` and sent in `hello.instance.home` | |
| 130 | | `REASONIX_DEV` | skips the single-instance lock and marks the instance as `dev` | |
| 131 | | `REASONIX_ELECTRON_DEV_URL` | loads this URL instead of `reasonix://app/index.html` | |
| 132 | | `REASONIX_FRONTEND_DIST` | overrides the directory served under `reasonix://app/` | |
| 133 | | `REASONIX_CHANNEL`, `REASONIX_COMMIT` | build identity in `hello.build` (default `dev`) | |
| 134 | |
| 135 | Logs live under `<home>/desktop-shell/logs/`: `shell.log` (main process) and |
| 136 | `service.log` (the Go service's stderr), each rotating at 5 MB. In dev both |
| 137 | are echoed to the terminal. |
| 138 | |
| 139 | ## Verify |
| 140 | |
| 141 | ```sh |
| 142 | pnpm typecheck # main + preload tsconfigs |
| 143 | pnpm test # node --test; pure modules only, Electron is injected through interfaces |
| 144 | ``` |
| 145 | |
| 146 | ## Security boundaries |
| 147 | |
| 148 | - The application window runs with `sandbox: true`, `contextIsolation: true`, |
| 149 | `nodeIntegration: false`, no spellcheck, and loads only `reasonix://app`. |
| 150 | Every navigation away from the app origin is blocked; popups are denied; |
| 151 | `<webview>` is refused. |
| 152 | - The preload exposes exactly one object, `window.reasonixDesktop`, shaped as |
| 153 | the protocol document's `ReasonixDesktopHost`. IPC replies are envelopes, so |
| 154 | a Go error reaches the renderer as `Error(<Go message>)` with no Electron |
| 155 | prefix. |
| 156 | - `ipcMain` handlers accept calls only from the main window's top frame |
| 157 | (`event.sender` and `event.senderFrame` are both checked); any other sender |
| 158 | is rejected and logged. |
| 159 | - `desktop/invoke` names are validated against the embedded contract before |
| 160 | they reach Go; unknown names fail with a `-32601` error. |
| 161 | - `reasonix://app` serves files strictly under the frontend dist (no `..`, |
| 162 | no absolute escapes, no directory index fallback except `/`). Only the |
| 163 | three resource prefixes are forwarded to the loopback origin, and the bearer |
| 164 | token is attached in the main process; it never reaches any renderer. |
| 165 | - Remote Serve windows use their own `persist:remote-<hostKey>` session, no |
| 166 | preload, sandbox on, popups denied, navigation pinned to the page origin. |
| 167 | - Website views are sandboxed `WebContentsView`s on the `persist:browser` |
| 168 | partition (`temp:<id>` for temporary tabs) with a preload that only reports |
| 169 | user input. `host/browser.*` calls need a grant scoped to one task and one |
| 170 | service generation; reads and writes refuse a tab in human mode. |
| 171 | - `shell.openExternal` from the renderer accepts `http:`, `https:` and |
| 172 | `mailto:` only. |
| 173 | - The service is restarted automatically at most three times per five |
| 174 | minutes after an unexpected exit; afterwards the failure page offers a |
| 175 | manual restart, the logs folder, and quit. There is no mock fallback. |
| 176 | |
| 177 | Packaging (`electron-builder`) is intentionally not part of this package yet. |
| 178 |