
Tauri
- 67 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tauri is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tauri
- AI & Agent Building
- AI-coding skill
Tauri by the numbers
- 67 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,935 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill tauriAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 67 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Tauri
Overview
Tauri is a framework for building desktop and mobile applications using web technologies (HTML, CSS, JavaScript/TypeScript) for the UI and Rust for the backend logic. It produces small, fast binaries by leveraging the OS webview instead of bundling a browser engine. The IPC layer connects the frontend to Rust commands through a capability-based permission system.
When to use: Cross-platform desktop/mobile apps with web UIs, system-level integrations (tray, notifications, file system), apps requiring small bundle sizes, security-sensitive applications needing fine-grained permission control.
When NOT to use: Pure web apps with no native requirements, Electron apps that depend heavily on Node.js APIs with no Rust migration path, projects where the team has no capacity to maintain Rust code.
Quick Reference
| Pattern | API / Config | Key Points |
|---|---|---|
| Create project | cargo create-tauri-app or pnpm create tauri-app | Scaffolds Rust backend + frontend framework |
| Define command | #[tauri::command] fn name() {} | Must register in generate_handler! |
| Invoke from frontend | invoke('cmd_name', { arg: value }) | Returns Promise, args as camelCase JSON |
| Emit event (frontend) | emit('event-name', payload) | Global event, all listeners receive |
| Listen event | listen('event-name', handler) | Returns unlisten function |
| Manage state | app.manage(MyState {}) + State<'_, MyState> | No Arc needed, Mutex for mutability |
| Window creation | WebviewWindowBuilder::new(app, label, url) | Label must be unique per window |
| System tray | TrayIconBuilder::new().menu(&menu).build(app) | Requires tray-icon Cargo feature |
| Add plugin (Rust) | .plugin(tauri_plugin_name::init()) | Register in Builder chain |
| Add plugin (frontend) | @tauri-apps/plugin-name | NPM package per plugin |
| Define capability | src-tauri/capabilities/*.json | Scoped to windows, merged at build |
| Grant permission | "permissions": ["plugin:scope"] | Commands inaccessible without explicit grant |
| Sidecar binary | tauri.conf.json bundle.externalBin | Name must match {name}-{target_triple} |
| Custom protocol | tauri::Builder::default().register_uri_scheme_protocol | Intercept custom scheme:// URLs |
| Auto-updater | tauri-plugin-updater + JSON endpoint | Requires code signing |
| App bundle/sign | cargo tauri build | Platform-specific signing via env vars |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Multiple generate_handler! calls | Pass all commands to a single generate_handler! invocation |
Using &str in async command args | Use owned String types in async commands |
| Forgetting to add capability for plugin | Add permission identifier in src-tauri/capabilities/*.json |
Wrapping state in Arc | Tauri State handles reference counting internally |
Using std::sync::Mutex across .await points | Use tokio::sync::Mutex for async commands holding locks across awaits |
| Wrong sidecar binary name | Binary must be named {name}-{target_triple} (e.g., mybin-x86_64-pc-windows-msvc) |
| Assuming all windows share capabilities | Each window/webview gets capabilities scoped by label |
Using SystemTray (v1 API) | Use TrayIconBuilder from tauri::tray module in v2 |
| Not signing app before enabling updater | Auto-updater requires valid code signing on all platforms |
| Using window title for security decisions | Capabilities use window labels, not titles |
Delegation
- Command pattern discovery: Use
Exploreagent - Security review: Use
Taskagent - Code review: Delegate to
code-revieweragent
If the rust skill is available, delegate Rust language patterns, error handling, and async/concurrency to it.If the react-patterns skill is available, delegate React frontend patterns to it.If the svelte skill is available, delegate Svelte frontend patterns to it.If the vite skill is available, delegate build tooling and dev server configuration to it.If the github-actions skill is available, delegate CI/CD pipeline and release workflow configuration to it.References
- Project setup, configuration, and directory structure
- IPC commands, events, and channels
- State management patterns
- Window management and multi-window apps
- System tray and menus
- Plugin system and official plugins
- Security model, capabilities, and permissions
- Sidecar binaries and custom protocols
- App signing, distribution, and auto-updates
App Signing, Distribution, and Auto-Updates
Building for Production
pnpm tauri buildOutput appears in src-tauri/target/release/bundle/:
| Platform | Formats |
|---|---|
| macOS | .app, .dmg |
| Windows | .msi, .exe (NSIS) |
| Linux | .AppImage, .deb, .rpm |
Bundle Configuration
{
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.example.myapp",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
},
"macOS": {
"frameworks": [],
"minimumSystemVersion": "",
"signingIdentity": null,
"entitlements": null
},
"linux": {
"deb": {
"depends": []
}
}
}
}macOS Code Signing
Set environment variables for CI:
export APPLE_CERTIFICATE="base64-encoded-p12-certificate"
export APPLE_CERTIFICATE_PASSWORD="certificate-password"
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAM_ID)"For notarization:
export APPLE_API_ISSUER="issuer-uuid"
export APPLE_API_KEY="key-id"
export APPLE_API_KEY_PATH="/path/to/AuthKey.p8"Windows Code Signing
For EV certificates, configure in tauri.conf.json:
{
"bundle": {
"windows": {
"certificateThumbprint": "YOUR_CERT_THUMBPRINT",
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
}
}
}Auto-Updater
Setup
cargo add tauri-plugin-updater
pnpm add @tauri-apps/plugin-updatertauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())Generate Signing Keys
pnpm tauri signer generate -w ~/.tauri/myapp.keySet environment variables:
export TAURI_SIGNING_PRIVATE_KEY="content-of-private-key"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="key-password"Updater Configuration
{
"plugins": {
"updater": {
"pubkey": "YOUR_PUBLIC_KEY",
"endpoints": [
"https://releases.myapp.com/{{target}}/{{arch}}/{{current_version}}"
]
}
}
}Update Endpoint Response
The endpoint must return JSON matching this structure:
{
"version": "1.0.1",
"notes": "Bug fixes and performance improvements",
"pub_date": "2025-01-15T00:00:00Z",
"platforms": {
"darwin-aarch64": {
"signature": "SIGNATURE_STRING",
"url": "https://releases.myapp.com/myapp-1.0.1-aarch64.app.tar.gz"
},
"darwin-x86_64": {
"signature": "SIGNATURE_STRING",
"url": "https://releases.myapp.com/myapp-1.0.1-x86_64.app.tar.gz"
},
"linux-x86_64": {
"signature": "SIGNATURE_STRING",
"url": "https://releases.myapp.com/myapp-1.0.1-x86_64.AppImage.tar.gz"
},
"windows-x86_64": {
"signature": "SIGNATURE_STRING",
"url": "https://releases.myapp.com/myapp-1.0.1-x86_64-setup.nsis.zip"
}
}
}Check and Install Updates (Frontend)
import { check } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
const update = await check();
if (update) {
console.log(`Update available: ${update.version}`);
let downloaded = 0;
let contentLength = 0;
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength ?? 0;
break;
case 'Progress':
downloaded += event.data.chunkLength;
console.log(`Downloaded ${downloaded}/${contentLength}`);
break;
case 'Finished':
console.log('Download complete');
break;
}
});
await relaunch();
}Check and Install Updates (Rust)
use tauri_plugin_updater::UpdaterExt;
#[tauri::command]
async fn check_for_updates(app: tauri::AppHandle) -> Result<bool, String> {
let update = app
.updater()
.check()
.await
.map_err(|e| e.to_string())?;
if let Some(update) = update {
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|e| e.to_string())?;
Ok(true)
} else {
Ok(false)
}
}Updater Permissions
{
"permissions": ["updater:default", "process:allow-restart"]
}GitHub Releases as Update Endpoint
Use the CrabNebula or Tauri GitHub Action to publish releases:
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'v__VERSION__'
releaseBody: 'See the assets for download links.'
releaseDraft: true
prerelease: falseConfigure the updater endpoint to point to GitHub releases:
{
"plugins": {
"updater": {
"endpoints": [
"https://github.com/owner/repo/releases/latest/download/latest.json"
]
}
}
}IPC Commands
Defining Commands
Commands are Rust functions annotated with #[tauri::command]:
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}Register all commands in a single generate_handler! call:
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet, another_command])
.run(tauri::generate_context!())
.expect("error while running tauri application");Invoking from Frontend
import { invoke } from '@tauri-apps/api/core';
const greeting = await invoke<string>('greet', { name: 'World' });Arguments are passed as a camelCase JSON object. Rust parameter names in snake_case are automatically mapped from camelCase.
Async Commands
Async commands run on a separate Tokio task, keeping the main thread free:
#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
let response = reqwest::get(&url)
.await
.map_err(|e| e.to_string())?;
response.text().await.map_err(|e| e.to_string())
}Borrowed types like &str and State<'_, T> require owned types in async commands. Use String instead of &str.
Error Handling
Return Result<T, E> where E implements serde::Serialize:
use serde::Serialize;
#[derive(Debug, Serialize)]
enum AppError {
NotFound(String),
DatabaseError(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::NotFound(msg) => write!(f, "Not found: {msg}"),
AppError::DatabaseError(msg) => write!(f, "Database error: {msg}"),
}
}
}
#[tauri::command]
fn read_file(path: String) -> Result<String, AppError> {
std::fs::read_to_string(&path)
.map_err(|e| AppError::NotFound(e.to_string()))
}Frontend receives errors as rejected Promises:
try {
const content = await invoke<string>('read_file', { path: '/missing' });
} catch (error) {
console.error(error);
}Accessing Special Types in Commands
#[tauri::command]
fn with_context(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>,
) -> String {
let label = window.label();
format!("Called from window: {label}")
}Tauri injects AppHandle, WebviewWindow, and State automatically; they are not passed from the frontend.
Channels (Streaming Data)
Channels enable streaming data from Rust to the frontend:
use tauri::ipc::Channel;
#[tauri::command]
fn stream_progress(on_progress: Channel<u32>) {
for i in 0..=100 {
on_progress.send(i).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
}
}import { invoke, Channel } from '@tauri-apps/api/core';
const onProgress = new Channel<number>();
onProgress.onmessage = (progress) => {
console.log(`Progress: ${progress}%`);
};
await invoke('stream_progress', { onProgress });Events
Events provide pub/sub communication between frontend and backend.
Frontend to Frontend
import { emit, listen } from '@tauri-apps/api/event';
const unlisten = await listen<string>('user-logged-in', (event) => {
console.log(event.payload);
});
await emit('user-logged-in', 'alice');
unlisten();Rust to Frontend
use tauri::Emitter;
app.emit("backend-event", "payload data").unwrap();Frontend to Rust
use tauri::Listener;
app.listen("frontend-event", |event| {
println!("Received: {:?}", event.payload());
});Window-Scoped Events
Target events to specific windows:
import { Window } from '@tauri-apps/api/window';
const mainWindow = new Window('main');
await mainWindow.emit('window-specific-event', { data: 'value' });
const unlisten = await mainWindow.listen('response-event', (event) => {
console.log(event.payload);
});use tauri::Emitter;
window.emit("window-specific-event", "data").unwrap();Raw IPC Request
Access the full request object for advanced use cases:
use tauri::ipc::Request;
#[tauri::command]
fn raw_handler(request: Request) -> String {
let headers = request.headers();
format!("Content-Type: {:?}", headers.get("content-type"))
}Plugin System
Adding a Plugin
Each Tauri plugin has a Rust crate and an optional NPM package for frontend APIs.
Rust Side
cargo add tauri-plugin-dialogRegister in the builder chain:
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");Frontend Side
pnpm add @tauri-apps/plugin-dialogimport { open, save } from '@tauri-apps/plugin-dialog';Permissions
Add plugin permissions in src-tauri/capabilities/default.json:
{
"permissions": [
"core:default",
"dialog:default",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text",
"notification:default"
]
}Official Plugins Reference
Dialog
import { open, save, message, ask, confirm } from '@tauri-apps/plugin-dialog';
const selected = await open({
multiple: true,
filters: [{ name: 'Images', extensions: ['png', 'jpg'] }],
});
const savePath = await save({
defaultPath: 'document.txt',
filters: [{ name: 'Text', extensions: ['txt'] }],
});
await message('Operation complete', { title: 'Success', kind: 'info' });
const yes = await ask('Delete this file?', {
title: 'Confirm',
kind: 'warning',
});Clipboard
import { writeText, readText } from '@tauri-apps/plugin-clipboard-manager';
await writeText('Copied text');
const text = await readText();Notification
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from '@tauri-apps/plugin-notification';
let permitted = await isPermissionGranted();
if (!permitted) {
const permission = await requestPermission();
permitted = permission === 'granted';
}
if (permitted) {
sendNotification({ title: 'Tauri', body: 'Hello from Tauri!' });
}File System
cargo add tauri-plugin-fs
pnpm add @tauri-apps/plugin-fsimport { readTextFile, writeTextFile, exists } from '@tauri-apps/plugin-fs';
import { BaseDirectory } from '@tauri-apps/api/path';
const content = await readTextFile('config.json', {
baseDir: BaseDirectory.AppConfig,
});
await writeTextFile('config.json', JSON.stringify(config), {
baseDir: BaseDirectory.AppConfig,
});
const fileExists = await exists('config.json', {
baseDir: BaseDirectory.AppConfig,
});File system permissions use path scopes:
{
"permissions": [
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$APPCONFIG/**" }]
},
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$APPCONFIG/**" }]
}
]
}Store (Persistent Key-Value)
cargo add tauri-plugin-store
pnpm add @tauri-apps/plugin-storeimport { load } from '@tauri-apps/plugin-store';
const store = await load('settings.json', { autoSave: true });
await store.set('theme', 'dark');
const theme = await store.get<string>('theme');
await store.delete('theme');
await store.save();Shell (Opener)
cargo add tauri-plugin-opener
pnpm add @tauri-apps/plugin-openerimport { openUrl, openPath } from '@tauri-apps/plugin-opener';
await openUrl('https://tauri.app');
await openPath('/path/to/file.pdf');Deep Link
cargo add tauri-plugin-deep-link
pnpm add @tauri-apps/plugin-deep-linkRegister the scheme in tauri.conf.json:
{
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["myapp"]
}
}
}
}import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
await onOpenUrl((urls) => {
console.log('Deep link received:', urls);
});Autostart
cargo add tauri-plugin-autostartuse tauri_plugin_autostart::MacosLauncher;
tauri::Builder::default()
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
None,
))Global Shortcut
cargo add tauri-plugin-global-shortcut
pnpm add @tauri-apps/plugin-global-shortcutimport { register } from '@tauri-apps/plugin-global-shortcut';
await register('CommandOrControl+Shift+C', (event) => {
if (event.state === 'Pressed') {
console.log('Shortcut triggered');
}
});Plugin Naming Convention
| Component | Pattern |
|---|---|
| Cargo crate | tauri-plugin-{name} |
| NPM package | @tauri-apps/plugin-{name} |
| Permission ID | {name}:action or {name}:default |
| Config section | plugins.{name} |
Project Setup
Scaffolding
Create a new Tauri project with the official scaffolding tool:
pnpm create tauri-app my-appOr with Cargo:
cargo create-tauri-app my-appThe scaffolder prompts for frontend framework (React, Svelte, Vue, Solid, vanilla) and package manager. It generates both the Rust backend and frontend project in one step.
Directory Structure
my-app/
├── src/ # Frontend source (framework-dependent)
│ ├── App.tsx
│ └── main.ts
├── src-tauri/ # Rust backend
│ ├── Cargo.toml # Rust dependencies
│ ├── tauri.conf.json # Tauri configuration
│ ├── capabilities/ # Permission capability files
│ │ └── default.json
│ ├── icons/ # App icons (generated)
│ ├── src/
│ │ ├── main.rs # Desktop entry point
│ │ └── lib.rs # Shared logic (desktop + mobile)
│ └── gen/ # Auto-generated code (do not edit)
├── package.json
└── vite.config.ts # Or equivalent bundler configEntry Points
Desktop and mobile share lib.rs for core logic. The main.rs is desktop-only:
// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}// src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Configuration: tauri.conf.json
Core configuration lives in src-tauri/tauri.conf.json:
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
"productName": "my-app",
"version": "0.1.0",
"identifier": "com.example.my-app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
},
"app": {
"windows": [
{
"title": "My App",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}Cargo.toml Essentials
[dependencies]
tauri = { version = "2", features = [] }
tauri-build = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"Add feature flags as needed:
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }Development Workflow
# Start dev server (frontend HMR + Rust recompilation)
pnpm tauri dev
# Build production bundle
pnpm tauri build
# Generate app icons from a source image
pnpm tauri icon path/to/icon.png
# Run on Android emulator
pnpm tauri android dev
# Run on iOS simulator
pnpm tauri ios devMobile Setup
Initialize mobile targets after project creation:
pnpm tauri android init
pnpm tauri ios initThis creates src-tauri/gen/android/ and src-tauri/gen/apple/ directories with platform-specific project files. Mobile builds use the same lib.rs entry point annotated with #[tauri::mobile_entry_point].
Environment Variables
| Variable | Purpose |
|---|---|
TAURI_SIGNING_PRIVATE_KEY | Private key for update signing |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD | Password for signing key |
APPLE_CERTIFICATE | Base64-encoded signing certificate |
APPLE_CERTIFICATE_PASSWORD | Certificate password |
APPLE_SIGNING_IDENTITY | Code signing identity |
Security Model
Architecture
Tauri v2 uses a capability-based permission system that replaced the v1 allowlist. Every IPC command is inaccessible by default. Access must be explicitly granted through capabilities that scope permissions to specific windows and webviews.
Capability
├── Windows/Webviews (which UI contexts have access)
├── Permissions (which commands are allowed/denied)
│ └── Scopes (restrictions on command arguments)
└── Platforms (optional platform filtering)Capability Files
Capabilities live in src-tauri/capabilities/ as JSON or TOML files. All files in this directory are automatically included at build time.
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Main window permissions",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"fs:allow-read-text-file",
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$APPDATA/**" }]
}
]
}Permission Identifiers
Permissions follow the pattern {plugin}:{action}:
| Pattern | Meaning |
|---|---|
core:default | Core default permissions set |
dialog:default | All default dialog permissions |
fs:allow-read-text-file | Allow reading text files |
fs:deny-read-text-file | Explicitly deny reading text files |
shell:allow-open | Allow opening URLs/paths |
notification:default | All default notification permissions |
Deny permissions always take precedence over allow permissions.
Scoped Permissions
Restrict command arguments with scopes:
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$APPCONFIG/**" }, { "path": "$APPDATA/**" }]
}{
"identifier": "fs:allow-read-text-file",
"deny": [{ "path": "$HOME/.ssh/**" }]
}Path Variables
| Variable | Description |
|---|---|
$APPCONFIG | App-specific config directory |
$APPDATA | App-specific data directory |
$APPLOCALDATA | App-specific local data |
$APPCACHE | App-specific cache directory |
$APPLOG | App-specific log directory |
$HOME | User home directory |
$DESKTOP | User desktop directory |
$DOCUMENT | User documents directory |
$DOWNLOAD | User downloads directory |
$RESOURCE | App resource directory |
$TEMP | System temp directory |
Window Scoping
Capabilities target specific windows by label:
{
"identifier": "settings-capability",
"windows": ["settings"],
"permissions": ["store:default"]
}Use "windows": ["*"] to grant to all windows (use sparingly).
Platform-Specific Capabilities
{
"identifier": "desktop-only",
"windows": ["main"],
"platforms": ["linux", "macOS", "windows"],
"permissions": ["shell:allow-open"]
}{
"identifier": "mobile-only",
"windows": ["main"],
"platforms": ["iOS", "android"],
"permissions": ["haptics:default", "biometric:default"]
}Remote Domain Access
Grant capabilities to remote domains loaded in webviews:
{
"identifier": "remote-api",
"windows": ["main"],
"remote": {
"urls": ["https://*.myapp.com"]
},
"permissions": ["core:event:default"]
}Content Security Policy
Configure CSP in tauri.conf.json:
{
"app": {
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; connect-src ipc: http://ipc.localhost"
}
}
}Custom Command Permissions
For custom commands, create permission files in src-tauri/permissions/:
{
"identifier": "allow-greet",
"description": "Allows calling the greet command",
"commands": {
"allow": ["greet"]
}
}Then reference in capabilities:
{
"permissions": ["allow-greet"]
}Security Boundaries
What the capability system protects against:
- Frontend code accessing unauthorized system APIs
- Compromised frontend escalating to full system access
- One window accessing another window's capabilities
What it does NOT protect against:
- Malicious Rust backend code (full system access by design)
- Overly permissive scope configurations
- WebView zero-day vulnerabilities
- Compromised build environment
Key Rules
| Rule | Explanation |
|---|---|
| Labels for security, not titles | Window labels (not display titles) determine access |
| Deny overrides allow | A deny permission always wins over allow |
| Capabilities merge | Multiple capabilities for a window merge permissions |
| No iframe distinction on Linux/Android | Requests from iframes and windows are indistinguishable |
| Scopes are AND-combined | Multiple allow scopes are combined (any match works) |
Sidecar Binaries and Custom Protocols
Sidecar Binaries
Sidecars are external executables bundled with the app. They run as child processes managed by Tauri.
Configuration
Register sidecar binaries in tauri.conf.json:
{
"bundle": {
"externalBin": ["binaries/my-sidecar"]
}
}Binary Naming
Sidecar binaries must include the target triple in their filename:
binaries/
├── my-sidecar-x86_64-pc-windows-msvc.exe
├── my-sidecar-x86_64-unknown-linux-gnu
├── my-sidecar-aarch64-apple-darwin
└── my-sidecar-x86_64-apple-darwinThe config references the base name without the triple. Tauri resolves the correct binary at runtime.
Find the target triple:
rustc -Vv | grep hostRunning a Sidecar (Rust)
use tauri_plugin_shell::ShellExt;
#[tauri::command]
async fn run_sidecar(app: tauri::AppHandle) -> Result<String, String> {
let output = app
.shell()
.sidecar("my-sidecar")
.unwrap()
.args(["--input", "data.json"])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
String::from_utf8(output.stdout).map_err(|e| e.to_string())
} else {
Err(String::from_utf8(output.stderr).unwrap_or_default())
}
}Running a Sidecar (Frontend)
import { Command } from '@tauri-apps/plugin-shell';
const command = Command.sidecar('binaries/my-sidecar', [
'--input',
'data.json',
]);
const output = await command.execute();
console.log('stdout:', output.stdout);
console.log('stderr:', output.stderr);
console.log('exit code:', output.code);Streaming Sidecar Output
import { Command } from '@tauri-apps/plugin-shell';
const command = Command.sidecar('binaries/my-sidecar');
command.on('close', (data) => {
console.log(`Exited with code ${data.code}`);
});
command.stdout.on('data', (line) => {
console.log(`stdout: ${line}`);
});
command.stderr.on('data', (line) => {
console.error(`stderr: ${line}`);
});
const child = await command.spawn();
await child.kill();Sidecar Permissions
{
"permissions": [
{
"identifier": "shell:allow-execute",
"allow": [{ "name": "binaries/my-sidecar", "sidecar": true }]
}
]
}Custom Protocols
Register custom URI schemes to serve content from Rust:
tauri::Builder::default()
.register_uri_scheme_protocol("myapp", |_ctx, request| {
let path = request.uri().path();
let content = match path {
"/data" => b"Hello from custom protocol".to_vec(),
_ => b"Not found".to_vec(),
};
tauri::http::Response::builder()
.status(if path == "/data" { 200 } else { 404 })
.header("content-type", "text/plain")
.body(content)
.unwrap()
})
.run(tauri::generate_context!())
.expect("error while running tauri application");Access from the frontend:
const response = await fetch('myapp://localhost/data');
const text = await response.text();Asset Protocol
Tauri includes a built-in asset protocol for serving files from the file system:
<img src="asset://localhost/path/to/image.png" />Configure asset protocol scope in capabilities:
{
"permissions": [
{
"identifier": "core:asset:default",
"allow": [{ "path": "$APPDATA/**" }]
}
]
}Custom Protocol Use Cases
| Use Case | Pattern |
|---|---|
| Serve local images | Asset protocol with scoped paths |
| Stream binary data | Custom protocol returning application/octet-stream |
| Dynamic HTML generation | Custom protocol returning text/html |
| Proxy external APIs | Custom protocol forwarding to HTTP endpoints |
| Serve from database | Custom protocol querying embedded DB |
Shell Plugin Setup
Both sidecar and shell commands require the shell plugin:
cargo add tauri-plugin-shell
pnpm add @tauri-apps/plugin-shelltauri::Builder::default()
.plugin(tauri_plugin_shell::init())State Management
Basic Setup
Register state during app setup with app.manage():
use std::sync::Mutex;
struct AppData {
welcome_message: String,
}
struct Counter {
value: u32,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
app.manage(AppData {
welcome_message: "Welcome to Tauri!".into(),
});
app.manage(Mutex::new(Counter { value: 0 }));
Ok(())
})
.invoke_handler(tauri::generate_handler![get_welcome, increment])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Accessing State in Commands
Tauri injects state via the State extractor:
use tauri::State;
#[tauri::command]
fn get_welcome(data: State<'_, AppData>) -> String {
data.welcome_message.clone()
}Mutable State with Mutex
Wrap mutable state in std::sync::Mutex:
use std::sync::Mutex;
use tauri::State;
#[tauri::command]
fn increment(counter: State<'_, Mutex<Counter>>) -> u32 {
let mut counter = counter.lock().unwrap();
counter.value += 1;
counter.value
}Async Commands with Tokio Mutex
When holding a lock across .await points, use tokio::sync::Mutex:
use tokio::sync::Mutex;
use tauri::State;
struct AsyncDb {
connection: String,
}
#[tauri::command]
async fn query_db(db: State<'_, Mutex<AsyncDb>>) -> Result<String, String> {
let db = db.lock().await;
Ok(format!("Connected to: {}", db.connection))
}Standard std::sync::Mutex should be preferred unless the lock must be held across await points, because Tokio's mutex has slightly higher overhead.
Accessing State Outside Commands
Use the Manager trait through AppHandle:
use std::sync::Mutex;
use tauri::Manager;
fn setup_event_handlers(app: &tauri::AppHandle) {
let app_handle = app.clone();
app.listen("some-event", move |_event| {
let state = app_handle.state::<Mutex<Counter>>();
let mut counter = state.lock().unwrap();
counter.value += 1;
});
}State in Plugin Setup
Plugins manage their own state through the plugin builder:
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("my-plugin")
.setup(|app, _api| {
app.manage(PluginState::default());
Ok(())
})
.invoke_handler(tauri::generate_handler![plugin_command])
.build()
}Key Rules
| Rule | Explanation |
|---|---|
No Arc wrapper needed | Tauri's State handles reference counting internally |
One type per manage call | Each unique type can only be managed once |
| Type must match exactly | Wrong type in State<'_, T> causes a runtime panic |
| Mutex for mutability | Shared state across threads requires interior mutability |
State is read-only ref | The State wrapper provides &T, not &mut T |
System Tray
Setup
Enable the tray feature in Cargo.toml:
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }Basic Tray Icon
use tauri::tray::TrayIconBuilder;
tauri::Builder::default()
.setup(|app| {
let tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.tooltip("My App")
.build(app)?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");Tray with Menu
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
tauri::Builder::default()
.setup(|app| {
let show = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?;
let hide = MenuItem::with_id(app, "hide", "Hide Window", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &hide, &separator, &quit])?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"hide" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.build(app)?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");Tray Icon Events
Handle click events on the tray icon itself:
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
TrayIconBuilder::new()
.on_tray_icon_event(|tray, event| match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
} => {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.maximize();
}
}
_ => {}
})
.build(app)?;Submenus
use tauri::menu::{Menu, MenuItem, Submenu};
let theme_light = MenuItem::with_id(app, "theme-light", "Light", true, None::<&str>)?;
let theme_dark = MenuItem::with_id(app, "theme-dark", "Dark", true, None::<&str>)?;
let theme_menu = Submenu::with_items(app, "Theme", true, &[&theme_light, &theme_dark])?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&theme_menu, &quit])?;Check Menu Items
use tauri::menu::CheckMenuItem;
let auto_start = CheckMenuItem::with_id(
app,
"auto-start",
"Start on Login",
true,
false,
None::<&str>,
)?;Dynamic Tray Updates
Update tray properties at runtime:
use tauri::Manager;
#[tauri::command]
fn update_tray_tooltip(app: tauri::AppHandle, message: String) -> Result<(), String> {
if let Some(tray) = app.tray_by_id("main") {
tray.set_tooltip(Some(&message)).map_err(|e| e.to_string())?;
}
Ok(())
}Named Tray Icons
Create tray with an explicit ID for later reference:
TrayIconBuilder::with_id("main")
.icon(app.default_window_icon().unwrap().clone())
.build(app)?;Permissions
Tray functionality requires capabilities:
{
"permissions": ["core:tray:default", "core:menu:default"]
}Platform Notes
| Feature | macOS | Windows | Linux |
|---|---|---|---|
| Left-click event | Yes | Yes | Yes |
| Right-click menu | Yes | Yes | Yes |
| Double-click | Yes | Yes | No |
| Cursor enter/leave | Yes | Yes | No |
| Animated icons | Yes | No | No |
Window Management
Static Windows in Config
Define windows in tauri.conf.json:
{
"app": {
"windows": [
{
"label": "main",
"title": "My App",
"width": 800,
"height": 600,
"resizable": true,
"decorations": true,
"transparent": false,
"url": "/"
},
{
"label": "settings",
"title": "Settings",
"width": 400,
"height": 300,
"url": "/settings"
}
]
}
}Creating Windows at Runtime (Rust)
use tauri::WebviewWindowBuilder;
use tauri::WebviewUrl;
#[tauri::command]
async fn open_settings(app: tauri::AppHandle) -> Result<(), String> {
let _window = WebviewWindowBuilder::new(
&app,
"settings",
WebviewUrl::App("/settings".into()),
)
.title("Settings")
.inner_size(400.0, 300.0)
.resizable(false)
.build()
.map_err(|e| e.to_string())?;
Ok(())
}Creating Windows at Runtime (Frontend)
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
const settingsWindow = new WebviewWindow('settings', {
url: '/settings',
title: 'Settings',
width: 400,
height: 300,
resizable: false,
center: true,
});
settingsWindow.once('tauri://created', () => {
console.log('Window created');
});
settingsWindow.once('tauri://error', (e) => {
console.error('Window creation failed', e);
});Window Operations
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
const appWindow = getCurrentWebviewWindow();
await appWindow.setTitle('New Title');
await appWindow.setSize({ type: 'Logical', width: 800, height: 600 });
await appWindow.setPosition({ type: 'Logical', x: 100, y: 100 });
await appWindow.center();
await appWindow.setFullscreen(true);
await appWindow.minimize();
await appWindow.maximize();
await appWindow.unmaximize();
await appWindow.setAlwaysOnTop(true);
await appWindow.setDecorations(false);
await appWindow.hide();
await appWindow.show();
await appWindow.close();Window Events
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
const appWindow = getCurrentWebviewWindow();
const unlisten = await appWindow.onCloseRequested(async (event) => {
const confirmed = await confirm('Are you sure you want to close?');
if (!confirmed) {
event.preventDefault();
}
});
await appWindow.onResized(({ payload: size }) => {
console.log(`Resized to ${size.width}x${size.height}`);
});
await appWindow.onMoved(({ payload: position }) => {
console.log(`Moved to ${position.x}, ${position.y}`);
});
await appWindow.onFocusChanged(({ payload: focused }) => {
console.log(`Window ${focused ? 'focused' : 'unfocused'}`);
});Inter-Window Communication
Windows communicate through the event system. Events can target specific windows or broadcast globally:
import { emit, listen } from '@tauri-apps/api/event';
await emit('theme-changed', { theme: 'dark' });
const unlisten = await listen<{ theme: string }>('theme-changed', (event) => {
applyTheme(event.payload.theme);
});From Rust, emit to a specific window:
use tauri::{Emitter, Manager};
#[tauri::command]
fn notify_window(app: tauri::AppHandle, label: String, message: String) {
if let Some(window) = app.get_webview_window(&label) {
window.emit("notification", message).unwrap();
}
}Window Builder Options
| Option | Type | Description |
|---|---|---|
title | String | Window title bar text |
inner_size | f64,f64 | Content area dimensions |
min_inner_size | f64,f64 | Minimum resize dimensions |
max_inner_size | f64,f64 | Maximum resize dimensions |
resizable | bool | Allow user resizing |
fullscreen | bool | Start in fullscreen |
decorations | bool | Show native title bar and borders |
transparent | bool | Transparent window background |
always_on_top | bool | Float above other windows |
visible | bool | Show window immediately |
center | () | Center window on screen |
focused | bool | Focus window on creation |
content_protected | bool | Prevent screenshots of window content |
Permissions Required
Add window permissions in capabilities:
{
"permissions": [
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-set-title",
"core:window:allow-set-size",
"core:window:allow-center"
]
}