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 intauri::generate_handler![...]insideinvoke_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 Rustread_config(file_path: String). Command names stay snake_case; args auto-map camelCase (JS) ⇄ snake_case (Rust). - Fallible commands return
Result<T, E>whereE: Serialize. AnErrbecomes 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
asynccommand 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, usetokio::sync::Mutex, notstd::sync::Mutex— the std guard is notSendand 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/listenevents 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 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.externalBinto 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.