tauri

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 capabi

SKILL.md

GitHub

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<T, E> 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.
// 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<String, String> {
    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<AppState>>) -> Result<u64, String> {
    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");
}
// 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:

// 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<AppState>>) { /* ... */ }

// Good: async-aware lock.
async fn save(state: State<'_, tokio::sync::Mutex<AppState>>) -> Result<(), String> { Ok(()) }

Stream vs notify

Two ways to push from Rust to the frontend — pick by ordering needs:

  • Channel<T> 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.
use tauri::ipc::Channel;

#[derive(Clone, serde::Serialize)]
struct Progress { downloaded: u64, total: u64 }

#[tauri::command]
async fn download(url: String, on_progress: Channel<Progress>) -> Result<(), String> {
    // ... as bytes arrive:
    on_progress.send(Progress { downloaded: 4096, total: 1_000_000 })
        .map_err(|e| e.to_string())?;
    Ok(())
}
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.

// 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<T, E> 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.