A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default https://sim.ai) as a normal top-level page in a bundled, pinned Chromium — rendering is identical to Chrome of that version on every machine. No UI is re-implemented and no server stack is bundled.
src/main/ # main process (bundled to dist/main.cjs)
index.ts # lifecycle + wiring
config.ts # origin + settings store (userData/settings.json)
navigation.ts # navigation classifier + openExternalSafe
windows.ts # window.open policy (single-window, MCP popup, blank children)
window.ts # secure BrowserWindow, permissions, crash/hang recovery
security-guards.ts# global web-contents guards, TLS policy
handoff.ts # 127.0.0.1 loopback login handoff + token redeem
session-lifecycle.ts # sign-out teardown, 401 watcher, connect intercept
load-health.ts # offline/error page, auto-retry, watchdog
local-filesystem.ts # session-scoped read-only directory grants + localfs:// broker
downloads.ts # will-download handling
context-menu.ts # native right-click + spellcheck
telemetry-policy.ts # third-party analytics blocking
observability.ts # JSONL event log (userData/logs/desktop-events.log)
updater.ts # electron-updater wiring, channels, downgrade/block guards
menu.ts # role-based macOS menus
src/preload/ # contextBridge IPC bridge (bundled to dist/preload.cjs)
static/ # bundled local pages (offline.html)
e2e/ # Playwright _electron smoke suite
bun install # workspace root
cd apps/desktop
bun run dev # bundle + launch against https://sim.ai
SIM_DESKTOP_ORIGIN=http://localhost:3000 bun run dev # against local simbun run test— vitest unit suite (electron is mocked; runs anywhere).bun run test:e2e— Playwright_electronsmoke suite against a fixture origin (macOS, real Electron window).bun run type-check/lint:check— standard workspace checks; CI picks these up automatically viaturbo run.SIM_DESKTOP_USER_DATA=<dir>isolates settings/partition state (used by e2e).
Everything is bundled by esbuild into dist/main.cjs + dist/preload.cjs — including electron-updater and the @sim/* packages — so the packaged app has no runtime node_modules and electron-builder needs no lockfile/npmRebuild step (this is the deliberate workaround for Bun ↔ electron-builder friction; there is no package-lock.json).
- The app loads the hosted origin top-level; better-auth session cookies live in a persistent partition (
persist:sim, per-origin for self-hosts). Email/password and verified-lenient providers (GitHub) sign in fully in-window. - Google / Microsoft / SSO cannot OAuth inside an embedded browser (
disallowed_useragent; UA spoofing is fingerprint-defeated — do not ship it). Navigation to those hosts from an auth surface is intercepted and rerouted through the system-browser handoff:- App starts a one-shot
127.0.0.1loopback listener on an ephemeral port and opens<origin>/desktop/auth?state=<random>&port=<port>in the browser. The state is single-use, in-memory (the app is always running when the callback returns, so nothing is persisted), and constant-time compared. apps/sim/app/desktop/auth/page.tsxrequires a browser session (redirects through/login?callbackUrl=…), mints a better-auth one-time token, and redirects straight to the loopback (http://127.0.0.1:<port>/auth/callback?token=…&state=…, RFC 8252 §7.3). This is the single hand-back channel — a deterministic server redirect, no OS scheme registration, no client-side step, works identically in dev and packaged builds. Interception of loopback is mitigated the way PKCE mitigates it: the token is single-use, short-TTL, and bound to a 128-bit state the app compares in constant time. (RFC 8252's most-preferred callback is claimed-https/ macOS universal links, which bind the OS to a verified app identity — a future hardening step that needs an associated-domains entitlement +apple-app-site-associationon the origin.)- The loopback fires the callback in the main process: the app validates the state, then a renderer in the app partition POSTs the token to
/api/auth/one-time-token/verify(same-origin ⇒ trustedOrigins/CSRF pass; better-auth sets the session cookie and burns the token) and loads/workspace.
- App starts a one-shot
- Integration connects are same-window redirects (
client.oauth2.link), not popups. Unknown provider hosts stay in-window (lenient default); Google/Microsoft connects get a native dialog offering to finish in the browser — the browser is signed in after the login handoff, tokens land server-side, the app just refreshes. - The MCP OAuth popup (
mcp-oauth-*) is allowed as a same-partition child sowindow.opener.postMessagekeeps working. - Sign-out clears cookies/localStorage/IndexedDB/cache/service workers plus any pending handoff state. Two signals trigger it: the
/login?fromLogout=truenavigation (fast path) and deletion of the better-auth session cookie confirmed by aget-sessionprobe (robust backstop — catches every sign-out path, not just the settings one, and rotation can't cause a false teardown). API 401s (probe-confirmed) surface a native re-auth prompt.
Deviations from the original plan doc (deliberate):
- One hand-back channel, not two. The plan proposed a
sim://deep link with a loopback fallback; that was collapsed to loopback-only. The app is always running when the callback returns (it started the loopback), so the custom scheme added complexity and a dev-only failure mode without buying anything — loopback works identically everywhere. Nosim://scheme is registered. - No launch-time session probe; the server's own redirect to
/logincovers the signed-out launch, and the last route is restored otherwise. - Browser-initiated
/desktop/authvisits without a validstate+portrender a friendly error and never mint a token.
Host lists live in src/main/navigation.ts (SYSTEM_BROWSER_IDP_HOSTS, IN_WINDOW_IDP_HOSTS). Verified so far: Google + Microsoft blocked (by policy, not spike); GitHub assumed lenient. Before GA, run the spike: GitHub sign-in, consumer-Microsoft, a sample of integration connects (Notion, Slack, Linear, Atlassian, Box, Dropbox), SSO, and Turnstile-on-signup in a packaged build, then update the lists and this section.
A thin shell over a hosted web app unavoidably knows a few of the web app's conventions. They are listed here so a web-app change that would break the desktop app is auditable in one place. Each is a documented, deliberate coupling — not accidental. The robust long-term de-coupling for all of them is a two-way preload bridge (see "Desktop-only features" below): the web app signals intent (signalLogout(), markAuthSurface()) instead of the shell inferring it.
| Shell code | Depends on | Breaks if the web app… | Failure mode | Mitigation today |
|---|---|---|---|---|
session-lifecycle.ts isLogoutNavigation |
/login?fromLogout=true on sign-out |
renames the param/route | fast-path teardown misses | Cookie backstop (session-cookie deletion + probe) still tears down — no residue |
session-lifecycle.ts isSessionCookieName |
better-auth cookie ends session_token |
changes the cookie name/prefix | backstop misses (fast path still works for settings sign-out) | better-auth library contract; stable. Revisit on better-auth major |
navigation.ts AUTH_SURFACE_PREFIXES |
auth routes /login /signup /sso /reset-password /verify |
adds/renames an auth route | SSO from the new route gets the connect dialog instead of login | Update the list; unknown hosts from non-auth pages still default sensibly |
navigation.ts IdP host lists |
provider OAuth hostnames + embedded-UA policy | a provider changes hostnames/policy | that provider's sign-in/connect misroutes until a new release | Ships with the app; the U5 spike + upgrade checklist re-verify. Server-delivered config is the future fix |
session-lifecycle.ts / handoff.ts /workspace default |
/workspace is the post-login home |
changes the default landing route | post-login/last-route restore lands on a redirect/404 | Web app's own routing usually redirects; low blast radius |
navigation.ts mcp-oauth-* frame name |
hooks/queries/mcp.ts opens mcp-oauth-${id} |
renames the popup frame | MCP popup treated as generic → opener lost, flow hangs | String contract; add a shared constant if it churns |
window.ts theme probe |
document.documentElement.classList.contains('dark') (next-themes attribute='class') |
drops the dark class convention |
pre-paint background may flash once | Cosmetic only; self-corrects on next load |
handoff.ts redeem |
POST /api/auth/one-time-token/verify sets the cookie |
better-auth changes the endpoint | handoff sign-in fails | better-auth built-in endpoint; pinned by the better-auth version |
Overall this is within normal thin-wrapper coupling — every item is either backstopped (sign-out), cosmetic (theme), or a stable library/route contract. The only one that genuinely can't self-heal without a release is the IdP host list, which is inherent to the "pin Chromium, ship a binary" model and is managed by the upgrade program.
Local unsigned build: bun run package:dir (app in release/mac-universal/). Signed: bun run package:mac with CSC_LINK/CSC_KEY_PASSWORD exported.
Pre-release share (no Developer ID yet): SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai bun run package:share builds a DMG whose fresh installs default to that origin (baked at build time; official builds leave it unset → prod) and skips per-file signature timestamps. Recipients must clear quarantine once: xattr -cr /Applications/Sim.app.
CI (.github/workflows/desktop-release.yml, wired into ci.yml):
- Runs only after
create-releaseon avX.Y.Z:commit to main — never before:scripts/create-single-release.tsskips creation if the tag exists, so a desktop job publishing first would eat the changelog. The job builds--publish neverand uploads assets withgh release upload --clobber(idempotent re-runs). - Secrets gate:
check-desktop-signinginci.ymlprobes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "Desktop Release (macOS)" → Run workflow with avX.Y.Zversion (publish: falseuploads artifacts to the run instead of the release). - The product semver is injected from the release tag into
apps/desktop/package.jsonat build time (repo package versions are placeholders). A mismatch guard fails the build. - Fuses are flipped at package time (
electronFusesinelectron-builder.yml): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, cookie encryption on,strictlyRequireAllFusesso new fuses fail loudly on Electron bumps. - Cookie-encryption go/no-go: on every Electron bump, verify a packaged build keeps its session across relaunch (there are historical cookie-persistence bugs with the
EnableCookieEncryptionfuse). If it reproduces, setenableCookieEncryption: falseand record it here.
Required repo secrets (owner: whoever holds the Apple Developer account; calendar the expiries — an expired cert/API key breaks every release):
| Secret | Contents |
|---|---|
CSC_LINK |
base64 of the Developer ID Application .p12 |
CSC_KEY_PASSWORD |
.p12 password |
APPLE_API_KEY_P8 |
App Store Connect API key file contents (.p8) |
APPLE_API_KEY_ID |
API key ID |
APPLE_API_ISSUER |
API issuer ID |
APPLE_TEAM_ID |
Developer team ID |
Yes — the architecture has a single, clean seam for native features, and nothing about "the renderer is the hosted web app" gets in the way. The rules:
- One bridge. The preload (
src/preload/index.ts) exposeswindow.simDesktopviacontextBridgeon the main window. This is the only channel between web content and native capability. It exposes narrow, typed methods — never rawipcRenderer(Electron security checklist item 20). - Feature-detect, never assume. The same web app is served to browsers and to the desktop from one origin, so a desktop feature is progressive enhancement:
if (window.simDesktop) { … }. In a browserwindow.simDesktopisundefinedand the feature is simply absent. (isHostedalready tags these sessions for analytics.) - Gate in main. Every channel is validated in
src/main/ipc.tsby sender frame — app-origin for capability calls, bundledfile:pages for shell-control calls (checklist item 17). A new native feature adds one gated channel there. - Single-source the contract.
apps/simcannot import fromapps/desktop(monorepo rule:apps/* → packages/*only). The bridge interface lives in the shared types-onlypackages/desktop-bridgepackage, which both the preload and web app consume.
Concrete example — a "Reveal in Finder" button:
// packages/desktop-bridge/index.ts (shared contract)
export interface SimDesktopApi { showItemInFolder(path: string): void /* …existing methods… */ }
// apps/desktop/src/preload/index.ts (implement)
showItemInFolder: (path: string) => ipcRenderer.send('desktop:show-item', path),
// apps/desktop/src/main/ipc.ts (gate)
ipcMain.on('desktop:show-item', (event, path) => {
if (!isAppOriginSender(event, deps.appOrigin()) || typeof path !== 'string') return
shell.showItemInFolder(path)
})
// apps/sim (consume — progressive enhancement)
const desktop = useDesktop()
{desktop && <Button onClick={() => desktop.showItemInFolder(file.path)}>Reveal in Finder</Button>}Good fits for the bridge: OS notifications + dock badge on workflow completion, global shortcuts, "reveal in Finder", tray, secure OS-keychain storage. Anything that touches the server/DB still goes through normal APIs — the bridge is only for native capability. This same bridge is also the robust way to retire the web-app couplings in the table above: have the web app tell the shell (signalLogout(), markAuthSurface()) instead of the shell inferring from URLs.
The main Copilot agent can inspect a user-selected local directory in the desktop app through request-local client tools (local_mount_directory, local_list_mounts, local_list, local_glob, local_read, local_grep, local_stat, and local_forget_mount). This capability is:
- Explicit and read-only: the native folder picker creates the grant; there are no write/delete/execute operations.
- Remembered securely: grants are encrypted in Electron's private app data with OS-backed
safeStorageand restored with the same opaque URI after a normal app restart. (A security-scoped bookmark is stored alongside each grant, but it is a no-op in the current Developer ID build — only the macOS App Sandbox consumes it — and is kept purely for forward-compatibility should a sandboxed/MAS build ever ship.) There is no plaintext fallback: when secure storage is unavailable, the returned mount hasremembered: falseand lasts only for that app session. - Revocable:
local_forget_mountremoves one grant. All grants are removed on explicit sign-out or server-origin change so another Sim account or server cannot inherit them. Normal app quit only releases active OS handles and keeps the encrypted grants. - Opaque: renderer and model see only
localfs://<mount-id>/...URIs. Electron resolves every URI, checks lexical and realpath containment, and refuses symlink escapes. - Desktop-only: the web app advertises these request-local tools only when
window.simDesktop.localFilesystemis present. They are available directly to the main agent and are not added to subagent allowlists.
Server-side tools cannot consume a localfs:// URI. local_stage_file reads the granted file in Electron and uploads its bytes as a normal chat upload, returning uploads/... plus the collision-safe fileName. The agent then calls materialize_file(fileName) to promote it to durable files/... before passing that path to tools such as function_execute or generate_image:
localfs://... → local_stage_file → uploads/... → materialize_file → files/... → server tool
electron-updaterreads the GitHub Releases feed (publishis pinned tosimstudioai/sim); deltas via.zip.blockmap. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session.- Channels: stable builds (
X.Y.Z) followlatest;-beta.Nbuilds followbeta(never attach a betalatest-mac.ymlto a stable tag). - Staged rollout: after publishing, edit
stagingPercentage: 10into the release'slatest-mac.yml, then raise as crash metrics stay clean. - Rollback: a pulled release must be superseded by a higher version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in
updater.tsif a remote config source ever exists to feed it.) - Ship the DMG and tell users to install to
/Applications— App Translocation breaks Squirrel.Mac updates from quarantined paths.
- Point Settings… (
Cmd+,) at your instance. HTTPS required (HTTP for localhost only); each origin gets an isolated cookie partition. - Deploy the
/desktop/authpage (ships withapps/sim) and include your desktop users' origin inTRUSTED_ORIGINSif it differs fromNEXT_PUBLIC_APP_URL. - TLS must be system-trusted — the shell hard-rejects certificate errors (no in-app bypass). Install private CA roots in the macOS keychain.
DISABLE_AUTHinstances: the web app serves an anonymous session; the shell needs no special handling, but understand that anyone with the app and your origin has full access.- Forks: repoint
publish.owner/repoinelectron-builder.ymlor strip the updater.
- Voice STT requires the microphone TCC prompt (wired;
NSMicrophoneUsageDescription+device.audio-inputentitlement). Camera stays denied by design. - Default Electron ships H.264/AAC/MP3 — do not swap in the codec-free ffmpeg build.
- Web Speech recognition (
SpeechRecognition) does not exist in Electron; Sim does not use it (voice goes throughgetUserMedia+ server STT). - Third-party web analytics (GTM/GA) are blocked at the network layer by default (
blockThirdPartyAnalytics); first-party PostHog/ingestis untouched. Cmd+Ffind-in-page overlay is not implemented (Monaco and tables ship their own finds); revisit if users ask.- Sign-in uses only the
127.0.0.1loopback callback, which needs no OS registration — so it completes identically underbun run dev(unpackaged) and in a packaged build. There is no custom URL scheme.
Cadence: Electron ships a major every ~8 weeks and supports the latest 3 — budget a bump every ~4–6 months and adopt security patches within ~2 weeks. Follow docs/electron-upgrade-checklist.md; the desktop-e2e.yml canary leg (electron@latest) is the early-warning signal.