Electron app: src/main (main process, Node), src/preload (context-isolated, sandboxed bridge),
and src/renderer (Vite + React Router SPA). The renderer connects to the daemon over
transport, exactly like webview. Read .claude/rules/frontend.md
when touching src/renderer; release/signing/notarization → docs/RELEASE.md;
run/test/E2E, the dev-shell procedure, and daemon-log locations → docs/DEVELOPMENT.md.
The rules below are desktop-only — the system plane.
- System plane = TypeSafe IPC only (
@linkcode/ipc; tRPC is the default impl). IPC carries window / OS / native-UI operations between main and renderer — never business data. Sessions, agent events, and everything in@linkcode/schematravel over thetransport, never over IPC. - Main vs renderer.
src/main/**is Node — no coss-ui / React conventions apply there. Onlysrc/renderer/**is the SPA the front-end rule governs. - Desktop owns only desktop integration. Keep native chrome, traffic-light/backdrop behavior, desktop-only layout adapters, desktop transport construction, and
SystemBridgereads here. Move reusable workbench/sidebar/chat presentation topackages/presentation/ui; move data-plane/runtime containers topackages/client/workbench. - Pass system data down as props. If shared UI needs the app version, platform, a picked file path, or another system-plane value, read it once in desktop and pass a plain value/callback to shared code. Do not keep a whole component in desktop just because one prop comes from IPC.
- Use Electron/Node as the system source of truth. Platform checks use the sandboxed preload's
process.platform, exposed as the immutableSystemBridge.app.platformvalue — never renderer heuristics such asnavigator.platform. - Do not import app code through another app. Desktop may depend on
packages/client/workbenchandpackages/presentation/ui; it must not import@linkcode/webviewto reuse app roots, providers, transports, or shells.
electron-builder.ymlis the packaging config (YAML, not apackage.jsonbuild key).node scripts/build.mts(three plain-Vite builds —vite.{main,preload,renderer}.config.ts, in that order;scripts/dev.mtsis the dev counterpart) compiles intoout/; electron-builder only wrapsout/**+package.json.directories.output=releasemust equalOUTPUT_DIRinbuild-desktop.yml;buildResources=build-resources(NOTbuild/, which is a gitignored turbo output).electronVersionis hardcoded (electron-builder can't read pnpm'scatalog:protocol) — the pinned number and its sync rule live indocs/RELEASE.md.- Packaging always goes through
scripts/package-app.mts(CODE-107), never a bareelectron-builder. Itpnpm --prod deploys the app's production closure into a self-contained staging dir outside the workspace, then runs electron-builder with--projectDirpointed there soappDir === projectDir === workspaceRoot. This is what makes@electron/rebuildfind better-sqlite3 (pnpm hoists it to the repo-rootnode_modules, which electron-builder's Windows workspace-root probe can't locate — the pre-CODE-107 daemon shipped an un-rebuilt binding and died at boot on Windows) and makes the asar module collector see one importer instead of the whole monorepo.package:devshelluseselectron-builder.devshell.yml+--dir;packageis the production variant; both run through the script, which redirectsdirectories.outputback torelease/and the icons to the sharedassets/. There is nodistscript (release packaging is CI-only). extraResourcesship real executables OUTSIDE the asar — the OS cannot exec an asar member:sidecar/${arch}(thelinkcode-ptyPTY sidecar everywhere, plus thelinkcode-simiOS Simulator sidecar on macOS — the crate list lives inscripts/stage-sidecar.mts).sidecar/andrelease/are gitignored, so a fresh clone has none until staged;pnpm -F @linkcode/desktop stage:host-runtimebuilds the daemon + stages the sidecars. Agent CLI binaries do not ship (CODE-114):filesglobs exclude the SDK platform packages from the asar (each is ~220 MB, host-arch only — broken cross-arch anyway), and the daemon spawns a detected user install or a managed download instead (resolution inpackages/host/agent-adapter/AGENTS.md); The pi npm closure is likewise absent (CODE-219): its SDK is a devDependency of agent-adapter, so the--prod deploystaging never contains it — the daemon downloads it into the asset store on first use.verify-artifacts.mtsfails the build if a platform package, a pi-closure package, orResources/agent-binsneaks back in, or any artifact exceeds 200 MB.- The bundled daemon comes from the
bundle-daemon-artifactplugin invite.main.config.ts: it copiesapps/daemon/dist/index.js→out/daemon/index.mjs(andinstrument.js→instrument.mjsfor Sentry preload; both renamed.mjsbecause they leave the daemon'stype=modulescope) andapps/daemon/drizzle→out/drizzle. It throwsapps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` firstif the daemon wasn't built; turbo^buildguarantees ordering. linkcode://deep-link registration differs by build path. Packaged builds register it via theelectron-builder.ymlprotocolsblock (macOS Info.plist + Windows installer); dev shells register it at runtime viasetAsDefaultProtocolClient(cloud-auth/client.ts). OAuth callback routing therefore depends on how the app was shipped.
Both fail only in packaged builds, produce no error, and are invisible to typecheck and the Vite builds — verify by launching the packaged app.
- Workspace packages export raw TS, so they MUST be bundled into main/preload, never externalized. A leftover
requireresolves to a.tsfile inside the asar and throwsERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPINGat launch.nodeExternals()invite.shared.tsderives the external list frompackage.jsondependencieswhile keeping everyworkspace:*dep bundled — do not hand-list packages (transitive ones get missed). - electron-builder always writes the manifest's
productNameinto the asarpackage.json(no config, not even-c.productName, overrides it), and Electron pinsuserDataby that name at startup soapp.setNamecan't move it. A dev-shell pack that reusesLinkCodesilently steals the release build's single-instance lock and exits 0. Main callsapp.setPath('userData', appData/APP_NAME)explicitly.
- The preload runs
sandbox: true; itsrequireresolves ONLY Electron built-ins. Any external dep bundled into the preload (e.g.zod) throwsmodule not foundat runtime, aborting the whole preload socontextBridge.exposeInMainWorldnever runs and the first screen is blank. Keeppackages/system-plane/ipc/src/electron-renderer.tsfree of heavy runtime deps: type-only imports and zod-free constant subpaths (e.g.@linkcode/schema/daemon-runtime— those constants live on a separate subpath precisely for this) are fine; never import schema modules that executezod(schema definitions,.parse/.safeParse) — validate in the main process instead. Verify every preload/IPC change by running desktop — the build marks the dep external and emits therequireregardless. - A preload dep that IS needed there is the opposite fix: pass it to
nodeExternals([...])invite.preload.config.tsso it is bundled IN. This is why@better-auth/electronis listed there — externalizing it makes the auth bridge silently fail. - The renderer enforces a
<meta>CSP insrc/renderer/index.html. Every web-content feature needs an explicit minimal widening (a blank iframe/image/PDF with only a console CSP violation is the signature):wasm-unsafe-eval(the narrow token, NOTunsafe-eval) for shiki/restty WASM;img-src data:/blob:for inline file viewers;frame-src … *.localhost:*for hosted-artifact iframes. The broadimg-src http: https:allowance is deliberate: chat links eagerly race the destination's${origin}/favicon.icoagainst Google S2, so merely viewing a link contacts its destination and discloses its encoded origin to Google before a click;referrerPolicy="no-referrer"does not conceal the origin embedded in the S2 request URL. PDFs additionally needwebPreferences.plugins:true(window.ts) and ablob:iframe.apps/webview/index.htmlhas no CSP meta (browser-served; any CSP is server headers). The restty font-inlining recipe (black screen withoutwasm-unsafe-eval; ghosted glyphs from the CDN-blocked default font) lives in.claude/rules/frontend.md.
src/main/daemon-supervisor.tsis PACKAGED-ONLY (if (!app.isPackaged) return) — in dev the desktop app never starts a daemon; run it yourself (see DEVELOPMENT.md). It forksout/daemon/index.mjsunder Electron's Node viautilityProcess.fork(Route A — notELECTRON_RUN_AS_NODE, not a standalone binary), starts it on app-ready before the window, and SIGTERMs it onbefore-quit(Cmd+Q); closing windows (Cmd+W) leaves it running.before-quitholds the quit (preventDefault+ re-quit()on the child'sexit, 5000ms grace) until the daemon is gone — Electron destroys utility processes as the browser exits, so quitting straight after the SIGTERM truncates the drain: the daemon dies mid-shutdown and leaves itsruntime.jsonbehind (the packaged-smoke shutdown assertion's flake). Gate:isDaemonManaged() = app.isPackaged && getSettings().daemonUrl === null. It only spawns — no probe/adoption.- Spawn env: it spreads
process.env(PATH/HOME survive), then injectsLINKCODE_PTY_SIDECAR_PATHfromprocess.resourcesPath(warnspty sidecar missing …; terminals unavailableif absent) and, on signed builds,LINKCODE_SENTRY_DSNfrom the inlinedMAIN_VITE_SENTRY_DSN. Wheninstrument.mjsis present it preloads it viaexecArgv: ['--import', …](same contract as standalonenode --import). Agent CLI paths need no env: the daemon owns its managed-asset store (@linkcode/assets, CODE-111) and resolves spawn paths managed → detected itself. Child stdout →electron-loginfo, stderr → warn; log-file locations are in DEVELOPMENT.md. - Supervisor recovery: respawn after 1000ms; a child living <30000ms increments a fast-exit counter; 5 consecutive make it log
daemon keeps exiting (last code N); giving upand stop. This crash-loop block is sticky until the renderer's explicit Retry callsSystemBridge.daemon.retry(). Exit codeDAEMON_EXIT_ALREADY_RUNNINGis not a crash: the supervisor stands down for the external daemon, watchesruntime.json, and automatically re-arms when that runtime changes. Every spawn rechecksisDaemonManaged()so an override saved during a pending respawn wins.
- Desktop login to LinkCode Cloud uses
better-auth1.7.0-rc.1+@better-auth/electron(both RC), wired insrc/main/cloud-auth/.createAuthClient({ baseURL: 'https://api.linkcode.ai' (overrideLINKCODE_CLOUD_API_URL), basePath: '/auth' })— not the client default/api/auth; the API mounts better-auth at/auth, and a mismatch 404s every auth call. Sign-in runs in the system browser athttps://linkcode.ai/sign-in, bounces through the central IdP, and deep-links back vialinkcode://. The renderer never talks to the cloud API — only via preload IPC bridges; the account UI is the prop-drivenHostFooterinpackages/presentation/ui. Production endpoints are the default even in dev. - The session store fails closed (CODE-371).
cloud-auth/storage.tspersists touserData/cloud-auth.jsononly when the OS actually protects the ciphertext; otherwise the session lives in a module-levelMapfor that run and nothing is written. "Protected" is stricter thansafeStorage.isEncryptionAvailable(): on Linux that returns true even for thebasic_textbackend, where Chromium derives the key from a hardcoded in-memory password — sogetSelectedStorageBackend()must also not bebasic_text/unknown. Consequence to expect on such a host (a headless or unrecognized DE): login works but does not survive a restart. Values left by the oldplain:base64 fallback are migrated off disk on read — re-encrypted where possible, otherwise deleted and carried in memory. Never callsafeStorage.setUsePlainTextEncryption(true); it re-creates exactly the hole this closed. - Auth traps (real-device only, CI-green):
setupMainmust run BEFOREappis ready (it callsregisterSchemesAsPrivileged, which throws after ready) — not insidewhenReady().then(). It is opt-in per feature: the call must be{ csp:false, scheme:true, bridges:true, getWindow }— passing only{csp:false}silently drops the protocol + IPC handlers, leavingrequestAuth/getUserwith no handler. A dev Electron build cannot decrypt acloud-auth.jsonwritten by the packaged app (safeStorage/Keychain ACLs are per-binary).
- A local/unpackaged build must be isolated from an installed release on four axes — app name,
userDatadir, single-instance lock, and OS keychain (safeStorage). The identity isCHANNEL(release/development→LinkCodevsLinkCode Development) × optionalPROFILE(--profile/LINKCODE_PROFILE, suffixing(<name>)), both insrc/main/constants.ts; a production bundle run by the dev Electron binary is still thedevelopmentchannel.src/main/identity.tsapplies the identity and MUST stay the first import ofindex.ts— any module body that runs before it captures paths in the wrong universe (the CODE-166 bug class). Skipping any axis clobbers release settings, steals its lock, or poisons its keychain entry. Since CODE-460 the daemon-side state (~/.linkcodevs~/.linkcode.development), the workspace root, and the managed asset store fork by channel too, so the supervisor injects bothLINKCODE_PROFILEandLINKCODE_CHANNELinto the daemon it spawns — the channel injection is load-bearing for the devshell pack, whose bundled daemon is stampedreleaseat build time. Discovery must readruntime.jsonthrough the same pair (daemonRuntimeFilePath(CHANNEL, PROFILE)) or the shell dials the other channel's daemon. Full consequences, the per-channel path table, and the keychain cleanup command →docs/DEVELOPMENT.md.