| 1 | # Reasonix Desktop (Electron shell) |
| 2 | |
| 3 | ## macOS 1.38.7 update recovery |
| 4 | |
| 5 | Desktop 1.38.7 can report `current executable is not inside a macOS .app bundle` |
| 6 | before it installs a newer build. Quit Reasonix, mount the Apple Silicon, Intel, |
| 7 | or Universal DMG from the official download page, and replace |
| 8 | `/Applications/Reasonix.app`. The application bundle is replaced; settings, |
| 9 | sessions, and other user data remain in their existing user-data directories. |
| 10 | After this one-time full install, verify automatic update by updating the repaired |
| 11 | build to the next candidate. Do not treat the manual replacement itself as an |
| 12 | automatic-update pass. |
| 13 | |
| 14 | Model/provider setup: [English guide](../docs/MODEL_SETTINGS.md) · [中文指南](../docs/MODEL_SETTINGS.zh-CN.md). |
| 15 | |
| 16 | A native desktop window around the Reasonix Go kernel. The same |
| 17 | transport-agnostic `control.Controller` that backs the chat TUI and the HTTP/SSE |
| 18 | server is driven by the Electron shell through the desktop host protocol — the |
| 19 | Go binary runs as a supervised service (`reasonix-desktop --host-rpc` over |
| 20 | stdio), the shell owns the window, tray, menu and renderer lifecycle. See |
| 21 | [the host protocol](../docs/DESKTOP_HOST_PROTOCOL.md) and |
| 22 | [the migration record](../docs/DESKTOP_SHELL_MIGRATION.md). |
| 23 | |
| 24 | ``` |
| 25 | ┌─────────────────────────────────────────────────────────────┐ |
| 26 | │ Electron shell (desktop/electron) │ |
| 27 | │ renderer: bridge.ts ──invoke──▶ window.reasonixDesktop │ |
| 28 | │ bridge.ts ◀─events── host.on("agent:event") │ |
| 29 | └───────────────▲───────────────────────────┬─────────────────┘ |
| 30 | desktop/invoke (JSON-RPC over stdio) │ desktop/event |
| 31 | ┌───────────────┴───────────────────────────▼─────────────────┐ |
| 32 | │ desktop/app.go App (bound) + eventSink (event.Sink) │ |
| 33 | │ desktop/host_rpc.go --host-rpc service, embedded dist │ |
| 34 | └───────────────▲───────────────────────────┬─────────────────┘ |
| 35 | commands │ │ typed event stream |
| 36 | ┌───────────────┴────────────────────────────▼────────────────┐ |
| 37 | │ internal/boot.Build → internal/control.Controller (kernel) │ |
| 38 | │ (same assembly the CLI uses: providers, tools, gate, …) │ |
| 39 | └──────────────────────────────────────────────────────────────┘ |
| 40 | ``` |
| 41 | |
| 42 | ## Why a nested module |
| 43 | |
| 44 | `desktop/` is its own Go module (`module reasonix/desktop`, `replace reasonix => |
| 45 | ../`). That keeps the CGO desktop build entirely separate from the CLI's |
| 46 | `CGO_ENABLED=0` single-static-binary guarantee: the parent module's `go build / |
| 47 | vet / test ./...` skip this directory, while the import path stays under |
| 48 | `reasonix/` so it can still import the `reasonix/internal/*` kernel. |
| 49 | |
| 50 | ## Prerequisites |
| 51 | |
| 52 | - Go (matches the parent module). |
| 53 | - Node 24+ and **pnpm 10** (`npm install -g pnpm@10`); `pnpm --dir desktop |
| 54 | install` pulls the Electron toolchain for the shell. |
| 55 | - No platform webview dependencies: the shell ships its own Chromium. |
| 56 | |
| 57 | ## Develop |
| 58 | |
| 59 | For browser-only UI development with the built-in mock bridge: |
| 60 | |
| 61 | ```sh |
| 62 | cd desktop |
| 63 | pnpm install # first run only |
| 64 | pnpm dev |
| 65 | ``` |
| 66 | |
| 67 | For the complete Electron application, including the Go service and Vite dev |
| 68 | server, use the single development entry point: |
| 69 | |
| 70 | ```sh |
| 71 | cd desktop |
| 72 | pnpm install # first run only |
| 73 | pnpm dev:desktop |
| 74 | ``` |
| 75 | |
| 76 | For a production-style renderer build instead of the Vite development server, |
| 77 | the equivalent manual sequence remains: |
| 78 | |
| 79 | ```sh |
| 80 | cd desktop |
| 81 | pnpm install # one workspace: frontend + electron |
| 82 | go build -o build/bin/reasonix-desktop-service . |
| 83 | pnpm --dir frontend build:electron # rewrites drag regions for Chromium |
| 84 | pnpm --dir electron start # launches the shell against the service |
| 85 | ``` |
| 86 | |
| 87 | Frontend-only iteration without the Go side: |
| 88 | |
| 89 | ```sh |
| 90 | cd desktop/frontend |
| 91 | pnpm install |
| 92 | pnpm dev # opens in a plain browser; bridge.ts uses the dev mock |
| 93 | ``` |
| 94 | |
| 95 | In a plain browser the native bindings are absent, so `bridge.ts` falls back to a |
| 96 | **mock** that streams a canned turn (text + one `edit_file` tool call) through the |
| 97 | exact same event contract — so layout, streaming, markdown, tool cards, and the |
| 98 | diff seam can all be built without rebuilding Go. |
| 99 | |
| 100 | ```sh |
| 101 | pnpm --dir electron smoke # real-service end-to-end check |
| 102 | ``` |
| 103 | |
| 104 | `go run . -emit-contract frontend/src/generated` regenerates the TypeScript |
| 105 | contract; `go test -run HostContract .` fails when it drifts. |
| 106 | |
| 107 | ## Test |
| 108 | |
| 109 | The desktop package is a nested Go module, so parent `go test ./...` does not run |
| 110 | it. Use the full lane before merging desktop changes, and the short lane for fast |
| 111 | local feedback: |
| 112 | |
| 113 | ```sh |
| 114 | make desktop-test # cd desktop && go test . |
| 115 | make desktop-test-short # skips slow desktop integration/e2e checks |
| 116 | ``` |
| 117 | |
| 118 | To find the next bottleneck, rank individual test cases from the JSON stream: |
| 119 | |
| 120 | ```sh |
| 121 | make desktop-test-times |
| 122 | # or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py |
| 123 | ``` |
| 124 | |
| 125 | ### Frontend UI review checklist |
| 126 | |
| 127 | For anchored menus, dropdowns, tooltips, and other portaled UI, review both the |
| 128 | component code and the CSS positioning contract: |
| 129 | |
| 130 | - If a component uses `createPortal` plus `getBoundingClientRect()`, it must |
| 131 | handle scrollable ancestors, window resize, and `visualViewport` changes. |
| 132 | - Add a focused regression test when changing shared positioning primitives such |
| 133 | as `AnchoredPopover`, not only the specific menu that exposed the bug. |
| 134 | - Exercise at least one scrollable container path, such as Settings content, when |
| 135 | manually checking dropdown or popover changes. |
| 136 | |
| 137 | ## Build |
| 138 | |
| 139 | ```sh |
| 140 | scripts/desktop-build.sh darwin/arm64 v0.0.0-dev # one platform per run |
| 141 | ``` |
| 142 | |
| 143 | The script regenerates the host contract (failing on drift), builds the Go |
| 144 | service, and packages the Electron shell through `desktop/packaging/package.mjs`. |
| 145 | Chromium cannot cross-compile the native targets from one host, so releases run |
| 146 | it once per platform on a native runner. |
| 147 | |
| 148 | `frontend/dist` is generated by the build (it's git-ignored except for a |
| 149 | `.gitkeep` that keeps the Go `//go:embed all:frontend/dist` compilable on a fresh |
| 150 | checkout). A bare `go build` without a prior `pnpm build` produces a service with |
| 151 | no frontend assets. |
| 152 | |
| 153 | ## Releases & auto-update |
| 154 | |
| 155 | Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*` |
| 156 | tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`, |
| 157 | which builds on a native runner per platform (Electron's Chromium can't |
| 158 | cross-compile), packages each artifact, signs it with minisign, generates a |
| 159 | `latest.json` manifest, publishes a GitHub release, marks the desktop release as |
| 160 | GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the |
| 161 | current desktop manifest to the matching CLI release for old clients that still |
| 162 | ask GitHub's repository-wide `latest` release for it. |
| 163 | The Linux artifact bundles Electron's Chromium and ships a root-owned |
| 164 | `chrome-sandbox` helper in the `.deb`; no system webview is required. |
| 165 | |
| 166 | ```sh |
| 167 | git tag desktop-v1.1.0 && git push origin desktop-v1.1.0 |
| 168 | ``` |
| 169 | |
| 170 | The app checks `latest.json` on startup (R2 first, then the |
| 171 | `crash.reasonix.io` desktop release gateway) and shows an update banner when a |
| 172 | newer version is published; **Settings → Software update** has a manual check. |
| 173 | The gateway resolves only the desktop `desktop-v*` release line and never uses |
| 174 | GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does |
| 175 | not depend on homepage badge semantics. Self-update behavior by platform: |
| 176 | |
| 177 | - **Linux portable (`.tar.gz`)** — download, verify the minisign signature, replace |
| 178 | the binaries in the install directory, and relaunch through Guard. No elevation. |
| 179 | - **Linux Debian/Ubuntu (`.deb`)** — download the signed `.deb`, request administrator |
| 180 | authorization via Polkit (`pkexec`), re-verify and install with `apt-get |
| 181 | --only-upgrade`, then relaunch through Guard. The first build that ships the |
| 182 | update helper and Polkit policy is a one-time bootstrap: existing `.deb` users |
| 183 | should overwrite-install once with |
| 184 | `sudo apt install ./Reasonix-linux-amd64.deb` (no uninstall required). After |
| 185 | that, in-app authorized updates work. If Polkit/`pkexec` is unavailable, use |
| 186 | the same manual command. Failed installs leave the running app intact so you |
| 187 | can retry; successful installs are managed by apt/dpkg and are not auto-downgraded. |
| 188 | - **Windows** — download, verify the minisign signature, then run the per-user |
| 189 | NSIS installer (no admin rights needed). |
| 190 | - **macOS** — Developer ID signed and notarized release builds update in place. |
| 191 | Local and fork builds use ad-hoc signing and remain manual-only because |
| 192 | Gatekeeper cannot authorize their replacement bundle. |
| 193 | |
| 194 | ### Code signing — first launch |
| 195 | |
| 196 | - **Windows** — stable builds carry an Authenticode signature (SignPath, approved |
| 197 | per release; `release-desktop.yml` verifies every payload binary through |
| 198 | `scripts/verify-windows-authenticode.ps1` and fails the release otherwise). A |
| 199 | brand-new version can still show SmartScreen until the signature accumulates |
| 200 | reputation: *More info → Run anyway*. |
| 201 | - **macOS** — official release builds are signed and notarized. Choose the Apple |
| 202 | Silicon or Intel DMG for the smallest download, or the Universal DMG when the |
| 203 | CPU architecture is unknown. Local ad-hoc builds may still require clearing the |
| 204 | quarantine attribute when Gatekeeper reports the app "is damaged" or is from an |
| 205 | unidentified developer: |
| 206 | ```sh |
| 207 | xattr -dr com.apple.quarantine /Applications/Reasonix.app |
| 208 | ``` |
| 209 | The release workflow's `HAS_APPLE_CERT` gate controls the signed, notarized, |
| 210 | self-updating path. |
| 211 | |
| 212 | ### Verifying a download |
| 213 | |
| 214 | Artifacts are signed with minisign (public key ID `AF12CA46F4A9EBB0`). The `.minisig` |
| 215 | signature sits next to each artifact in the release; verify with the |
| 216 | [minisign](https://jedisct1.github.io/minisign/) CLI: |
| 217 | |
| 218 | ```sh |
| 219 | minisign -Vm Reasonix-darwin-arm64.zip \ |
| 220 | -P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq |
| 221 | ``` |
| 222 | |
| 223 | ## Editor seams and workspace file previews |
| 224 | |
| 225 | Code and diff rendering go through two components with stable prop contracts and |
| 226 | lazy boundaries, so heavier viewers stay out of the initial bundle. `CodeViewer` |
| 227 | keeps the compact highlighted viewer for chat, Markdown, and tool output, while |
| 228 | workspace file previews opt into the searchable line-number viewer: |
| 229 | |
| 230 | | Component | Props | Default impl | Upgrade | |
| 231 | |---|---|---|---| |
| 232 | | `components/CodeViewer.tsx` | `EditorProps` | `editors/HljsCode.tsx`; `editors/LineNumberCode.tsx` when `showLineNumbers` is enabled | extend the implementation selection for Monaco or CodeMirror | |
| 233 | | `components/DiffView.tsx` | `DiffProps` | `editors/HljsDiff.tsx` (highlighted LCS/unified diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` | |
| 234 | |
| 235 | ```sh |
| 236 | # Monaco |
| 237 | pnpm add @monaco-editor/react monaco-editor |
| 238 | # or CodeMirror 6 |
| 239 | pnpm add @uiw/react-codemirror @codemirror/lang-javascript @codemirror/merge |
| 240 | ``` |
| 241 | |
| 242 | Then add `editors/MonacoCode.tsx` (default-export a component taking |
| 243 | `EditorProps`) and update the implementation selection in `CodeViewer.tsx`. |
| 244 | `ToolCard` already routes `edit_file` calls' `old_string`/`new_string` through |
| 245 | `DiffView`, and `Markdown` routes fenced code blocks through `CodeViewer`, so |
| 246 | both seams light up everywhere at once. |
| 247 | |
| 248 | `WorkspacePanel` passes `showLineNumbers` for text-file previews. The resulting |
| 249 | viewer provides a line-number gutter, viewer-scoped Ctrl/Cmd+F search with case |
| 250 | and whole-word options, copy support, and virtualized rendering above 100 lines. |
| 251 | Search marks are applied only to visible rows so query input does not rebuild the |
| 252 | entire highlighted document. Files above 512 KiB or 20,000 lines keep line |
| 253 | numbers, search, copy, and virtualization but use escaped plain text instead of |
| 254 | syntax highlighting. Workspace files are previewed up to 2 MiB; larger files |
| 255 | display the first 2 MiB with a localized truncation notice. |
| 256 | |
| 257 | ## Multi-platform adaptation |
| 258 | |
| 259 | One Chromium runtime (Electron) serves every OS, so the remaining platform work |
| 260 | is native-shell behavior, not per-engine rendering quirks: |
| 261 | |
| 262 | - **Linux** — the shell runs with the Chromium sandbox (the `.deb` ships a |
| 263 | root-owned 4755 `chrome-sandbox`, never `--no-sandbox`). Close-to-background is |
| 264 | enabled only after a DBus health probe confirms a live StatusNotifierWatcher, |
| 265 | a registered visual host, and this app's registered StatusNotifierItem. If any |
| 266 | of them disappears while the main window is hidden, Reasonix presents the |
| 267 | window again until the tray recovers. |
| 268 | - **Windows** — the shell follows the OS light/dark setting. Remote Markdown |
| 269 | images are fetched by the Go backend with the configured proxy and re-served |
| 270 | from the local asset origin, so embedded images never bypass the configured |
| 271 | proxy. Image hosts must resolve locally to public addresses; direct, |
| 272 | HTTP(S)-proxy, and SOCKS-proxy connections are pinned to those vetted IPs |
| 273 | while preserving the original Host and TLS SNI. |
| 274 | - **macOS** — inset/hidden title bar; the CSS marks the top bar as an OS drag |
| 275 | region (the Electron build rewrites `--reasonix-draggable` to |
| 276 | `-webkit-app-region`) and leaves room for the traffic lights. |
| 277 | - **Renderer recovery** — the shell reloads a crashed renderer |
| 278 | (`render-process-gone`) and reports service state to the UI; a renderer that |
| 279 | never reports ready is presented anyway with a diagnostics trail instead of |
| 280 | staying hidden. |
| 281 | - **Theming** — colors are CSS variables gated on `prefers-color-scheme`, so the |
| 282 | UI follows the OS theme without native glue. |
| 283 | - **Fonts / offline** — system font stack only; no web-font fetches, so first paint |
| 284 | is instant and identical offline. |
| 285 | - **First paint** — the window background is set to the dark shell color so there's |
| 286 | no white flash before CSS loads. |
| 287 | |
| 288 | ## Files |
| 289 | |
| 290 | ``` |
| 291 | desktop/ |
| 292 | main.go service entry: host launch modes, shell bootstrap |
| 293 | host_rpc.go --host-rpc service over stdio + -emit-contract |
| 294 | app.go App (bound command surface) + eventSink (event.Sink) |
| 295 | wire.go event.Event → JSON wire form (mirrors internal/serve/wire.go) |
| 296 | electron/ Electron shell (main process, preload, browser surface) |
| 297 | packaging/ @electron/packager pipeline + packaged smoke test |
| 298 | frontend/ |
| 299 | src/ |
| 300 | lib/ |
| 301 | types.ts wire contract (mirrors wire.go) |
| 302 | bridge.ts desktop host bridge + browser dev mock |
| 303 | desktopHost.ts the only module touching window.reasonixDesktop |
| 304 | useController.ts event-stream reducer + command surface (the hook) |
| 305 | components/ |
| 306 | Transcript, Message, ToolCard, Composer, ApprovalModal, ContextGauge, |
| 307 | Markdown, CodeViewer, DiffView |
| 308 | editors/ PlainCode, PlainDiff ← editor seam impls (swap targets) |
| 309 | ``` |
| 310 | |
| 311 | ## Telemetry |
| 312 | |
| 313 | The desktop app sends one anonymous ping per launch to `crash.reasonix.io`: |
| 314 | a random anonymous install id (generated locally and not an account id), app |
| 315 | version, OS, architecture, Windows build/revision or bounded Linux |
| 316 | distribution/kernel/session facts, and the renderer engine tag. When the |
| 317 | previous process ended abnormally, the next normal launch may also send a |
| 318 | bounded native diagnostic (lifecycle phase, symbolized stack, window failure |
| 319 | kind, and coarse device facts). Reports queued by the retired WebView2/WebKitGTK |
| 320 | shell still decode and forward unchanged after upgrade. |
| 321 | Panic values are removed and paths/secrets are scrubbed before the report is |
| 322 | queued. The install id is attached only while sending and is not stored in a |
| 323 | pending crash file. It never includes conversations, account data, API keys, |
| 324 | file contents, usernames, hostnames, GPU driver details, or full local paths. |
| 325 | |
| 326 | Opt out any time: Settings > Updates > "Anonymous usage ping", or set |
| 327 | `telemetry = false` under `[desktop]` in the global config. Dev builds |
| 328 | never ping or upload queued native diagnostics. Frontend crash and |
| 329 | performance-pressure reports remain separate and are sent only when the user |
| 330 | clicks "Send report" on the diagnostic UI. |
| 331 | |
| 332 | Aggregate quality metrics are also enabled by default and can be disabled from |
| 333 | Settings > Updates > "Share aggregate quality metrics", or by setting |
| 334 | `metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket |
| 335 | counts, lifecycle/window failure buckets, and preference buckets; they never |
| 336 | include conversations, prompts, keys, paths, base URLs, or file contents. |
| 337 |