The big picture
The Rust shell owns the window, the menu bar, deep links, and the auto-updater. It also runs the server: the backend is compiled into the same binary and serves the app on127.0.0.1:<port>, reusing the port it had last time. The user’s browser-equivalent (WKWebView) loads the same UI a Docker user sees through Chrome. Releases before the Rust backend spawn a Node process for the server instead.
Why this shape
Three constraints pulled the design here:- One frontend, three distribution surfaces. Docker users get the same built frontend, served by the same Rust server running on its own (
pt-core serve). Homebrew installs the same.appTauri produces. The desktop app serves that frontend from the Rust backend compiled into it, which answers the same HTTP API as the Node server, so every UI change reaches all three. Releases before the Rust backend run the Next.js bundle itself as a child process. - Native file-system access for backups + diagnostics. Pure-web app would need to ask the user every time it touched a file. Tauri exposes safe file IO through the Rust IPC layer.
- Signed auto-updates. macOS Gatekeeper + ed25519-verified patches = silent, safe updates. A Docker user has no equivalent; they
docker compose pullmanually.
The Rust backend
From the first release on the Rust backend there is no second process. Whenprivacytracker.app launches:
Tauri finds the data directory
src-tauri/src/backend.rs resolves ~/Library/Application Support/privacytracker, the directory the sidecar releases use, for either backend, so either build finds the database the other wrote.Tauri binds the port it had last time, if it can
.desktop-port in the data directory. If it’s still free, the app binds 127.0.0.1 on it again; if anything else has it, the app takes a free port and remembers that one instead. A page’s origin includes its port, so keeping the port keeps what the page stores in the browser, such as the accessibility quick toggles, from one launch to the next. The sidecar takes a fresh random port at every launch, which loses that state every time.The server starts inside the app
src-tauri/src/embedded.rs starts the server on that listener, in the app’s own process. It opens and migrates privacy.db, starts the scheduled jobs, and serves the frontend from Contents/Resources/site, read-only inside the signed bundle. Nothing is extracted into the data directory.The server doesn’t read the app’s environment. The shell hands it a fixed set of variables, such as the data directory, a loopback bind and PRIVACYTRACKER_RUNTIME=desktop, and never an admin token, much as the sidecar starts Node from a cleared environment. Nothing in the app’s own environment changes who may call the API.The WebView loads the app
http://127.0.0.1:<port>/.Quitting stops the server
Cmd-Q, tray Quit, menu-bar Quit and updater restart all route through RunEvent::ExitRequested. The server stops accepting connections and ends its scheduled jobs, and requests in flight get up to 3 seconds, the grace the sidecar gets between SIGTERM and SIGKILL. A bulk run cut off there resumes on the next launch, as it does after a crash.The sidecar Node runtime
Releases before the Rust backend run the server as a separate Node process, the sidecar. When one of them launches:Tauri picks a free port
src-tauri/src/sidecar.rs opens an ephemeral TCP socket on 127.0.0.1, reads back the assigned port, and immediately closes it — so the port is known but not held.Tauri spawns Node with that port in env
src-tauri/binaries/node-<arch>) runs the standalone Next.js bundle (server.js) with PORT=<that-port> and HOSTNAME=127.0.0.1. The bundle binds to localhost only — no external network exposure.Tauri waits for the sidecar to answer
sidecar::wait_until_ready polls http://127.0.0.1:<port>/api/apps every 250 ms with a 2-second per-request timeout, and treats any response with status < 500 as ready — it never parses the body. The overall deadline is READY_TIMEOUT, 60 seconds (the in-source comment records the bump from 30s: standalone-tree extraction takes 5–10s on a slow disk, and Next.js compiles the route on first hit). Once a probe succeeds, the WebView loads http://127.0.0.1:<port>/ and the user sees the dashboard./api/ready exists, but nothing in the Tauri shell uses it — it’s the Docker/compose healthcheck and the smoke-test probe. It answers { status: "ready" | "not_ready", checks }, not { ok: true }.The sidecar watches Tauri, not the other way round
main.rs, and SidecarHandle only ever terminates it — if the Node process dies mid-session, the WebView is left pointing at a dead port and the user has to relaunch.The liveness relationship runs in the opposite direction. sidecar.rs passes PRIVACYTRACKER_PARENT_PID (Tauri’s own PID) into the child, and lib/parent-watchdog.ts — installed from instrumentation.ts — probes that PID with signal 0 every 3 seconds after a 5-second initial delay, calling process.exit(0) when it disappears. That’s what stops a Force Quit, kill -9, or Rust panic from leaving an orphaned Node process holding the SQLite WAL and the listening port. The clean-quit path is handled by SIGTERM from the Rust side; the watchdog covers the unclean ones.Tauri kills the sidecar on app quit
Cmd-Q — and tray Quit, menu-bar Quit, and updater restart, all of which route through RunEvent::ExitRequested — sends SIGTERM to the sidecar’s whole process group, then polls try_wait() every 50 ms for up to 3 seconds. Still alive at the deadline, it escalates to SIGKILL; SQLite WAL crash recovery makes that safe either way. On Windows there’s no SIGTERM step — the code falls straight through to TerminateProcess.npm run build artefact serve all three distribution surfaces; embedding would mean a separate build pipeline.
Bundling the right Node
Only the sidecar releases carry Node. The Rust backend bundles none, so nothing in this section applies to it.src-tauri/binaries/ ships a Node runtime per supported architecture:
--target.
When upstream Node ships a new LTS, the Tauri Bundled Node guide on the Plane board walks through:
- Downloading the new Node tarball for both arches.
- Verifying the SHA-256 from
nodejs.org/dist/v.../SHASUMS256.txt. - Re-codesigning the binaries with our Developer ID.
- Re-pinning
engines.nodeinpackage.jsonto match.
.node add-on is built against a specific Node ABI. A mismatch produces NODE_MODULE_VERSION errors at sidecar startup.
Standalone build mode
The Rust backend doesn’t use a standalone tree. It serves the regularnext build output, copied into the bundle’s site/ folder: the prerendered pages, the static chunks and public/, about 9 MB, and no server code.
The sidecar runs npm run build:standalone, which is BUILD_STANDALONE=1 next build --webpack && node ./scripts/stage-standalone.mjs.
Two things differ from the regular npm run build:
BUILD_STANDALONE=1flipsnext.config.jsinto Next.js’s standalone output mode, which produces a self-contained.next/standalone/directory with a minimalnode_modulescontaining only what’s actually used at runtime. Even so, the tree the sidecar releases ship comes to about 200 MB.scripts/stage-standalone.mjscopies the standalone output, the public assets, and the better-sqlite3 native module into the right layout forsrc-tauri/binariesto pick up.
The auto-updater
The public key for verification is embedded intauri.conf.json at build time. The matching private key is held offline; only the release pipeline (.github/workflows/macos-release.yml) signs latest.json + the patch tarball during a Run workflow action. A leaked private key would be the most serious incident in privacytracker’s threat model — it would let an attacker push a signed malicious update to every desktop install. The mitigation is keeping the key offline and air-gapped.
If a signature ever fails to verify, Tauri:
- Drops the patch.
- Logs to
audit_log(update_signature_invalid). - Surfaces a notification to the user.
- Does not retry the same patch.
src-tauri/src/main.rs and plugins.updater.active is true. For Tier 3 (public-internet) deploys that need deterministic update timing, see Hardening → Disable inbound auto-update on Tier 3 for the workarounds that actually exist.
Filesystem layout on disk
standalone/, with a .standalone-extracted-from-size-mtime marker beside it, on first run and again after every update. The Rust backend never reads either and leaves both where an earlier release put them. You can delete them to get the space back; a sidecar release extracts a fresh copy if you go back to one.
There is no logs/ directory here. tauri-plugin-log writes to Tauri’s LogDir (~/Library/Logs/org.privacykey.privacytracker/ on macOS) as a single rolling file holding the Rust shell’s log. Settings → Show log folder opens it (the open_log_dir command). On the Rust backend the server’s log lines go there too, because the server logs through the same log facade, in the same process.
There is no sidecar.log at all. On the sidecar releases, Node’s stdout and stderr are Stdio::inherit(), so its output goes to the parent’s stdout, visible only when the app is launched from a terminal:
data/ directory you’d see in a Docker install corresponds to this entire folder on the desktop. A backup bundle from one is restorable into the other, with the untrusted opt-in — see Backup & restore → Migrating between install paths.
On the Rust backend, the frontend and the licence notices ship inside the signed bundle:
/legal, the Legal link at the bottom left of every page) names the crates chosen directly, gives the licence breakdown, and links the full list.
Touch ID
src-tauri/src/touch_id.rs wraps macOS LocalAuthentication (LAPolicy::DeviceOwnerAuthentication — Touch ID with login-password fallback) and exposes it as the authenticate_touch_id command, with a 60-second timeout. On non-macOS hosts it always returns true.
Three call sites today:
- cfgutil app uninstall. A native prompt runs before the destructive
cfgutilsubprocess. The wizard’s “type DELETE” step is webview-side, so a compromised webview could invoke the command directly — the native modal is what it can’t fake. Documented at Devices → Device actions. - Enabling “Require unlock” in Desktop settings — confirms the user before persisting
require_unlock. Note the gate on window reveal isn’t implemented yet:reveal_main_windowalways reveals immediately and defers the lock overlay to the webview, which doesn’t draw one. - The “Test Touch ID” button in Desktop settings.
POST /api/reset involves neither. SettingsView.resetAllData() is a bare fetch("/api/reset", { method: "POST" }) with no headers — the route is protected by the same-origin proxy check, a 30-per-10-minutes rate limit, and the admin token when one is required, returning 401 otherwise. The admin token reaches the server as the pt_admin_token cookie the user establishes through /api/auth/admin-token/login; the Rust side never reads it from the environment and never injects it.
Touch ID is also deliberately not used to derive backup-encryption keys — bundles have to stay portable to the web build and to other machines.
Things that commonly trip people up
- The app quits as soon as it opens (Rust backend). The backend couldn’t start, so no window appears. Launch the binary from Terminal (
/Applications/privacytracker.app/Contents/MacOS/privacytracker): the line starting[privacytracker] FATAL: failed to start the backendgives the reason. If it says it could not find a built frontend, the bundle is incomplete; reinstall it from the latest DMG. - Sidecar fails to start with
NODE_MODULE_VERSION X does not match Y. better-sqlite3’s native module was built against a different Node ABI. Either rebuild it (npm rebuild better-sqlite3) against the bundled Node version, or update the bundled Node to match. The Tauri Bundled Node guide on the Plane board has the recipe. - App opens but the WebView is blank (sidecar releases). The sidecar didn’t answer
/api/appswithin the 60-second readiness deadline. Relaunch from Terminal (/Applications/privacytracker.app/Contents/MacOS/privacytracker) to see the sidecar’s stdout, and check~/Library/Logs/org.privacykey.privacytracker/for the Rust shell’s log. Usually it’s a port conflict (rare on macOS, more common on Linux) or a database lock another copy of the app is still holding. Quit every copy, look for a stray sidecar withpgrep -fl nodeand stop it, then copy~/Library/Application Support/privacytracker/somewhere safe before you try anything else. - Auto-update gets stuck. Tauri caches the checked state for 24h regardless of outcome. To force a re-check: quit the app, delete
~/Library/Caches/org.privacykey.privacytracker/, relaunch. spctl --assessfails withcode object is not signed at all. The bundle was tampered with after signing, or the download was truncated. Re-download from GitHub releases.