Desktop client (Electron shell)
The desktop app is the only form Polaris ships in: an offline, single-machine build. The packaged app ships its own Python backend — the engine — and boots it locally on first launch: no Docker, no database server, no Redis, no account, no server address. Sign-in does not exist: the frontend silently adopts a local session, and whoever uses the app is the owner. The engine is one process with SQLite, an in-process task queue, and an in-process scheduler (see Architecture).
NOTE
The local engine first shipped in v0.4.0. Releases up to v0.3.9 are remote-only shells that always ask for a server address, and they cannot update themselves into the local-engine build: install v0.4.0 or later from Releases over the old app.
When no local engine is available (the bootstrap failed, or a development run without POLARIS_DESKTOP_ENGINE), the app shows a page saying so, with a Restart Polaris button that quits and reopens the app (which retries the bootstrap and the engine start). There is no remote mode to fall back to.
The code lives in src/desktop/, a sibling of src/frontend and src/backend with its own package.json (one pnpm workspace at the repo root).
Process layers
Renderer (the existing React code in src/frontend, sandbox: true)
↓ one channel: ipcRenderer.invoke('polaris:rpc') / .on('polaris:event')
Main (shell and arbitration: window, menu, protocol, config)
└─ @polaris/kernel — a cordis plugin runtime mounted in the main process
├─ storage SQLite persistence (userData/kernel/storage.db);
│ holds the plugin config tree
├─ Loader + SqliteTree mounts plugins from the persisted config tree
└─ legacy-engine spawns the local Python backend and health-checks it
→ http://127.0.0.1:18080 — the full FastAPI backend, run locally:
uv-bootstrapped venv, SQLite database, in-process task queue
and scheduler (no external services, no login)The renderer talks to the engine over HTTP; main never proxies the API. The local engine is a real HTTP server on loopback, so lib/api.ts, lib/sse.ts and lib/ws.ts work against it unchanged. The moment main starts proxying, SSE streaming, WebSocket upgrades, blob streams and token handling all have to be reimplemented there. The shell exists to add capabilities, not to take over the network.
The kernel is also the plugin platform: what runs is driven by a persistent config tree (seeded with desktop-probe / sources / legacy-engine on first launch, user edits are authoritative afterwards). See Plugins for the loading model and the market.
The local engine: configuration and precedence
The legacy-engine entry is seeded disabled in the config tree; the engine's parameters are computed fresh on every launch and injected in memory only — the tree stores the user's intent (whether the plugin should exist), never a stale engine spec. Configuration sources, in order:
POLARIS_DESKTOP_ENGINE— explicit, for development and debugging. Two forms:docker:<image>:<backendDirAbs>— run the backend image with the source dir mounted, e.g.docker:polaris-api-test:local:/repo/src/backend;command:<json argv>— spawn an arbitrary command, e.g.command:["python","-m","uvicorn",...].
POLARIS_DESKTOP_ENGINE_CONTAINERandPOLARIS_DESKTOP_ENGINE_PORToverride the container name and host port for parallel shell instances (E2E runs on one machine); the packaged bootstrap does not read them.Packaged auto-bootstrap — when the app is packaged and no env is set, the installer's own resources bootstrap a local environment (next section). The user's machine needs neither Python nor Docker.
Neither (development, no env) — the entry stays disabled and no engine starts; the app shows the "local engine is not running" page.
First launch: bootstrap and the progress page
The installer carries two extra resources (electron-builder extraResources, staged by pnpm run stage:resources): a pinned single-file uv binary and the backend source with a build-time content hash. On first launch (or after an update that changed either), the app runs:
uv python install 3.12 → uv venv → uv pip install <resources/backend>entirely under userData/engine/ (managed Python, venv, uv cache — UV_PYTHON_PREFERENCE=only-managed, so a system Python is never used and never polluted). A sentinel file (engine/bootstrap.json recording the uv version, backend hash, and Python version) makes every later launch skip the whole sequence at the cost of a few file reads.
The window opens before the kernel starts: a first-launch waiting page polls kernel.engineBootstrapStatus and shows the phase (check / python / venv / install / engine / ready; idle means the embedded path was not taken, failed means it was and did not finish). The first run downloads the Python toolchain and dependencies, which can take minutes; after that, startup cost is zero.
Every engine start runs alembic upgrade head under a migration guard: a non-empty database is snapshotted to engine/snapshots/<timestamp>/ first, a failed migration restores the snapshot before re-raising, and only the last 3 snapshots are kept. A failed bootstrap or engine start never blocks the window — it logs and reports failed, and the waiting page turns into the "local setup did not finish" page with the same Restart Polaris button. When the engine could not start because port 18080 is taken, the page says so instead: either an older Polaris engine is still running, or another program holds the port (see Securing the local engine). The port stays 18080 because MCP clients and the browser extension are configured with it.
Where the data lives
Everything is under Electron's userData directory; uninstalling the app and deleting userData removes every trace.
| Path | What it holds |
|---|---|
userData/kernel/storage.db | The kernel's SQLite store: plugin config tree, install records |
userData/engine/ | Managed Python, venv, uv cache — the bootstrapped runtime |
userData/engine/polaris.db | The engine's SQLite database (the system of record) |
userData/engine/snapshots/ | Pre-migration database snapshots (last 3) |
userData/engine/data/ | User files: PDFs, exports, experiment logs (POLARIS_DATA_DIR) |
userData/engine/data/workspace/ | The file projection: a continuously refreshed, read-only copy of your papers (papers/), notes (notes/), and library wikis (wiki/, an Obsidian vault). The database is the source of truth — edits here are not written back and are overwritten on the next change. |
userData/plugins/ | Market-installed plugin bundles |
userData/engine-secrets.json | The engine's per-install signing and encryption keys (file mode 0600); see Securing the local engine. Back it up together with engine/polaris.db — without it the saved API keys and SSH credentials in a restored database cannot be decrypted. |
userData/engine.pid.json | Process id and launch id of the engine the shell started, used to stop an orphaned engine on the next launch |
Why Electron rather than Tauri
We need macOS, Windows and Linux. Tauri renders through WebKitGTK on Linux, which has known blank-window and rendering failures on NVIDIA GPUs — and Polaris's UI is exactly the heavy-rendering combination that would suffer: pdf.js canvas, CodeMirror 6, KaTeX and yjs. The cost (85–150MB installer, ~168MB idle RAM) is acceptable.
Page loading: app://polaris
Not file://: Chromium treats it as an opaque origin and rejects new Worker('file:///...'), which takes the pdf.js reader out entirely. The absolute /assets/ and /pdfjs/cmaps/ paths would also have to change, forking the desktop bundle from the web one.
Not a local HTTP server for the pages either: any process on the machine could reach that port, which adds a network attack surface for no benefit. (The local engine does listen on loopback, but it authenticates every request; the page origin stays app://polaris, and the CSP explicitly allows connecting to the local engine's address.)
Registered as standard + secure, the page gets a real origin, so pushState, workers, localStorage, navigator.clipboard and Notification all behave as they do over https — and not one line of frontend structure changes (vite.config.ts keeps its default base, createBrowserRouter stays).
polaris:// is a separate thing: it is reserved for deep links. Do not make one scheme serve both content and OS-level handling.
The IPC contract
src/desktop/src/shared/contract.ts is the single source of truth: one method table and one event union.
One channel rather than an ipcMain.handle per capability. Preload is the boundary that ships inside the installer and is directly visible to the renderer; with per-capability channels, every local capability added later means touching preload, main and the renderer together. As it stands, preload is written once and adding a method touches only the contract and the main-side implementation.
Methods are named <domain>.<object>.<verb>: host.* is shell capability, kernel.* is kernel state (status, local backend address, bootstrap progress, plugin management), local.* is local long-job bookkeeping (currently just local.job.cancel). Real local compute does not run as a separate agent process — it runs as kernel plugins (legacy-engine being the first); the stdio JSON-RPC agent of the original phase-1 design was removed once the kernel landed (#731).
Every frontend decision about what the shell can do reads the capability manifest (host.capabilities). Do not branch on the platform or on a version number. A declared capability that is unavailable at call time fails with ERR_CAPABILITY_UNAVAILABLE and the frontend falls back to the engine; plugins.manage is the first capability that is actually true (it tracks whether the kernel's config tree is reachable), while latex.compile still reports unavailable (the tectonic probe already runs, the implementation does not exist yet).
Settings that look removable and are not
| Where | Constraint |
|---|---|
plugins: true in window.ts | The manuscript PDF preview is an <iframe src=blob:…pdf> rendered by Chromium's built-in pdfium, not pdf.js. Electron disables plugins by default, so dropping this makes the preview blank |
script-src 'wasm-unsafe-eval' in the CSP | pdf.js ships openjpeg / qcms as WASM |
blob: in connect-src | The annotating reader hands pdf.js a blob: URL and pdf.js fetches it; 'self' does not cover blob: in Chromium. Drop it and only that reader breaks — the standard reader is an <iframe> and goes through frame-src |
style-src 'unsafe-inline' in the CSP | CodeMirror's style-mod and KaTeX insert rules at runtime; without it the editor and formula rendering break |
role: 'editMenu' in menu.ts | Without it, Cmd+C/V/A/Z stop working in some controls on macOS |
| The full-page reload after the first-launch waiting page | The CSP response header is fixed at document load, and the first document loads before the engine has an address; only a reload lets the page connect to it |
Frontend conventions
- Engine addresses go through
src/frontend/src/lib/endpoint.ts(apiBase(),wsUrl(),localOrigin()). Do not assemblewindow.locationin components — in a plain browser (the Vite dev server) these functions degrade to same-origin relative paths. - Desktop capabilities go through
src/frontend/src/lib/host.ts, which is a safe no-op in a plain browser (plugin management, for instance, simply does not appear there). Never readwindow.polarisin a component, or every caller needs null checks. - System notifications go through
lib/desktop-notify.ts, and only for events that need a human or that reached a terminal state — and only while the window is unfocused. - Keep the auth token in
localStorage; do not switch to ElectronsafeStorage. This was tried and reverted: ad-hoc signed builds get a different signature every build, so the keychain ACL never matches and macOS prompts for authorisation on every launch. The keychain only becomes reasonable once the app has a stable Developer ID signature. Sessions persist throughPOLARIS_SESSION_LIFETIME_SECONDS(30 days by default), which does not depend on the keychain at all. (Against the local engine there is nothing to type in the first place: the frontend fetches the local session itself.)
Developing and packaging
make desktop-deps # pnpm install for the whole workspace
make desktop-dev # build the frontend and start the shell (real app:// path)
cd src/desktop && pnpm run smoke # loads the SPA for real; non-zero exit means failure
make desktop-dist # stage uv + backend, build an installer (unsigned)In development the shell starts with no local engine by default; set POLARIS_DESKTOP_ENGINE (either form above) to exercise the local engine chain. The quickest way is command: pointing at a backend you run from source, or docker: with the dev/test engine image (make engine-image builds polaris-api-test:local; see Development). The smoke and E2E engine groups use that image too (POLARIS_SMOKE_ENGINE=1, POLARIS_E2E_ENGINE=1). The packaged app boots its own engine, so it asks for nothing on first launch.
CI covers both: desktop-build.yml runs the smoke test on pull requests that touch the frontend or the shell, and desktop-release.yml builds all three platforms on a v* tag and publishes a GitHub release. The release also ships the renderer separately as renderer-<version>-c<contract>.tar.gz (built once, on Linux — the bundle is platform-independent), so a frontend-only release can be applied in place: the client swaps the bundle in and reloads, without reinstalling. The contract version in the filename tells the client whether its preload is new enough to run it; see src/desktop/src/main/updates.
Local packaging failures worth recognising
unable to execute hdiutil … Exit code: 16— a dmg volume from a previous build (or from a manualhdiutil attach) is still mounted and the new run cannot unmount it, so you get a zip but no dmg. Clear it withhdiutil detach -force "/Volumes/Polaris <version>-<arch>"and rebuild. CI never hits this; its runners are clean.Application entry file "dist/main.cjs" … does not exist—electron-builderwas invoked without building first. Usepnpm run dist:mac, which stages resources and builds, or runpnpm run buildyourself. Note thatpnpm run smokebuilds only preload and smoke — notmain.cjs.- The packaged app exits immediately with status 0 — that is the single-instance lock, not a crash. Another instance is already running.
The SUID sandbox helper binary … is not configured correctly(Linux) — Electron refuses to start whenchrome-sandboxis not owned by root with mode 4755, which is how npm installs it. Fix the permissions rather than passing--no-sandbox.
Notes on unsigned distribution
- macOS:
identity: nullonly means "do not sign with a Developer ID" — it does not ad-hoc sign for you. And electron-builder rewrites the bundle (icon,app.asar,extraResources), which invalidates the signature Electron's prebuilt binary ships with. The Apple Silicon kernel refuses to execute a binary with no valid signature, so users see "damaged" — and that is not a quarantine flag, soxattrcannot clear it.build/after-pack.cjstherefore runscodesign --force --deep --sign -after packaging and verifies the result, failing the build if it cannot. This is not a substitute for notarization; it only makes an unsigned build launchable. The hook skips the*-tempdirectories so universal builds work:@electron/universalrequires every non-binary file to be byte-identical across architectures, and signing each arch separately makes the merge abort. Distribute the zip rather than the dmg (one less layer of quarantine propagation); first launch still needsxattr -dr com.apple.quarantine /Applications/Polaris.appor right-click → Open. - Windows: prefer the portable zip, which bypasses SmartScreen's installer check.
- Linux: AppImage and deb. The deb maintainer comes from
authorinsrc/desktop/package.jsonand must include an email, or electron-builder aborts — a macOS-only build never exercises that path, so CI is where it surfaces. AppImage needslibnss3 libgtk-3-0 libasound2on the host. Under Ubuntu 24.04+ AppArmor restrictions, or without a SUIDchrome-sandbox, it needs--no-sandbox— document that, do not disable the sandbox in code.
Backend side
The engine's CORS whitelist always includes app://polaris — it is a constant (DESKTOP_ORIGIN in src/backend/app/core/config.py), not something to configure. The whitelist matters because every desktop request carries an Authorization header, so every request triggers a preflight, and with an empty allow_origins Starlette answers those preflights with 400. This cannot be worked around on the client — injecting response headers cannot change a status code.
Securing the local engine
The engine listens on 127.0.0.1:18080, but "only this machine can reach it" is not the same as "only this user's Polaris window can use it": any web page open in a browser can send requests to a local port, a malicious domain can be re-pointed at 127.0.0.1 (DNS rebinding), and other accounts on the same computer can connect too. A session on the engine is enough to register an MCP server or agent backend with an arbitrary command, so the engine guards its door (#850):
- No wildcard CORS. Cross-origin access is allowed only for
app://polarisand the origins inPOLARIS_CORS_ORIGINS, whateverPOLARIS_ENVis. Other pages cannot read any response. - Host header check. Requests whose
Hostis not127.0.0.1,localhost, or[::1](any port) get400— this is what stops DNS rebinding.POLARIS_ALLOWED_HOSTSadds names. - A per-launch session secret. Every time the
legacy-engineplugin starts the engine it generates a random secret and passes it asPOLARIS_LOCAL_SESSION_SECRET. The shell hands the same value to its own renderer throughkernel.localBackend(sessionSecret), andlib/local-session.tssends it in theX-Polaris-Session-Secretheader. With the variable set,POST /api/auth/local-sessionanswers403to anyone without it. When the variable is not set (running from source, tests), the endpoint instead accepts requests with noOriginheader or an allowed one, so a browser page on another site still gets403. - Per-install keys. On first launch the plugin writes
userData/engine-secrets.json(mode0600) with a randomPOLARIS_SECRET_KEY(session tokens, signed download links, browser extension key digests) and a FernetPOLARIS_ENCRYPTION_KEY(stored API keys, SSH credentials, MCP/agent environment variables). Earlier versions used the public default secret key and derived the encryption key from it. The engine still decrypts values written that way, and at startup it re-encrypts every one of them with the new key (rotate_encrypted_secretsinapp/core/security.py): idempotent, committed table by table, and safe to interrupt, because decryption accepts both keys. The secret-key change signs everyone out once; the renderer simply fetches a new session. Browser-extension keys keep working: a key whose digest still matches the old default is accepted once and re-hashed. - Knowing its own engine. The plugin also passes a random
POLARIS_INSTANCE_ID, which/api/healthechoes back; the health check only accepts an answer carrying that id. Before starting, if something already answers on the port, the plugin compares it withuserData/engine.pid.json: if it is the engine the shell itself started last time (same instance id), the shell stops it and carries on; otherwise it refuses to start and the "engine not running" page says whether the port is held by an older Polaris engine or by another program. It never stops a process it cannot identify as its own.
The keys travel only through environment variables (docker mode passes them by name, so they never appear in a command line) and never through the plugin config tree, which the renderer can read through plugins.list.