--- name: tauri description: "Use when building a lightweight cross-platform desktop (or v2 mobile) app with Tauri — a Rust core plus the OS-native WebView: Rust commands and IPC, streaming to the frontend, the default-deny capabilities/permissions ACL, bundling and signed auto-updates. NOT a Chromium+Node shell needing Node APIs in a main process (that is electron)." tags: [tauri, desktop, rust, cross-platform, ipc, webview] recommends: [rust, electron, react, secure-coding, github-actions] origin: risco --- # Tauri — Rust core, OS WebView, locked-down IPC, tiny binaries Tauri builds a desktop (and, since v2, mobile) app from a **Rust core** plus the **operating system's own WebView** — not a bundled browser. That is the whole value proposition: ~12MB installers and 30-50MB idle RAM, versus Electron's ~180MB installers and 150-300MB because it ships Chromium + Node. You write your UI in any web framework, expose privileged work as Rust commands, and the WebView talks to Rust over a sandboxed IPC bridge. **Always target v2.** Tauri 2.0 went stable in October 2024; the current line is 2.x (2.11.x as of mid-2026). v1 docs use a `tauri > allowlist` config that no longer exists — if you see `allowlist`, you are reading the wrong era. v2 also adds iOS/Android targets, so the same Rust core can ship to mobile. This skill owns the **shell**: commands, IPC, the security ACL, the bundler, the updater. It does **not** own the Rust language itself (that is the `rust` skill), the web UI inside the window (the `react`/`nextjs` skills), a Chromium+Node shell (`../electron/SKILL.md`), or app-code hardening beyond the IPC boundary (`../secure-coding/SKILL.md`). ## Pick your starting shape | Situation | Do this | |-----------|---------| | Greenfield app, no UI yet | `npm create tauri-app@latest` — pick your frontend, get `src-tauri/` wired | | You already have a web app (Vite/Next/etc.) | `npx @tauri-apps/cli@latest init` inside it; point `build.frontendDist` at your build output | | Add mobile to an existing desktop app | `tauri ios init` / `tauri android init`; gate native bits behind `#[cfg(mobile)]` | | You see `tauri.conf.json > tauri > allowlist` | You are on v1 — migrate to v2 capabilities before adding anything | `src-tauri/` is its own Cargo crate: `Cargo.toml`, `tauri.conf.json`, `src/lib.rs` (the `run()` entry point), and `capabilities/`. The frontend is a sibling directory the bundler reads from `frontendDist`. ## Commands & IPC — the core contract A command is a Rust function the frontend can call. Each rule below has a one-line *why*. - **Annotate and register.** `#[tauri::command]` on the fn, then list it in `tauri::generate_handler![...]` inside `invoke_handler`. *Unregistered commands are not a compile error — they fail at runtime when JS calls them.* - **Naming crosses the bridge.** JS `invoke('read_config', { filePath })` maps to Rust `read_config(file_path: String)`. *Command names stay snake_case; args auto-map camelCase (JS) ⇄ snake_case (Rust).* - **Fallible commands return `Result` where `E: Serialize`.** *An `Err` becomes a rejected JS promise; a panic instead crashes the command thread silently.* - **Never block the command thread.** Long or I/O work goes in an `async` command or a spawned task. *Commands run on a shared IPC thread pool — a blocking call freezes other IPC, which users see as a frozen UI.* - **Share state with `.manage(x)` + `State<'_, T>`.** *If a guard is held across an `.await`, use `tokio::sync::Mutex`, not `std::sync::Mutex` — the std guard is not `Send` and will not compile in an async command.* ```rust // src-tauri/src/lib.rs use tauri::State; use tokio::sync::Mutex; #[derive(Default)] struct AppState { counter: u64 } #[tauri::command] // registered below or it 404s at runtime async fn read_config(file_path: String) -> Result { tokio::fs::read_to_string(&file_path) // async I/O — does not block the IPC pool .await .map_err(|e| e.to_string()) // Err -> rejected JS promise } #[tauri::command] async fn bump(state: State<'_, Mutex>) -> Result { let mut s = state.lock().await; // tokio Mutex: guard is held across .await s.counter += 1; Ok(s.counter) } #[cfg_attr(mobile, tauri::mobile_entry_point)] // same core compiles for iOS/Android pub fn run() { tauri::Builder::default() .manage(Mutex::new(AppState::default())) .invoke_handler(tauri::generate_handler![read_config, bump]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` ```javascript // frontend import { invoke } from '@tauri-apps/api/core'; const text = await invoke('read_config', { filePath: '/app/config.toml' }); // throws (rejected promise) if the command returns Err — wrap in try/catch ``` Bad → Good, the failure people hit most: ```rust // Bad: std Mutex held across .await — won't compile in an async command, or you // "fix" it by dropping the guard early and create a race. async fn save(state: State<'_, std::sync::Mutex>) { /* ... */ } // Good: async-aware lock. async fn save(state: State<'_, tokio::sync::Mutex>) -> Result<(), String> { Ok(()) } ``` ## Stream vs notify Two ways to push from Rust to the frontend — pick by ordering needs: - **`Channel` for ordered streaming.** Download progress, file chunks, an HTTP body. *Messages arrive in send order on one typed channel — the right tool for "report progress as it happens."* - **`emit` / `listen` events for fire-and-forget pub/sub.** App-wide notifications, "data refreshed," a tray action. *No ordering or backpressure guarantees; many listeners, no reply.* ```rust use tauri::ipc::Channel; #[derive(Clone, serde::Serialize)] struct Progress { downloaded: u64, total: u64 } #[tauri::command] async fn download(url: String, on_progress: Channel) -> Result<(), String> { // ... as bytes arrive: on_progress.send(Progress { downloaded: 4096, total: 1_000_000 }) .map_err(|e| e.to_string())?; Ok(()) } ``` ```javascript import { Channel, invoke } from '@tauri-apps/api/core'; const onProgress = new Channel(); onProgress.onmessage = (p) => updateBar(p.downloaded / p.total); await invoke('download', { url, onProgress }); ``` ## Security — the part people skip Tauri v2's IPC is an **Access Control List**, default-deny. The chain: **capabilities** (group windows/webviews) → grant **permissions** (named command sets) → **permissions map scopes** (what data/paths a command may touch). **A webview that matches no capability has zero IPC access.** This is the opposite of v1's opt-out allowlist — you grant exactly what each window needs. ```json // src-tauri/capabilities/default.json — grant only what the main window uses { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-capability", "windows": ["main"], "permissions": [ "core:default", { "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$APPCONFIG/*" }] // scope: app config dir only, nothing else } ] } ``` Three more rules that bite real apps: - **CSP is only enforced if you set it** in `tauri.conf.json > app > security > csp`. *No CSP = the WebView runs whatever it loads; local scripts are hashed, external ones get a per-load nonce only once a CSP exists.* - **Use the isolation pattern when frontend code may be untrusted** (third-party deps, plugins). *It injects a sandboxed iframe that can inspect/modify every IPC message before it reaches Rust; messages are encrypted with SubtleCrypto using a key regenerated each app start.* - **Treat the WebView as hostile.** *Anything in the frontend bundle ships to the user — no API keys, tokens, or secrets in JS. Privileged work and secrets stay in Rust.* Full capabilities/permissions/scope JSON, fs/http scope globs, CSP dev-vs-prod recipes, and isolation-pattern setup live in `references/security.md`. ## Bundle & ship `tauri build` produces native installers per OS — but unsigned binaries trigger "unidentified developer" / SmartScreen warnings, so signing is not optional for distribution. - **macOS:** sign with a Developer ID cert, then **notarize** — Gatekeeper blocks un-notarized apps. - **Windows:** Authenticode-sign the `.exe`/MSI/NSIS or SmartScreen warns. - **Linux:** AppImage / `.deb` / `.rpm`; no central signing authority, but ship checksums. - **Auto-update:** the updater plugin needs a signing keypair (`tauri signer generate`); the private key signs releases, the public key ships in config. *Without it the updater refuses unsigned updates — by design.* - **Sidecar:** embed an external binary via `bundle.externalBin` to call it at runtime (e.g. ship a CLI your app shells out to). Per-OS flags, notarization steps, updater config, sidecar setup, and a CI release matrix live in `references/bundling-distribution.md`. The CI runner matrix that *runs* those builds across three OSes is the `github-actions` skill's job; this skill defines what to build and sign. ## Anti-patterns | Anti-pattern | Why it's wrong | Do instead | |--------------|----------------|------------| | One capability granting broad permissions to all windows | Any XSS gets the full IPC surface | Per-window capability, scoped to the commands that window needs | | Permission with no `allow`/scope on `fs`/`http` | Command can touch any path/host | Add an `allow` glob (`$APPCONFIG/*`) and deny the rest | | Blocking call (`std::fs`, sync HTTP) in a command | Freezes the IPC pool → frozen UI | `async fn` + `tokio` / spawn the work | | `.unwrap()` instead of returning `Result` | Panic crashes the command thread silently | Return `Result`, `map_err` to a serializable error | | API keys/tokens in the frontend bundle | Ships to every user; trivially extracted | Keep secrets and privileged calls in Rust | | No CSP set in config | WebView runs any loaded script | Set `app.security.csp`; isolation pattern if deps are untrusted | | Copying a v1 `tauri.conf.json > allowlist` | That key does not exist in v2 | Use `capabilities/*.json` (ACL) | | Assuming bundled Chromium | It's the **OS** WebView (WebKit/WebView2) | Test rendering on each OS's engine; avoid Chromium-only CSS/JS | `scripts/verify.sh` is an advisory static lint over an `src-tauri/` tree that catches several of these: capabilities present, registered commands exist, no v1 `allowlist`, fallible-looking commands return `Result`.