---
paths:
  - "**/*.rs"
  - "**/Cargo.toml"
  - "**/Cargo.lock"
  - "**/tauri.conf.json"
  - "**/src-tauri/**"
---
# Rust Desktop Apps (Tauri 2)

How native desktop apps are built: a **Rust core + web-frontend** shell — the cargo/JS dual workspace, the Rust↔TS contract seam, native-capability handling, and signed distribution. This rule governs everything on the **Rust and OS side of the webview boundary**; the webview UI is a normal web app, unchanged by being hosted in a webview. Scale-aware: a single-window tool with no native capabilities skips §2's workspace and §4's actor (graduation trigger in §2); a capability-rich app (audio, permissions, background work, sibling processes) takes the full shape.

## 1. Stack & frame

- **Tauri 2** — Rust core + the OS webview, one native binary. **Never Electron** (bundles Chromium + Node, no Rust core, ~10× the footprint); **never** a Rust-native GUI toolkit (`egui` / `iced` / `Slint`) for anything with real UI.
- **The frontend builds to static assets** that `frontendDist` points at and reaches Rust only through Tauri **commands** (request/response) and **events** (push). **No Node in production** — anything Node-only (`fs`, `spawn`, native modules) is a Rust command.
- **Dual workspace:** a cargo workspace for the Rust crates, a JS workspace for the webview app(s). Detect the JS package manager from the lockfile; never hardcode one.
- **Run the Tauri CLI via the JS devDependency** (`@tauri-apps/cli`, invoked as `pnpm tauri …`), **never** a global `cargo tauri` — the global drifts from the project's pinned CLI version.

| Need | Use | Not |
| --- | --- | --- |
| Rust→TS types | `ts-rs` derive → generated `.ts` | hand-written duplicate types, `serde-reflection` |
| Async runtime | `tokio` (`features=["full"]` at the app edge only) | async-std, smol |
| HTTP client | `reqwest` (`default-features=false`, `rustls-tls`) | system openssl, hyper direct |
| Errors | `thiserror` (library crates) · `anyhow` (the bin/app crate) | `Box<dyn Error>`, panicking on recoverable paths |
| Observability | `tracing` + `tracing-subscriber` | `log` + `env_logger`, `println!` |
| Localhost IPC to sibling processes | `axum` / `tokio-tungstenite` on a fixed port | ad-hoc TCP framing |
| JWT / auth | `jsonwebtoken` | rolling your own |
| Ids across process edges | `uuid` / `nanoid` | incrementing integers |

## 2. Workspace & crate structure

- **One crate per bounded context**, flat under `crates/`; the workspace root globs `members = ["crates/*"]` so adding a crate needs no root edit. Shared versions live in `[workspace.dependencies]`; members opt in with `<dep>.workspace = true`.
- **Exactly one binary crate — `<slug>-app` — is the composition root and the *only* crate allowed to touch Tauri, the OS/FFI, and the window.** Every other crate is a framework-agnostic library, unit-testable without Tauri. A native call outside `<slug>-app` is a layering break.
- **A dedicated `<slug>-contracts` crate owns every type that crosses the webview or a process boundary** (§3). No other crate defines a wire/command/event type.
- Crates are **kebab-case, prefixed with the app slug** (`sidekick-audio`, `sidekick-state`); the bin is `<slug>-app`.

> A single-window utility with no bounded contexts is **one crate** — the `-app` crate itself. Graduate to the workspace split at the second context (a background worker, a capture/engine unit, a sibling process).

## 3. Rust↔TS contract sync (the load-bearing seam)

The Rust type is the **single source of truth**; the TS type is generated from it.

- **`#[derive(TS)]` (`ts-rs`)** on every command argument, command return, and event payload in `<slug>-contracts`; an `export_bindings` test writes them into the webview package's `src/generated/`.
- **Generated files are committed and re-validated with a `zod` schema at the webview edge** — parse every inbound payload; never trust the boundary at runtime just because it typechecks.
- **CI runs a drift gate:** regenerate, then `git diff --exit-code` the generated dir — a nonzero diff means a Rust type changed without regeneration; the build fails.

```bash
# the two scripts every project wires:
contracts:gen    →  cargo test -p <slug>-contracts export_bindings   # regenerate
contracts:check  →  git diff --exit-code packages/*/src/generated/    # CI drift gate
```

- **Never hand-edit a generated file, and never hand-author a parallel TS type** — two sources of truth and a red diff gate. Change the Rust type and regenerate.

## 4. Process model & state

- **Single-writer state actor.** Mutable app state lives behind one owning task; the rest of the app sends it typed `Command`s and consumes an `Event` stream. **No** `Arc<Mutex<AppState>>` threaded through command handlers — that shared-mutable race is exactly what the actor removes.
- **The webview is a pure projection.** Rust pushes state through a typed emitter (`feed_state`, `nav_state`, …); the UI renders it and sends intents back as commands. Never compute authoritative state in the webview.
- **`#[tauri::command]` functions stay thin** — validate args, forward to the actor, return. Business logic lives in the library crates, not in command handlers.

## 5. Native capability & permissions

- **Degrade, never panic, on a missing capability.** A missing model file, an ungranted permission, an absent device → a `tracing::warn!` and a disabled feature, **never** a crash. The app must start and run with zero secrets and zero optional assets present.
- **Heavy or licensed assets (ML models, large binaries) resolve from the bundle resource dir via an env var — never committed, never downloaded at runtime.** The crate takes a `PathBuf` and never touches the network; absent ⇒ that stage degrades per the rule above.
- **OS permission grants (macOS TCC, etc.) key to the *signed bundle identity* and cannot be granted programmatically.** A permission-gated path that isn't granted returns "unavailable" — it does not block startup — and its real exercise is an `#[ignore]`d hardware test (§8).

> **Agent guard:** when a capability is unavailable in dev/CI, **do not** stub a fake success, fabricate engine output, or add a runtime download to "make it work." Wire the degrade path and mark the real path `#[ignore]`. Silent fallback hides a broken build — fail visibly.

## 6. Build profiles & `target/` hygiene

Debug builds and per-crate **test** binaries accumulate; a workspace `target/` reaches tens of GB without discipline.

```toml
# workspace Cargo.toml
[profile.dev]
debug = "line-tables-only"

[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true                 # the shipped binary carries no symbols
```

- **`debug = "line-tables-only"` on `profile.dev`** — full `debug = 2` bloats each of the dozens of per-crate test executables; line tables keep `file:line` panic backtraces while cutting binaries 40–60%. `profile.test` inherits it.
- **Cap `target/` with `cargo-sweep`, not periodic `cargo clean`** — `cargo sweep --maxsize <MB> -r .` (or `--time <days>`) evicts stale hashed artifacts while keeping the cache warm; cargo never garbage-collects old test binaries itself.
- **In CI, cache `target/` with `Swatinem/rust-cache`** (it prunes intermediates before caching). `target/` is always git-ignored.

**Pitfall:** `cargo build`/`cargo test` fails with `No such file or directory` on a path that doesn't exist — the repo was **moved or renamed with a warm `target/`**; absolute paths are baked into build-script outputs and `env!("CARGO_MANIFEST_DIR")` test binaries. Fix: one `cargo clean` (or `cargo sweep`) after relocating the repo root.

## 7. Signing, notarization & distribution

- **Distribute via Developer ID (direct-download `.dmg`/`.app`), not the App Store**, when the app needs capabilities the sandbox blocks (system audio, broad filesystem). Developer ID needs no provisioning profile.
- **Signing is opt-in and must never hard-fail an unsigned build.** No signing secrets present → an **unsigned** artifact; secrets present → sign → notarize → staple. Drive the build with `tauri-apps/tauri-action`.
- **A universal macOS binary needs *both* `aarch64-apple-darwin` and `x86_64-apple-darwin` targets installed** — `--target universal-apple-darwin` fails with only one. A local run uses just the host arch.

> **Agent guard (signing):** a signing or notarization failure is **stop-and-report** — never disable signing, never fabricate a certificate, never fall back to ad-hoc (`-`) signing to get a green build. Report the missing secret or identity. (Cf. the `NPM_PUBLISH_TOKEN` guard in the `nustack` skill.)

## 8. Testing

Framework: `cargo test`. This section is the desktop-specific shape.

- **Headless-by-default.** The whole non-UI, non-hardware path (state actor, commands, networking, business logic) is tested with **no window, no device, no models** — mock the external service, drive the actor directly. This is the tier CI runs.
- **Deterministic fixtures over live capture** — a committed input fixture drives the real state machines and asserts exact outputs; never gate a unit test on a live device.
- **Hardware / permission / model tests are `#[ignore]`d behind a feature or env var, run only on real hardware, and skip *honestly*** — print `skipping (model absent)` and return; **never** a fake pass. Non-default features (`--features real-*`) gate the heavy dependency so the default build stays light.

## 9. Config & environment

- **`.env.example` is the canonical env contract**; read keys via `std::env` at the app edge, never bake them into a client instance. Ship `.env.example`, **never** a real `.env`.
- **Bundle metadata + entitlements live beside the `-app` crate** (`tauri.conf.json` next to the bin), not at the repo root — a non-obvious location; tooling and agents point `tauri-action` at that dir.
- **A fixed cross-process port is a declared constant, not a config knob** when a sibling (extension, viewer) hard-codes it — a port env var the peer can't read is a footgun; drop it and document the constant.

## 10. Formatting & gates

- **`cargo fmt --check` and `cargo clippy -- -D warnings` are CI gates**, not suggestions — they run alongside the contract-drift gate (§3) and the frontend checks.
- The Rust `edition` and `rust-version` (MSRV) are pinned in `[workspace.package]`; bumping either is a deliberate change, never incidental.
