Skip to content

Latest commit

 

History

History
54 lines (38 loc) · 14.5 KB

File metadata and controls

54 lines (38 loc) · 14.5 KB

apps/desktop — Electron shell

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

  • 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/schema travel over the transport, never over IPC.
  • Main vs renderer. src/main/** is Node — no coss-ui / React conventions apply there. Only src/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 SystemBridge reads here. Move reusable workbench/sidebar/chat presentation to packages/presentation/ui; move data-plane/runtime containers to packages/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 immutable SystemBridge.app.platform value — never renderer heuristics such as navigator.platform.
  • Do not import app code through another app. Desktop may depend on packages/client/workbench and packages/presentation/ui; it must not import @linkcode/webview to reuse app roots, providers, transports, or shells.

Packaging & asar invariants

  • electron-builder.yml is the packaging config (YAML, not a package.json build key). node scripts/build.mts (three plain-Vite builds — vite.{main,preload,renderer}.config.ts, in that order; scripts/dev.mts is the dev counterpart) compiles into out/; electron-builder only wraps out/** + package.json. directories.output=release must equal OUTPUT_DIR in build-desktop.yml; buildResources=build-resources (NOT build/, which is a gitignored turbo output). electronVersion is hardcoded (electron-builder can't read pnpm's catalog: protocol) — the pinned number and its sync rule live in docs/RELEASE.md.
  • Packaging always goes through scripts/package-app.mts (CODE-107), never a bare electron-builder. It pnpm --prod deploys the app's production closure into a self-contained staging dir outside the workspace, then runs electron-builder with --projectDir pointed there so appDir === projectDir === workspaceRoot. This is what makes @electron/rebuild find better-sqlite3 (pnpm hoists it to the repo-root node_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:devshell uses electron-builder.devshell.yml + --dir; package is the production variant; both run through the script, which redirects directories.output back to release/ and the icons to the shared assets/. There is no dist script (release packaging is CI-only).
  • extraResources ship real executables OUTSIDE the asar — the OS cannot exec an asar member: sidecar/${arch} (the linkcode-pty PTY sidecar everywhere, plus the linkcode-sim iOS Simulator sidecar on macOS — the crate list lives in scripts/stage-sidecar.mts). sidecar/ and release/ are gitignored, so a fresh clone has none until staged; pnpm -F @linkcode/desktop stage:host-runtime builds the daemon + stages the sidecars. Agent CLI binaries do not ship (CODE-114): files globs 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 in packages/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 deploy staging never contains it — the daemon downloads it into the asset store on first use. verify-artifacts.mts fails the build if a platform package, a pi-closure package, or Resources/agent-bin sneaks back in, or any artifact exceeds 200 MB.
  • The bundled daemon comes from the bundle-daemon-artifact plugin in vite.main.config.ts: it copies apps/daemon/dist/index.jsout/daemon/index.mjs (and instrument.jsinstrument.mjs for Sentry preload; both renamed .mjs because they leave the daemon's type=module scope) and apps/daemon/drizzleout/drizzle. It throws apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first if the daemon wasn't built; turbo ^build guarantees ordering.
  • linkcode:// deep-link registration differs by build path. Packaged builds register it via the electron-builder.yml protocols block (macOS Info.plist + Windows installer); dev shells register it at runtime via setAsDefaultProtocolClient (cloud-auth/client.ts). OAuth callback routing therefore depends on how the app was shipped.

Packaged-only launch crashes (CODE-101)

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 require resolves to a .ts file inside the asar and throws ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING at launch. nodeExternals() in vite.shared.ts derives the external list from package.json dependencies while keeping every workspace:* dep bundled — do not hand-list packages (transitive ones get missed).
  • electron-builder always writes the manifest's productName into the asar package.json (no config, not even -c.productName, overrides it), and Electron pins userData by that name at startup so app.setName can't move it. A dev-shell pack that reuses LinkCode silently steals the release build's single-instance lock and exits 0. Main calls app.setPath('userData', appData/APP_NAME) explicitly.

Preload & CSP

  • The preload runs sandbox: true; its require resolves ONLY Electron built-ins. Any external dep bundled into the preload (e.g. zod) throws module not found at runtime, aborting the whole preload so contextBridge.exposeInMainWorld never runs and the first screen is blank. Keep packages/system-plane/ipc/src/electron-renderer.ts free 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 execute zod (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 the require regardless.
  • A preload dep that IS needed there is the opposite fix: pass it to nodeExternals([...]) in vite.preload.config.ts so it is bundled IN. This is why @better-auth/electron is listed there — externalizing it makes the auth bridge silently fail.
  • The renderer enforces a <meta> CSP in src/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, NOT unsafe-eval) for shiki/restty WASM; img-src data:/blob: for inline file viewers; frame-src … *.localhost:* for hosted-artifact iframes. The broad img-src http: https: allowance is deliberate: chat links eagerly race the destination's ${origin}/favicon.ico against 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 need webPreferences.plugins:true (window.ts) and a blob: iframe. apps/webview/index.html has no CSP meta (browser-served; any CSP is server headers). The restty font-inlining recipe (black screen without wasm-unsafe-eval; ghosted glyphs from the CDN-blocked default font) lives in .claude/rules/frontend.md.

Daemon supervisor

  • src/main/daemon-supervisor.ts is PACKAGED-ONLY (if (!app.isPackaged) return) — in dev the desktop app never starts a daemon; run it yourself (see DEVELOPMENT.md). It forks out/daemon/index.mjs under Electron's Node via utilityProcess.fork (Route A — not ELECTRON_RUN_AS_NODE, not a standalone binary), starts it on app-ready before the window, and SIGTERMs it on before-quit (Cmd+Q); closing windows (Cmd+W) leaves it running. before-quit holds the quit (preventDefault + re-quit() on the child's exit, 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 its runtime.json behind (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 injects LINKCODE_PTY_SIDECAR_PATH from process.resourcesPath (warns pty sidecar missing …; terminals unavailable if absent) and, on signed builds, LINKCODE_SENTRY_DSN from the inlined MAIN_VITE_SENTRY_DSN. When instrument.mjs is present it preloads it via execArgv: ['--import', …] (same contract as standalone node --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-log info, 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 up and stop. This crash-loop block is sticky until the renderer's explicit Retry calls SystemBridge.daemon.retry(). Exit code DAEMON_EXIT_ALREADY_RUNNING is not a crash: the supervisor stands down for the external daemon, watches runtime.json, and automatically re-arms when that runtime changes. Every spawn rechecks isDaemonManaged() so an override saved during a pending respawn wins.

Cloud auth

  • Desktop login to LinkCode Cloud uses better-auth 1.7.0-rc.1 + @better-auth/electron (both RC), wired in src/main/cloud-auth/. createAuthClient({ baseURL: 'https://api.linkcode.ai' (override LINKCODE_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 at https://linkcode.ai/sign-in, bounces through the central IdP, and deep-links back via linkcode://. The renderer never talks to the cloud API — only via preload IPC bridges; the account UI is the prop-driven HostFooter in packages/presentation/ui. Production endpoints are the default even in dev.
  • The session store fails closed (CODE-371). cloud-auth/storage.ts persists to userData/cloud-auth.json only when the OS actually protects the ciphertext; otherwise the session lives in a module-level Map for that run and nothing is written. "Protected" is stricter than safeStorage.isEncryptionAvailable(): on Linux that returns true even for the basic_text backend, where Chromium derives the key from a hardcoded in-memory password — so getSelectedStorageBackend() must also not be basic_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 old plain: base64 fallback are migrated off disk on read — re-encrypted where possible, otherwise deleted and carried in memory. Never call safeStorage.setUsePlainTextEncryption(true); it re-creates exactly the hole this closed.
  • Auth traps (real-device only, CI-green): setupMain must run BEFORE app is ready (it calls registerSchemesAsPrivileged, which throws after ready) — not inside whenReady().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, leaving requestAuth/getUser with no handler. A dev Electron build cannot decrypt a cloud-auth.json written by the packaged app (safeStorage/Keychain ACLs are per-binary).

Identity isolation (channel × profile)

  • A local/unpackaged build must be isolated from an installed release on four axes — app name, userData dir, single-instance lock, and OS keychain (safeStorage). The identity is CHANNEL (release/developmentLinkCode vs LinkCode Development) × optional PROFILE (--profile/LINKCODE_PROFILE, suffixing (<name>)), both in src/main/constants.ts; a production bundle run by the dev Electron binary is still the development channel. src/main/identity.ts applies the identity and MUST stay the first import of index.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 (~/.linkcode vs ~/.linkcode.development), the workspace root, and the managed asset store fork by channel too, so the supervisor injects both LINKCODE_PROFILE and LINKCODE_CHANNEL into the daemon it spawns — the channel injection is load-bearing for the devshell pack, whose bundled daemon is stamped release at build time. Discovery must read runtime.json through 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.