| 1 | # Desktop host protocol |
| 2 | |
| 3 | [简体中文](DESKTOP_HOST_PROTOCOL.zh-CN.md) |
| 4 | |
| 5 | The Electron shell and the Go desktop service are two processes joined by one |
| 6 | private, versioned JSON-RPC 2.0 connection over the service's stdio. This |
| 7 | document is the contract both sides implement. The Go side owns every desktop |
| 8 | business command; the Electron side owns every native surface. Neither side |
| 9 | may reach around the contract: the React UI never touches Electron or Go |
| 10 | globals, and Go business code never links a shell toolkit. |
| 11 | |
| 12 | ```text |
| 13 | React renderer ──typed IPC (preload)──▶ Electron main ──stdio JSON-RPC──▶ Go desktop service |
| 14 | ▲ │ |
| 15 | └────── host/* reverse requests ────┘ |
| 16 | ``` |
| 17 | |
| 18 | ## Transport |
| 19 | |
| 20 | - Framing: newline-delimited JSON-RPC 2.0 (`rpcwire` strict mode). One frame |
| 21 | per line, UTF-8, no batch arrays. |
| 22 | - The Go service is started as `reasonix-desktop --host-rpc`. Its stdout carries |
| 23 | only protocol frames; stderr carries logs. The shell closes stdin only after |
| 24 | `desktop/shutdown` has reported `completed`; closing stdin without that result |
| 25 | is treated as `connection_lost` and runs bounded cleanup. |
| 26 | - Limits: 64 MiB per inbound frame on both sides, 512 concurrent inbound |
| 27 | handlers on the service, 30 s write-stall watchdog. Large binary payloads never |
| 28 | travel in frames; they use the resource origin below. |
| 29 | - Every request the shell makes runs on its own goroutine, exactly as the |
| 30 | retired in-process shell |
| 31 | bound calls did. Ordering is only guaranteed for `desktop/event` frames, |
| 32 | which the service writes from one queue. |
| 33 | |
| 34 | ## Handshake |
| 35 | |
| 36 | The first request on a fresh connection must be `desktop/hello`. Anything else |
| 37 | fails with `-32002 not_ready`. |
| 38 | |
| 39 | ```jsonc |
| 40 | // shell → service |
| 41 | {"method":"desktop/hello","params":{ |
| 42 | "protocolVersion": 11, |
| 43 | "contractDigest": "sha256:…", // digest embedded in the shell bundle |
| 44 | "build": {"version":"v1.30.0","channel":"stable","commit":"abc123"}, |
| 45 | "host": {"name":"electron","version":"44.2.0","chrome":"152.0.0","platform":"darwin","arch":"arm64"}, |
| 46 | "instance": {"home":"/Users/…/.reasonix","dev":false} |
| 47 | }} |
| 48 | // service → shell |
| 49 | {"result":{ |
| 50 | "protocolVersion": 11, |
| 51 | "contractDigest": "sha256:…", |
| 52 | "service": {"version":"v1.30.0","channel":"stable","commit":"abc123","pid":4242}, |
| 53 | "runtimeGeneration": "g-01J…", // new for every service process |
| 54 | "instance": {"identityVersion":2,"identityDigest":"sha256:…","legacyId":"com.reasonix.desktop.…"}, |
| 55 | "runId": "…", "incidentId": "…", "diagnosticsEnabled": true, |
| 56 | "resources": {"origin":"http://127.0.0.1:51234","token":"…"}, |
| 57 | "window": {"width":1280,"height":820,"minWidth":760,"minHeight":480,"frameless":false,"zoomFactor":1} |
| 58 | }} |
| 59 | ``` |
| 60 | |
| 61 | `instance` is optional for cross-version compatibility. New services publish a |
| 62 | versioned digest from the shared filesystem identity resolver plus the legacy |
| 63 | instance ID. The shell consumes these opaque values for diagnostics and never |
| 64 | uses the digest as a filesystem path. Older shells ignore the object and newer |
| 65 | shells accept its omission. |
| 66 | |
| 67 | `window` is the initial main-window geometry Go derives from the saved state |
| 68 | and platform rules. Optional `position: {x, y}` carries the saved origin (zero |
| 69 | and negative coordinates are valid); omission requests centering. The shell |
| 70 | selects the matching display and fits the rectangle to its DIP work area before |
| 71 | creating the hidden window. Go later maximises and shows it from `domReady`, |
| 72 | without overriding the shell's corrected position. Persistence always captures |
| 73 | the normal-state rectangle, separately from the maximised flag; legacy oversized |
| 74 | rectangles are fitted rather than resetting every maximised entry to defaults. |
| 75 | While minimized, the shell retains its last non-minimized snapshot because |
| 76 | native normal-bounds queries can otherwise expose the maximized frame. |
| 77 | |
| 78 | The persisted JSON shape is unchanged. Older shells ignore the optional hello |
| 79 | position; newer shells accept its omission. Ship shell and service together: |
| 80 | mixed development builds do not provide the complete restore fix. Downgrading |
| 81 | can reintroduce the old geometry bug, and older readers may reject negative |
| 82 | origins below their previous validation floor. |
| 83 | |
| 84 | Failure codes are terminal: the shell shows the real error and offers |
| 85 | "open logs" and "quit". It never falls back to the browser mock. |
| 86 | |
| 87 | | Code | Name | Meaning | |
| 88 | | --- | --- | --- | |
| 89 | | `-32001` | `protocol_mismatch` | `protocolVersion` differs | |
| 90 | | `-32003` | `contract_mismatch` | command/event digest differs (mixed install) | |
| 91 | | `-32004` | `build_mismatch` | shell and service versions differ and neither is `dev` | |
| 92 | | `-32005` | `instance_mismatch` | the shell's canonical data home differs from the service's | |
| 93 | | `-32002` | `not_ready` | request before a successful hello | |
| 94 | |
| 95 | `runtimeGeneration` tags every event and every approval or browser grant |
| 96 | minted by this service process. A restarted service issues a new generation; |
| 97 | the shell discards anything tagged with an old one. |
| 98 | |
| 99 | `runId` identifies this service run. `incidentId` links service and shell |
| 100 | lifecycle evidence for the same failure chain. Both are random diagnostic |
| 101 | identifiers; they do not contain a PID, local path, or user content. When |
| 102 | diagnostics are disabled (including a `dev` service build), `diagnosticsEnabled` |
| 103 | is false and both identifiers are empty strings; the keys are always present. |
| 104 | |
| 105 | ## Lifecycle requests (shell → service) |
| 106 | |
| 107 | | Method | Params | Result | Go owner | |
| 108 | | --- | --- | --- | --- | |
| 109 | | `desktop/start` | `{}` | `{}` | `App.startup` | |
| 110 | | `desktop/domReady` | `{}` | `{}` | `App.domReady` | |
| 111 | | `desktop/rendererAttached` | `{"rendererGeneration":n}` | `{}` | frontend heartbeat/readiness | |
| 112 | | `desktop/beforeClose` | `{"reason":"window"\|"quit"\|"tray"\|"updater"}` | `{"prevent":bool}` | `App.beforeClose` | |
| 113 | | `desktop/shutdown` | `{"requestId":string,"reason":string}` | shutdown phase/result | coordinated, retryable shutdown | |
| 114 | | `desktop/shutdownStatus` | `{"requestId":string}` | same shutdown phase/result | query after timeout/unknown result | |
| 115 | | `desktop/hostEvent` | `{"name":string,"payload":any}` | `{}` | second instance, tray open/quit, menu actions | |
| 116 | | `desktop/browserControl` | `{"enabled":bool}` | `{}` | built-in browser switch, read when a session is built | |
| 117 | |
| 118 | Order: `hello` → `start` → window load → `domReady` → (`rendererAttached` after |
| 119 | each renderer mount) → … → `beforeClose` → (`shutdown` completed → stdin close |
| 120 | fallback → exit). A shutdown RPC timeout is an unknown result: the shell queries |
| 121 | `shutdownStatus` and keeps the window open on a retryable failure. An abrupt |
| 122 | stdin EOF enters the same coordinator with reason `connection_lost`; it does |
| 123 | not create a second cleanup flow after a completed shutdown. |
| 124 | |
| 125 | The shell publishes a `stopping` service phase before the shutdown RPC. During |
| 126 | that phase readiness is false and new business calls are rejected, while the |
| 127 | shutdown and shutdown-status requests retain the existing service session. |
| 128 | Clean exit removes the current temporary file under |
| 129 | `diagnostics/lifecycle`; an empty lifecycle directory after exit is expected. |
| 130 | Rotating `logs/shell.log` is the durable post-exit record. See |
| 131 | [Windows close and transcript diagnostics validation](WINDOWS_CLOSE_TRANSCRIPT_VALIDATION.md). |
| 132 | |
| 133 | ## Business commands |
| 134 | |
| 135 | ```jsonc |
| 136 | {"method":"desktop/invoke","params":{"method":"OpenProjectTab","args":["/path", true]}} |
| 137 | {"result": {...}} // the method's JSON result, null for void |
| 138 | {"error":{"code":-32000,"message":"<error text>","data":{"method":"OpenProjectTab"}}} |
| 139 | ``` |
| 140 | |
| 141 | `method` must name an exported method of the Go `App` value that the contract |
| 142 | registry accepted. Signatures follow the rules the retired shell used: any |
| 143 | JSON-serialisable |
| 144 | parameters and a result of `()`, `(T)`, `(error)` or `(T, error)`. The |
| 145 | registry rejects anything else at build time, so the surface can never gain a |
| 146 | method the shell cannot call. The shell validates `method` against the |
| 147 | embedded command list before forwarding. Unknown names fail with `-32601`. |
| 148 | |
| 149 | The generated contract (`cd desktop && go run . -emit-contract frontend/src/generated`) |
| 150 | is the single source of truth: it emits the JSON contract, its digest, the |
| 151 | TypeScript command table and the DTO type declarations consumed by the |
| 152 | renderer. A desktop Go test fails when the checked-in output drifts. |
| 153 | |
| 154 | Each command also records its source-module `domain`, exact `owner` (for |
| 155 | example `App.OpenProjectTab`), repository-relative `sources`, `scope` and |
| 156 | `cancellation`; these fields are included in the digest. The generator scans |
| 157 | all platform declarations and writes `desktop/host_command_owners.generated.json`, |
| 158 | which the host embeds and validates against every reflected command. Scope |
| 159 | records the owner's named wire `inputs` (`argN` for unnamed legacy parameters) |
| 160 | and `resolver`; zero-input commands use `owner-state`, others `owner-inputs`. |
| 161 | These are provenance and dispatch boundaries. Input validation, tab/session |
| 162 | selection and access checks remain in the existing App method. |
| 163 | |
| 164 | Current App commands declare `before-dispatch`: the host checks cancellation |
| 165 | before decoding and immediately before dispatch, then preserves the method's |
| 166 | result even if cancellation arrives during a synchronous write. They do not |
| 167 | promise interruption after dispatch. A host method may opt into |
| 168 | `cooperative-context` with a leading Go `context.Context`; the host injects |
| 169 | the request context and excludes it from JSON arguments and generated DTOs. |
| 170 | The method must cooperate with cancellation. Business Stop/Cancel commands |
| 171 | continue to use their existing owners and semantics. |
| 172 | |
| 173 | ## Events (service → shell → renderer) |
| 174 | |
| 175 | ```jsonc |
| 176 | {"method":"desktop/event","params":{"seq":1093,"generation":"g-01J…","name":"agent:event","args":[{...}]}} |
| 177 | ``` |
| 178 | |
| 179 | `args` preserves the variadic payload of the previous event bridge; most |
| 180 | events carry one element. The shell forwards the frame to the renderer on the |
| 181 | `reasonix:event` channel; the preload API `on(name, cb)` filters by `name` and |
| 182 | calls `cb(...args)`. Sequence numbers are strictly increasing per generation |
| 183 | so a renderer that re-attaches can detect a gap and re-snapshot instead of |
| 184 | trusting stale state. |
| 185 | |
| 186 | Both the service supervisor and preload reject duplicate or out-of-order |
| 187 | frames; the preload also rejects old generations using the current service |
| 188 | state. It binds the transport before React subscribes. A generation change, |
| 189 | sequence gap or missed subscription raises the shell-local `desktop:resync` |
| 190 | event (`generation`, `reason`, `expectedSeq`, `actualSeq`), which is not a Go |
| 191 | business event. Runtime state is re-read through `SyncRuntimeState`; mounted |
| 192 | controllers re-read `ListTabs` and use the existing `TurnEventsForTab` ledger |
| 193 | and pending-prompt presentation to repair their projection. Reads are fenced |
| 194 | against newer recovery requests and session/navigation changes. No business |
| 195 | mutation is replayed, and a surviving application renderer is reattached |
| 196 | after a service restart without reloading its unsent drafts. |
| 197 | |
| 198 | This recovery currently covers core runtime state, session metadata, durable |
| 199 | turn events and pending prompts. Terminal output has a bounded snapshot but |
| 200 | no atomic output cursor, so an affected terminal is visibly marked incomplete |
| 201 | instead of merging an ambiguous snapshot into live output. Extension output, |
| 202 | file-watch and other independent event streams still need capability-specific |
| 203 | resnapshot contracts; they are not covered by this core recovery guarantee. |
| 204 | |
| 205 | ## Native host calls (service → shell) |
| 206 | |
| 207 | These replace direct shell-toolkit calls in Go. Each maps to one method of the |
| 208 | Go `nativeHost` interface; the Wails implementation was retired when the Electron shell |
| 209 | landed. |
| 210 | |
| 211 | | Method | Params | Result | |
| 212 | | --- | --- | --- | |
| 213 | | `host/window.show` | `{"reason":string}` | `{}` | |
| 214 | | `host/window.hide` | `{}` | `{}` | |
| 215 | | `host/app.hide` | `{}` | `{}` (macOS application hide) | |
| 216 | | `host/window.maximise` `unmaximise` `minimise` `unminimise` `toggleMaximise` `center` | `{}` | `{}` | |
| 217 | | `host/window.isMaximised` `isMinimised` | `{}` | `{"value":bool}` | |
| 218 | | `host/window.setPosition` | `{"x":n,"y":n}` | `{}` | |
| 219 | | `host/window.setTitle` | `{"title":string}` | `{}` | |
| 220 | | `host/screen.list` | `{}` | `{"screens":[{"x","y","width","height","scale","primary"}]}` | |
| 221 | | `host/dialog.openDirectory` | `{"title","defaultDirectory"}` | `{"path":string}` (`""` = cancelled) | |
| 222 | | `host/dialog.openFile` | `{"title","defaultDirectory","filters":[{"displayName","pattern"}],"multiple":bool}` | `{"paths":[]}` | |
| 223 | | `host/dialog.saveFile` | `{"title","defaultDirectory","defaultFilename","filters"}` | `{"path":string}` | |
| 224 | | `host/dialog.message` | `{"type":"info"\|"warning"\|"error"\|"question","title","message","buttons":[],"defaultButton","cancelButton"}` | `{"button":string}` | |
| 225 | | `host/shell.openExternal` | `{"url":string}` | `{}` | |
| 226 | | `host/app.quit` | `{}` | `{}` | |
| 227 | | `host/app.relaunch` | `{"args":[],"execPath"?:string}` | `{}` | |
| 228 | | `host/devtools.toggle` | `{}` | `{}` | |
| 229 | | `host/remoteWindow.open` | `{"hostKey","url","title"}` | `{"windowId":string}` | |
| 230 | | `host/remoteWindow.navigate` | `{"hostKey","url","title"}` | `{}` | |
| 231 | | `host/remoteWindow.focus` `close` | `{"hostKey"}` | `{}` | |
| 232 | | `host/tray.ensure` | `{"openTitle","openTooltip","quitTitle","quitTooltip","tooltip"}` | `{"ready":bool,"reason":string}` | |
| 233 | |
| 234 | `host/shell.openExternal` accepts only `http:`, `https:`, and `mailto:` URLs. |
| 235 | Other schemes, including `file:`, `javascript:`, and `data:`, are rejected at |
| 236 | the Electron host boundary before the system opener is invoked. |
| 237 | | `host/tray.destroy` | `{}` | `{}` | |
| 238 | | `host/browser.grant` `revoke` | `{"grantId","tabId","sessionId"}` / `{"grantId"}` | `{}` | |
| 239 | | `host/browser.tabs.list` | `{"grantId"}` | `{"tabs":[{"id","url","title","loading","temporary"}]}` | |
| 240 | | `host/browser.tabs.open` | `{"grantId","url","temporary"}` | tab | |
| 241 | | `host/browser.tabs.navigate` | `{"grantId","tabId","url","action"}` | tab | |
| 242 | | `host/browser.tabs.close` | `{"grantId","tabId"}` | `{}` | |
| 243 | | `host/browser.snapshot` | `{"grantId","tabId","selector"}` | `{"documentToken","url","title","tree","refs"}` | |
| 244 | | `host/browser.act` | `{"grantId","operationId","tabId","documentToken","action","ref","text","keys","options","files","submit","deltaX","deltaY"}` | `{"executed","reason","documentToken"}` | |
| 245 | | `host/browser.screenshot` | `{"grantId","tabId","ref","fullPage","directory"}` | `{"path","mime","width","height"}` | |
| 246 | | `host/browser.downloads` | `{"grantId","tabId","waitForMs"}` | `{"downloads":[{"id","url","path","state","bytes"}]}` | |
| 247 | |
| 248 | Browser calls fail with `-32010` (stale reference), `-32011` (the user took the |
| 249 | tab over) or `-32012` (no current grant); the Go executor maps them onto the |
| 250 | kernel sentinels and records the operation outcome in its ledger. Grant |
| 251 | `tabId` is the desktop tab (the task); browser tabs opened under that grant |
| 252 | belong to it. |
| 253 | |
| 254 | Host events (`desktop/hostEvent`): `tray.open`, `tray.quit`, `secondInstance` |
| 255 | (raw argv in `payload`), `menu.showWindow`, `remoteWindow.closed` |
| 256 | (`{"hostKey"}`), `browser.takeover` (`{"tabId","epoch","reason"}`). |
| 257 | |
| 258 | Dialog results never expose file contents; they return paths that Go then |
| 259 | authorises through the existing workspace and media checks. |
| 260 | |
| 261 | ## Resource origin |
| 262 | |
| 263 | The service listens on a loopback port for the existing authorised asset |
| 264 | handlers (`/__reasonix_workspace_media/…`, `/__reasonix_theme_asset/…`, the |
| 265 | remote markdown image proxy). The shell serves the packaged UI from the |
| 266 | privileged `reasonix://app/` scheme and forwards only those prefixes to the |
| 267 | resource origin, adding `Authorization: Bearer <token>` in the main process. |
| 268 | The token never reaches the renderer, a website view, a remote window or an |
| 269 | MCP App frame. Go keeps every file-identity and TTL check it has today. |
| 270 | |
| 271 | ## Renderer preload API |
| 272 | |
| 273 | The trusted preload exposes exactly one object, `window.reasonixDesktop`: |
| 274 | |
| 275 | ```ts |
| 276 | interface ReasonixDesktopHost { |
| 277 | readonly kind: "electron"; |
| 278 | readonly contract: { protocolVersion: number; digest: string; commands: readonly string[] }; |
| 279 | readonly platform: { os: "darwin" | "windows" | "linux"; arch: string; versions: Record<string, string> }; |
| 280 | invoke(method: string, args: unknown[]): Promise<unknown>; |
| 281 | on(name: string, cb: (...args: unknown[]) => void): () => void; |
| 282 | native: { |
| 283 | openExternal(url: string): Promise<void>; |
| 284 | clipboard: { writeText(text: string): Promise<boolean>; readText(): Promise<string> }; |
| 285 | window: { |
| 286 | setTheme(theme: "system" | "light" | "dark"): void; |
| 287 | setBackgroundColour(r: number, g: number, b: number, a: number): void; |
| 288 | getBounds(): Promise<{ x: number; y: number; width: number; height: number; maximised: boolean }>; |
| 289 | isMaximised(): Promise<boolean>; |
| 290 | minimise(): void; toggleMaximise(): void; close(): void; |
| 291 | }; |
| 292 | getPathForFile(file: File): string; // native drop paths |
| 293 | onServiceState(cb: (state: ServiceState) => void): () => void; |
| 294 | browserControl: { // settings page for the built-in browser |
| 295 | get(): Promise<BrowserControlState | null>; |
| 296 | setEnabled(enabled: boolean): Promise<BrowserControlState>; |
| 297 | setIgnoreCertificateErrors(enabled: boolean): Promise<BrowserControlState>; |
| 298 | clearCache(): Promise<void>; // keeps cookies and site data |
| 299 | clearAllData(): Promise<void>; // cookies, site data and cache |
| 300 | importChromeLogin(): Promise<ChromeImportOutcome>; |
| 301 | }; |
| 302 | }; |
| 303 | browser: { // user-driven browser panel; agent calls go through Go |
| 304 | list(): Promise<BrowserTabView[]>; |
| 305 | open(url: string, opts?: { temporary?: boolean; taskId?: string }): Promise<BrowserTabView>; |
| 306 | close(tabId: string): Promise<void>; |
| 307 | activate(tabId: string | null): Promise<void>; |
| 308 | navigate(tabId: string, target: { url?: string; action?: "back" | "forward" | "reload" | "stop" }): Promise<void>; |
| 309 | setZoom(tabId: string, factor: number): Promise<void>; |
| 310 | toggleDevTools(tabId: string): Promise<void>; |
| 311 | resume(tabId: string): Promise<void>; // hand a taken-over tab back to the agent |
| 312 | setLayout(rect: { x: number; y: number; width: number; height: number } | null): void; |
| 313 | setOverlay(active: boolean): void; // app overlays hide every website view |
| 314 | onTabs(cb: (tabs: BrowserTabView[]) => void): () => void; |
| 315 | onDownload(cb: (download: BrowserDownloadView) => void): () => void; |
| 316 | }; |
| 317 | } |
| 318 | ``` |
| 319 | |
| 320 | `BrowserTabView` is `{ id, taskId, url, title, loading, canGoBack, canGoForward, |
| 321 | temporary, mode: "agent" | "human", epoch, zoom, error }` and |
| 322 | `BrowserDownloadView` is `{ id, tabId, url, filename, path, state, received, |
| 323 | total }`. Website views live in `persist:browser` (shared logins) or |
| 324 | `temp:<id>` partitions and never receive the application preload. |
| 325 | |
| 326 | `ServiceState` is `{ phase: "starting" | "ready" | "restarting" | "failed" | "exited"; generation: string; error?: string }`. |
| 327 | Business components import the typed SDK, never this object; only the bridge |
| 328 | adapter reads it. |
| 329 | |
| 330 | `BrowserControlState` is `{ controlEnabled, ignoreCertificateErrors, writable, |
| 331 | warning: "invalid-config" | "unreadable-config" | "unsupported-version" | null }` |
| 332 | and `ChromeImportOutcome` is either `{ ok: true, profile, cookies, skipped }` or |
| 333 | `{ ok: false, reason }` with `reason` one of `chrome-missing`, |
| 334 | `profile-not-found`, `cookies-unreadable`, `safe-storage-denied`, |
| 335 | `safe-storage-unavailable`, `unsupported-platform`. |
| 336 | |
| 337 | ## Performance diagnostics |
| 338 | |
| 339 | The optional native calls below are restricted to the trusted app main frame. |
| 340 | Older shells may omit them. No persisted user-data format changes or migrations |
| 341 | are required. |
| 342 | |
| 343 | - `processDiagnostics()` returns `{scope: "electron", samples, growth}`. |
| 344 | Samples contain age, nullable CPU interval, process PID/type/creation time, |
| 345 | nullable CPU percentage, working set and private memory in MiB, and a |
| 346 | truncation flag. Sampling is limited to once per 30 seconds in the foreground |
| 347 | and once per 60 seconds otherwise. Retention is at most 12 snapshots and five |
| 348 | minutes, with at most 128 processes per snapshot. No titles, URLs or process |
| 349 | names are collected. Electron-managed processes only; Go is excluded. |
| 350 | - `captureRendererProfile(requestId?)` records the current renderer through CDP for |
| 351 | five seconds at a requested 10 ms sample interval. It returns a status, |
| 352 | duration and at most eight app-script self-time summaries. Normal documents |
| 353 | do not enable JS self-profiling. Capture is single-flight, requires the |
| 354 | foreground window, observes a ten-minute cooldown, and allows at most three |
| 355 | attempts per shell lifetime. Existing debugger/DevTools sessions are not |
| 356 | taken over. Blur, hide, navigation, renderer loss or cancellation stops it. |
| 357 | - `cancelRendererProfile(requestId)` cancels only the matching capture; unscoped |
| 358 | renderer cancellation is ignored. This also fences delayed requests across |
| 359 | long suspension/resume gaps. Each CDP command has a |
| 360 | 1.5 second deadline and the owned debugger is released on every terminal path. |
| 361 | Analysis runs in a disposable Worker with a 32 MiB old-generation limit, |
| 362 | 1.5 second deadline, and input limits of 20,000 nodes / 100,000 samples. |
| 363 | Raw profiles never enter the UI report. |
| 364 | - `exportHeapSnapshot()` requires a user-confirmed native warning and save |
| 365 | dialog. It saves locally without uploading, and accepts no renderer-supplied |
| 366 | path. Snapshots may contain code, chats and secrets and can pause the renderer |
| 367 | or use substantial disk space. Electron cannot preempt a snapshot: its busy |
| 368 | lease remains held until the actual operation settles. |
| 369 | |
| 370 | A memory growth signal requires a continuous PID plus creation-time identity, |
| 371 | at least five readings spanning two minutes, and three recent readings exceeding |
| 372 | the initial two-reading baseline by both 256 MiB and 50%. Private memory is used |
| 373 | when available throughout; otherwise working set is used. This is an observation |
| 374 | of sustained growth, not proof of a leak or exclusive physical RAM ownership. |
| 375 | |
| 376 | Reports appear immediately. Process enrichment waits at most 750 ms; a bounded |
| 377 | CPU capture can update the same report later. The UI abandons capture enrichment |
| 378 | after 12 seconds and requests cancellation. These are asynchronous deadlines, |
| 379 | not preemptive limits on synchronous work. Dismissed reports never reappear. |
| 380 | The report distinguishes post-trigger samples from the already-ended long task. |
| 381 | User-requested heap capture suppresses pressure alerts during capture and for |
| 382 | the normal five-second settling grace afterward. |
| 383 | |
| 384 | From `desktop/electron`, run `node scripts/performance-smoke.mjs` to verify the |
| 385 | production owner, Worker, report enrichment and local heap snapshot with an |
| 386 | isolated native fixture. `node scripts/performance-benchmark.mjs` compares off, |
| 387 | lightweight monitoring and short capture in three fresh-process trials each. |
| 388 | All modes use the same renderer bundle and runtime mode selection. Activity |
| 389 | signals are pinned and background throttling disabled for unattended native |
| 390 | measurement. Host event tests separately cover the production focus and |
| 391 | navigation cancellation policy; the smoke verifies actual CDP and ASAR paths. |
| 392 | It records CPU time where available, frame timings, working sets and metric |
| 393 | collection cost in `artifacts/performance/overhead.json`. This synthetic |
| 394 | benchmark is not a reproduction of the Windows user workload. Field comparison |
| 395 | must still cover startup, extended use, foreground return and closing tabs. |
| 396 | |
| 397 | ## Security boundaries |
| 398 | |
| 399 | - The application window: sandbox on, context isolation on, Node integration |
| 400 | off, `reasonix://app` only, preload above. |
| 401 | - Website views, remote Serve windows and MCP App frames: separate sessions, |
| 402 | no preload from the application, no `reasonix://` access, no `host/*` reach. |
| 403 | - IPC handlers accept requests only from the application window's |
| 404 | `webContents`. Any other sender is rejected and logged. |
| 405 | - `desktop/invoke` names outside the embedded contract fail before reaching Go. |
| 406 |