Architecture
The two-world desktop model — a privileged Rust core and a sandboxed webview joined by typed, capability-scoped IPC.
The mental model is two worlds joined by one narrow, typed boundary.
Two worlds
| World | What it is | What it does |
|---|---|---|
| Rust core (privileged) | src-tauri/ — the native binary, plugins, and your #[tauri::command]s | Touches the OS: keychain, filesystem, notifications, updater, tray, logging. |
| Webview (sandboxed) | src/ — a React SPA in the system webview, HashRouter, lazy routes | Renders UI and calls the core. No Node, no preload script, no direct OS access. |
The boundary between them is invoke plus the capabilities ACL. UI code
never reaches the OS directly — it asks the core, and the core only answers for
permissions a capability has granted. This is the security difference from a web
template and from the Electron sibling: instead of a preload bridge, each window
carries an enumerated permission set and the Rust core rejects anything outside
it before your code runs.
IPC in one screen
component → tauri.<domain>.<method>() → generated bindings → #[tauri::command] (Rust)
(or useTauri('<domain>')) src/lib/bindings.ts src-tauri/src/commands/- Types are compiler-derived by
tauri-specta— there is no hand-written schema to keep in sync. The generatedsrc/lib/bindings.tsis committed and CI fails on drift. - The ACL checks permissions before the command body runs.
- Zod is reserved for settings and forms, not IPC args.
The full add-a-command walkthrough lives on the IPC page.
A use-conveyor.ts shim and src/lib/conveyor/schemas.ts survive from the
Electron port and are still imported by some components. Treat tauri /
useTauri as the real API and the conveyor layer as port-compat — some of its
methods are no-op stubs. See IPC.
Central configuration
All frontend configuration is read once in shared/config.ts; nothing else
touches import.meta.env. It exposes features, billingConfig,
showcaseConfig, and the analytics/Sentry slots.
Flag (features) | Env | Default |
|---|---|---|
auth | VITE_AUTH_ENABLED | on |
autoUpdate | VITE_AUTO_UPDATE_ENABLED | on |
showcase | VITE_SHOWCASE_ENABLED | on |
billing | VITE_BILLING_ENABLED | off |
A flag strips its whole subsystem at build time. Crucially, there is one flag
per subsystem — no MAIN_VITE_* twin like Electron. The native menu and tray
are built in TypeScript (src/lib/menu/, src/lib/tray/) and read the same
features object directly, so a single VITE_SHOWCASE_ENABLED reaches the
routes, sidebar, palette, and the native Template menu.
Some template comments and the README describe the frontend "pushing" flags to
Rust at boot via an app::set_feature_flags command. That command does not
exist and getNativeFeatureFlags() has no callers. The single-flag behavior
is real, but the reason is simpler: the menu/tray are JS-built and read
features directly. There is no config push.
State layers
| Layer | Holds | Backed by |
|---|---|---|
| zustand stores | Session and entitlement state (auth-store, subscription-store) | supabase-js |
| TanStack Query | Server data (todos, subscription reads) | src/lib/query/ |
| Settings store | User preferences | tauri-plugin-store + versioned TS-side migrations |
Startup flow
The Rust entry (main.rs → lib.rs run()) wires the app in a deliberate order:
- Single-instance plugin first — so a second launch is short-circuited before any window exists; its callback focuses the running window and forwards the new process's argv to the frontend intent dispatcher.
- Plugins — opener, store, dialog, fs, autostart, log, notification, global-shortcut, os, process, updater, window-state, and deep-link.
- Setup hook — in debug builds only, registers the
soar-tauri://scheme at runtime (deep_link().register_all()) so links are testable duringtauri dev; initializes the optional Sentry guard. - Window — created from
tauri.conf.jsonwithdecorations: false(the frameless custom titlebar). - Frontend —
main.tsxvalidates the Supabase env, then mountsApp→OnboardingGate→ routes.
With features.auth on and the Supabase env missing or invalid, main.tsx
renders SupabaseConfigErrorScreen and never imports App (whose store
imports would otherwise construct the Supabase client). With auth off, the app
mounts and the client is never touched.
The second defense layer: CSP
Beyond the ACL, tauri.conf.json app.security sets a strict Content Security
Policy. The default is anchored to 'self'; the notable relaxation is
connect-src, which allows the IPC transport plus outbound HTTPS/WSS for
Supabase and the update feed:
"connect-src 'self' ipc: http://ipc.localhost https: wss:"ipc: and http://ipc.localhost are how the webview reaches the Rust core;
script-src 'self' (dev adds 'unsafe-inline'/'unsafe-eval' for HMR) means
nothing in-app can inject or load foreign scripts. Together the CSP and the ACL
keep the webview pinned to self — external URLs are forced out through the
audited opener chokepoint (see Capabilities).
