| 1 | # Reasonix Desktop (Wails shell) |
| 2 | |
| 3 | A native desktop window around the Reasonix Go kernel. The same |
| 4 | transport-agnostic `control.Controller` that backs the chat TUI and the HTTP/SSE |
| 5 | server is bound **directly** to a React webview — Go methods in, typed events |
| 6 | out, no HTTP hop. |
| 7 | |
| 8 | ``` |
| 9 | ┌─────────────────────────────────────────────────────────────┐ |
| 10 | │ webview (React + TS, Vite) │ |
| 11 | │ bridge.ts ──calls──▶ window.go.main.App.{Submit,Cancel,…} │ |
| 12 | │ bridge.ts ◀─events── window.runtime.EventsOn("agent:event")│ |
| 13 | └───────────────▲───────────────────────────┬─────────────────┘ |
| 14 | bound methods runtime.EventsEmit |
| 15 | ┌───────────────┴───────────────────────────▼─────────────────┐ |
| 16 | │ desktop/app.go App (bound) + eventSink (event.Sink) │ |
| 17 | │ desktop/main.go Wails options, window, embed frontend/dist │ |
| 18 | └───────────────▲───────────────────────────┬─────────────────┘ |
| 19 | commands │ │ typed event stream |
| 20 | ┌───────────────┴────────────────────────────▼────────────────┐ |
| 21 | │ internal/boot.Build → internal/control.Controller (kernel) │ |
| 22 | │ (same assembly the CLI uses: providers, tools, gate, …) │ |
| 23 | └──────────────────────────────────────────────────────────────┘ |
| 24 | ``` |
| 25 | |
| 26 | ## Why a nested module |
| 27 | |
| 28 | `desktop/` is its own Go module (`module reasonix/desktop`, `replace reasonix => |
| 29 | ../`). That keeps the CGO + WebKit desktop build entirely separate from the CLI's |
| 30 | `CGO_ENABLED=0` single-static-binary guarantee: the parent module's `go build / |
| 31 | vet / test ./...` skip this directory, while the import path stays under |
| 32 | `reasonix/` so it can still import the `reasonix/internal/*` kernel. |
| 33 | |
| 34 | ## Prerequisites |
| 35 | |
| 36 | - Go (matches the parent module). |
| 37 | - Node + **pnpm** (`npm i -g pnpm`). |
| 38 | - Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest` |
| 39 | - Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2** |
| 40 | runtime; Linux needs `libgtk-3-dev` plus WebKitGTK. The default build links |
| 41 | against **WebKitGTK 4.0**; distros that only ship **4.1** (Fedora 40+, Ubuntu |
| 42 | 24.04+, Arch) build with `-tags webkit2_41` — see [Build](#build). Run |
| 43 | `wails doctor` to verify. |
| 44 | |
| 45 | ## Develop |
| 46 | |
| 47 | ```sh |
| 48 | cd desktop |
| 49 | wails dev # hot-reloads Go + frontend (Vite dev server) |
| 50 | ``` |
| 51 | |
| 52 | Frontend-only iteration without the Go side: |
| 53 | |
| 54 | ```sh |
| 55 | cd desktop/frontend |
| 56 | pnpm install |
| 57 | pnpm dev # opens in a plain browser; bridge.ts uses the dev mock |
| 58 | ``` |
| 59 | |
| 60 | In a plain browser the native bindings are absent, so `bridge.ts` falls back to a |
| 61 | **mock** that streams a canned turn (text + one `edit_file` tool call) through the |
| 62 | exact same event contract — so layout, streaming, markdown, tool cards, and the |
| 63 | diff seam can all be built without rebuilding Go. |
| 64 | |
| 65 | ## Test |
| 66 | |
| 67 | The desktop package is a nested Go module, so parent `go test ./...` does not run |
| 68 | it. Use the full lane before merging desktop changes, and the short lane for fast |
| 69 | local feedback: |
| 70 | |
| 71 | ```sh |
| 72 | make desktop-test # cd desktop && go test . |
| 73 | make desktop-test-short # skips slow desktop integration/e2e checks |
| 74 | ``` |
| 75 | |
| 76 | To find the next bottleneck, rank individual test cases from the JSON stream: |
| 77 | |
| 78 | ```sh |
| 79 | make desktop-test-times |
| 80 | # or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py |
| 81 | ``` |
| 82 | |
| 83 | ### Frontend UI review checklist |
| 84 | |
| 85 | For anchored menus, dropdowns, tooltips, and other portaled UI, review both the |
| 86 | component code and the CSS positioning contract: |
| 87 | |
| 88 | - If a component uses `createPortal` plus `getBoundingClientRect()`, it must |
| 89 | handle scrollable ancestors, window resize, and `visualViewport` changes. |
| 90 | - Add a focused regression test when changing shared positioning primitives such |
| 91 | as `AnchoredPopover`, not only the specific menu that exposed the bug. |
| 92 | - Exercise at least one scrollable container path, such as Settings content, when |
| 93 | manually checking dropdown or popover changes. |
| 94 | |
| 95 | ## Build |
| 96 | |
| 97 | ```sh |
| 98 | cd desktop |
| 99 | wails build # → build/bin/Reasonix(.app/.exe) |
| 100 | ``` |
| 101 | |
| 102 | **Linux on WebKitGTK 4.1 only** (Fedora 40+, Ubuntu 24.04+, Arch — no |
| 103 | `webkit2gtk-4.0` package): pass the Wails build tag so cgo links against 4.1. |
| 104 | |
| 105 | ```sh |
| 106 | wails build -tags webkit2_41 |
| 107 | wails dev -tags webkit2_41 # same tag for hot-reload |
| 108 | ``` |
| 109 | |
| 110 | Fedora deps: `sudo dnf install webkit2gtk4.1-devel gtk3-devel`. |
| 111 | |
| 112 | `frontend/dist` is generated by the build (it's git-ignored except for a |
| 113 | `.gitkeep` that keeps the Go `//go:embed all:frontend/dist` compilable on a fresh |
| 114 | checkout). A bare `go build` without a prior `pnpm build` produces a blank window. |
| 115 | |
| 116 | ## Releases & auto-update |
| 117 | |
| 118 | Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*` |
| 119 | tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`, |
| 120 | which builds on a native runner per platform (Wails can't cross-compile a |
| 121 | CGO/WebKit binary), packages each artifact, signs it with minisign, generates a |
| 122 | `latest.json` manifest, publishes a GitHub release, marks the desktop release as |
| 123 | GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the |
| 124 | current desktop manifest to the matching CLI release for old clients that still |
| 125 | ask GitHub's repository-wide `latest` release for it. |
| 126 | The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs |
| 127 | `libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+. |
| 128 | |
| 129 | ```sh |
| 130 | git tag desktop-v1.1.0 && git push origin desktop-v1.1.0 |
| 131 | ``` |
| 132 | |
| 133 | The app checks `latest.json` on startup (R2 first, then the |
| 134 | `crash.reasonix.io` desktop release gateway) and shows an update banner when a |
| 135 | newer version is published; **Settings → Software update** has a manual check. |
| 136 | The gateway resolves only the desktop `desktop-v*` release line and never uses |
| 137 | GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does |
| 138 | not depend on homepage badge semantics. Self-update behavior by platform: |
| 139 | |
| 140 | - **Linux portable (`.tar.gz`)** — download, verify the minisign signature, replace |
| 141 | the binaries in the install directory, and relaunch through Guard. No elevation. |
| 142 | - **Linux Debian/Ubuntu (`.deb`)** — download the signed `.deb`, request administrator |
| 143 | authorization via Polkit (`pkexec`), re-verify and install with `apt-get |
| 144 | --only-upgrade`, then relaunch through Guard. The first build that ships the |
| 145 | update helper and Polkit policy is a one-time bootstrap: existing `.deb` users |
| 146 | should overwrite-install once with |
| 147 | `sudo apt install ./Reasonix-linux-amd64.deb` (no uninstall required). After |
| 148 | that, in-app authorized updates work. If Polkit/`pkexec` is unavailable, use |
| 149 | the same manual command. Failed installs leave the running app intact so you |
| 150 | can retry; successful installs are managed by apt/dpkg and are not auto-downgraded. |
| 151 | - **Windows** — download, verify the minisign signature, then run the per-user |
| 152 | NSIS installer (no admin rights needed). |
| 153 | - **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an |
| 154 | in-place swap would be blocked by Gatekeeper; the banner links to the download |
| 155 | page for a manual update instead. |
| 156 | |
| 157 | ### Code signing — first launch |
| 158 | |
| 159 | - **Windows** — stable builds carry an Authenticode signature (SignPath, approved |
| 160 | per release; `release-desktop.yml` verifies every payload binary through |
| 161 | `scripts/verify-windows-authenticode.ps1` and fails the release otherwise). A |
| 162 | brand-new version can still show SmartScreen until the signature accumulates |
| 163 | reputation: *More info → Run anyway*. |
| 164 | - **macOS** — still unsigned and un-notarized. Open |
| 165 | `Reasonix-darwin-universal.dmg`, drag Reasonix into Applications, then clear the |
| 166 | quarantine attribute when Gatekeeper reports the app "is damaged" or is from an |
| 167 | unidentified developer: |
| 168 | ```sh |
| 169 | xattr -dr com.apple.quarantine /Applications/Reasonix.app |
| 170 | ``` |
| 171 | This is also why macOS has no in-place self-update: the swap would be blocked. |
| 172 | Adding a Developer ID certificate flips the release workflow's `HAS_APPLE_CERT` |
| 173 | gate to the signed path and removes both. |
| 174 | |
| 175 | ### Verifying a download |
| 176 | |
| 177 | Artifacts are signed with minisign (public key ID `AF12CA46F4A9EBB0`). The `.minisig` |
| 178 | signature sits next to each artifact in the release; verify with the |
| 179 | [minisign](https://jedisct1.github.io/minisign/) CLI: |
| 180 | |
| 181 | ```sh |
| 182 | minisign -Vm Reasonix-darwin-arm64.zip \ |
| 183 | -P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq |
| 184 | ``` |
| 185 | |
| 186 | ## Editor seams and workspace file previews |
| 187 | |
| 188 | Code and diff rendering go through two components with stable prop contracts and |
| 189 | lazy boundaries, so heavier viewers stay out of the initial bundle. `CodeViewer` |
| 190 | keeps the compact highlighted viewer for chat, Markdown, and tool output, while |
| 191 | workspace file previews opt into the searchable line-number viewer: |
| 192 | |
| 193 | | Component | Props | Default impl | Upgrade | |
| 194 | |---|---|---|---| |
| 195 | | `components/CodeViewer.tsx` | `EditorProps` | `editors/HljsCode.tsx`; `editors/LineNumberCode.tsx` when `showLineNumbers` is enabled | extend the implementation selection for Monaco or CodeMirror | |
| 196 | | `components/DiffView.tsx` | `DiffProps` | `editors/HljsDiff.tsx` (highlighted LCS/unified diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` | |
| 197 | |
| 198 | ```sh |
| 199 | # Monaco |
| 200 | pnpm add @monaco-editor/react monaco-editor |
| 201 | # or CodeMirror 6 |
| 202 | pnpm add @uiw/react-codemirror @codemirror/lang-javascript @codemirror/merge |
| 203 | ``` |
| 204 | |
| 205 | Then add `editors/MonacoCode.tsx` (default-export a component taking |
| 206 | `EditorProps`) and update the implementation selection in `CodeViewer.tsx`. |
| 207 | `ToolCard` already routes `edit_file` calls' `old_string`/`new_string` through |
| 208 | `DiffView`, and `Markdown` routes fenced code blocks through `CodeViewer`, so |
| 209 | both seams light up everywhere at once. |
| 210 | |
| 211 | `WorkspacePanel` passes `showLineNumbers` for text-file previews. The resulting |
| 212 | viewer provides a line-number gutter, viewer-scoped Ctrl/Cmd+F search with case |
| 213 | and whole-word options, copy support, and virtualized rendering above 100 lines. |
| 214 | Search marks are applied only to visible rows so query input does not rebuild the |
| 215 | entire highlighted document. Files above 512 KiB or 20,000 lines keep line |
| 216 | numbers, search, copy, and virtualization but use escaped plain text instead of |
| 217 | syntax highlighting. Workspace files are previewed up to 2 MiB; larger files |
| 218 | display the first 2 MiB with a localized truncation notice. |
| 219 | |
| 220 | ## Multi-platform adaptation |
| 221 | |
| 222 | Wails is the right shell for a Go kernel (no sidecar), but a Go+webview stack uses |
| 223 | the **native** webview per OS, so the rough edges are platform-specific. What's |
| 224 | handled here, and what to reach for if a target misbehaves: |
| 225 | |
| 226 | - **Linux / WebKitGTK** is the one real pain point — rendering varies by distro & |
| 227 | GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node |
| 228 | is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions |
| 229 | that cannot access `/dev/dri`. If artifacts persist, launch with |
| 230 | `WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release; |
| 231 | the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there). |
| 232 | - **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can |
| 233 | crash at startup (`Error 71: Protocol error`) due to an upstream WebKit |
| 234 | explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179). |
| 235 | Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects |
| 236 | Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`. |
| 237 | Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance) |
| 238 | or `GDK_BACKEND=x11` (forces XWayland). |
| 239 | - **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark |
| 240 | setting; the installer embeds the WebView2 bootstrapper. Canary builds disable |
| 241 | WebView2 GPU acceleration by default to smoke-test blank-window reports; set |
| 242 | `REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or |
| 243 | off. The WebView2 shell always uses a direct connection for embedded assets |
| 244 | and loopback remote-workspace pages; provider and other outbound traffic keeps |
| 245 | using Reasonix's own proxy configuration. Remote Markdown images are fetched |
| 246 | by the Go backend with the same proxy settings and re-served from the local |
| 247 | asset origin, so WebView2 never bypasses the configured proxy for them. Image |
| 248 | hosts must resolve locally to public addresses; direct, HTTP(S)-proxy, and |
| 249 | SOCKS-proxy connections are pinned to those vetted IPs while preserving the |
| 250 | original Host and TLS SNI. If the DOM is still not ready after 15 seconds, the |
| 251 | hidden startup window is shown with a native recovery prompt. |
| 252 | - **macOS / WebKit** — inset/hidden title bar (`TitleBarHiddenInset`); the CSS |
| 253 | marks the top bar as an OS drag region (`--wails-draggable: drag`) and leaves |
| 254 | room for the traffic lights. |
| 255 | - **Theming** — colors are CSS variables gated on `prefers-color-scheme`, which all |
| 256 | three webviews honor, so the UI follows the OS theme without native glue. |
| 257 | - **Fonts / offline** — system font stack only; no web-font fetches, so first paint |
| 258 | is instant and identical offline. |
| 259 | - **First paint** — the window background is set to the dark shell color so there's |
| 260 | no white flash before CSS loads (most visible on WebKitGTK). |
| 261 | |
| 262 | ## Files |
| 263 | |
| 264 | ``` |
| 265 | desktop/ |
| 266 | main.go Wails options, window, embed frontend/dist |
| 267 | app.go App (bound command surface) + eventSink (event.Sink → webview) |
| 268 | wire.go event.Event → JSON wire form (mirrors internal/serve/wire.go) |
| 269 | wails.json Wails project config (pnpm install/build/dev) |
| 270 | frontend/ |
| 271 | src/ |
| 272 | lib/ |
| 273 | types.ts wire contract (mirrors wire.go) |
| 274 | bridge.ts window.go/window.runtime wrapper + browser dev mock |
| 275 | useController.ts event-stream reducer + command surface (the hook) |
| 276 | components/ |
| 277 | Transcript, Message, ToolCard, Composer, ApprovalModal, ContextGauge, |
| 278 | Markdown, CodeViewer, DiffView |
| 279 | editors/ PlainCode, PlainDiff ← editor seam impls (swap targets) |
| 280 | ``` |
| 281 | |
| 282 | ## Telemetry |
| 283 | |
| 284 | The desktop app sends one anonymous ping per launch to `crash.reasonix.io`: |
| 285 | a random install id (generated locally, tied to nothing), app version, OS, |
| 286 | arch, and OS version. When the previous process ended abnormally, the next |
| 287 | normal launch may also send a bounded native diagnostic (lifecycle phase, |
| 288 | symbolized stack, WebView2/window failure kind, and coarse device facts). |
| 289 | Panic values are removed and paths/secrets are scrubbed before the report is |
| 290 | queued. It never includes conversations, API keys, or file contents. |
| 291 | |
| 292 | Opt out any time: Settings > Updates > "Anonymous usage ping", or set |
| 293 | `telemetry = false` under `[desktop]` in the global config. Dev builds |
| 294 | never ping or upload queued native diagnostics. Frontend crash and |
| 295 | performance-pressure reports remain separate and are sent only when the user |
| 296 | clicks "Send report" on the diagnostic UI. |
| 297 | |
| 298 | Aggregate quality metrics are also enabled by default and can be disabled from |
| 299 | Settings > Updates > "Share aggregate quality metrics", or by setting |
| 300 | `metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket |
| 301 | counts, lifecycle/window failure buckets, and preference buckets; they never |
| 302 | include conversations, prompts, keys, paths, base URLs, or file contents. |
| 303 |