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).
npx skills add https://github.com/ericrisco/rsc-harness --skill tauri
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).
| 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.
A command is a Rust function the frontend can call. Each rule below has a one-line *why*.
#[tauri::command] on the fn, then list it intauri::generate_handler![...] inside invoke_handler. *Unregistered commands are not a
compile error — they fail at runtime when JS calls them.*
invoke('read_config', { filePath }) maps to Rustread_config(file_path: String). *Command names stay snake_case; args auto-map
camelCase (JS) ⇄ snake_case (Rust).*
Result<T, E> where E: Serialize. *An Err becomes arejected JS promise; a panic instead crashes the command thread silently.*
async command or aspawned task. *Commands run on a shared IPC thread pool — a blocking call freezes other
IPC, which users see as a frozen UI.*
.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(()) }
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, "datarefreshed," 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 });
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:
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.*
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.*
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.
tauri build produces native installers per OS — but unsigned binaries trigger
"unidentified developer" / SmartScreen warnings, so signing is not optional for distribution.
un-notarized apps.
.exe/MSI/NSIS or SmartScreen warns..deb / .rpm; no central signing authority, but ship checksums.tauri signer generate);the private key signs releases, the public key ships in config. *Without it the updater
refuses unsigned updates — by design.*
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-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.
Take ericrisco/tauri from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npx.
Without those the skill loads but fails at the first command.