| name | tauri |
| description | Tauri commands, permissions, capabilities, security config, path handling, cross-platform file ops, and native filesystem APIs. Use when mentioning Tauri, desktop apps, Rust commands, invoke, capabilities, permissions, ResourceId, file paths, or platform differences. |
| metadata | {"author":"epicenter","version":"1.0"} |
Tauri Patterns
Reference Repositories
- Tauri: Desktop app framework with Rust backend and web frontend
When to Apply This Skill
Use this pattern when you need to:
- Add or change Tauri commands, permissions, capabilities, or security config.
- Build file paths in Tauri frontend code running in the webview.
- Choose correctly between
@tauri-apps/api/path and Node/Bun path APIs.
- Replace manual slash concatenation with
join(), dirname(), and related helpers.
- Handle cross-platform filesystem behavior for desktop apps.
- Combine Tauri path APIs with
@tauri-apps/plugin-fs operations.
Commands, Permissions, And Security
- Expose focused Rust APIs with
#[tauri::command], register them with generate_handler!, and return Result<T, E> for fallible work.
- Validate command inputs on the Rust side. TypeScript callers are not the trust boundary.
- Keep capabilities least-privilege in
app.security.capabilities, scoped to the windows or webviews that need them. Avoid broad permission wildcards.
- Treat CSP,
devCsp, asset protocol configuration, convertFileSrc, freezePrototype, and remote IPC as security-sensitive config.
- Long-lived Rust objects should be Tauri resources with frontend
ResourceIds. Do not serialize complex long-lived objects through command responses.
Webview CSP
Never ship app.security.csp: null (that disables CSP entirely). The
highest-value directive is connect-src: locking it to your API origin plus
Tauri's IPC blocks an injected same-origin script from exfiltrating in-memory
secrets (tokens, keys) to an attacker host. Set both csp (production) and
devCsp (the dev override, which replaces csp during tauri dev):
"security": {
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://api.example.com wss://api.example.com; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
"devCsp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://localhost:5173 ws://localhost:5173 https://api.example.com wss://api.example.com; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'"
}
Rules: always include ipc: http://ipc.localhost in connect-src or invoke()
breaks; only list asset: / http://asset.localhost if the asset protocol is
actually enabled (convertFileSrc); always smoke-test a real tauri dev AND a
release build, watching the webview console for CSP violations.
Typed IPC And Generated Bindings
When a Tauri app uses tauri-specta, keep the Rust command registry, generated TypeScript bindings, and handwritten frontend wrapper in sync.
- Register every typed command in the
tauri_specta::collect_commands! builder.
- Register frontend-listened event payloads in
tauri_specta::collect_events!, even when no command returns that event type.
- For tauri-specta v2 RC events, use
#[tauri_specta(event_name = "...")] on the event type. Do not invent #[tauri_specta::event(...)] unless installed macro docs or local macro source prove that form exists.
- Re-export event and command payload types from their owning Rust module when
lib.rs imports them for the builder.
- Treat
bindings.gen.ts as derived output. Commit regenerated bindings only when the Rust IPC surface intentionally changed. If a command only fixes Rust compile shape without changing the public IPC contract, avoid broad generated churn.
- Commands returning raw
tauri::ipc::Response cannot be generated by specta because the body is not specta::Type. Mount those through a separate tauri::generate_handler! route and keep a small handwritten TypeScript wrapper.
Verification for IPC changes usually needs both sides:
cargo check --manifest-path apps/whispering/src-tauri/Cargo.toml
bun run --cwd apps/whispering bindings:tauri
If binding generation rewrites unrelated sections, inspect the diff before committing it.
Context Detection
Before choosing a path API, determine your execution context:
| Context | Location | Correct API |
|---|
| Tauri frontend | apps/*/src/**/*.ts, apps/*/src/**/*.svelte | @tauri-apps/api/path |
| Node.js/Bun backend | packages/**/*.ts, CLI tools | Node.js path module |
Rule: If the code runs in the browser (Tauri webview), use Tauri's path APIs. If it runs in Node.js/Bun, use the Node.js path module.
Available Functions from @tauri-apps/api/path
Path Manipulation
| Function | Purpose | Example |
|---|
join(...paths) | Join path segments with platform separator | await join(baseDir, 'workspaces', id) |
dirname(path) | Get parent directory | await dirname('/foo/bar/file.txt') → /foo/bar |
basename(path, ext?) | Get filename, optionally strip extension | await basename('/foo/bar.txt', '.txt') → bar |
extname(path) | Get file extension | await extname('file.txt') → .txt |
normalize(path) | Resolve .. and . segments | await normalize('/foo/bar/../baz') → /foo/baz |
resolve(...paths) | Resolve to absolute path | await resolve('relative', 'path') |
isAbsolute(path) | Check if path is absolute | await isAbsolute('/foo') → true |
Platform Constants
| Function | Purpose | Returns |
|---|
sep() | Platform path separator | \ on Windows, / on POSIX |
delimiter() | Platform path delimiter | ; on Windows, : on POSIX |
Base Directories
| Function | Purpose |
|---|
appLocalDataDir() | App's local data directory |
appDataDir() | App's roaming data directory |
appConfigDir() | App's config directory |
appCacheDir() | App's cache directory |
appLogDir() | App's log directory |
tempDir() | System temp directory |
resourceDir() | App's resource directory |
resolveResource(path) | Resolve path relative to resources |
Patterns
Constructing Paths (Correct)
import { appLocalDataDir, dirname, join } from '@tauri-apps/api/path';
const baseDir = await appLocalDataDir();
const filePath = await join(baseDir, 'workspaces', workspaceId, 'data.json');
const parentDir = await dirname(filePath);
await mkdir(parentDir, { recursive: true });
Logging Paths (Exception)
For human-readable log output, hardcoded / is acceptable since it's not used for filesystem operations:
const logPath = pathSegments.join('/');
console.log(`[Persistence] Loading from ${logPath}`);
Anti-Patterns
Never: Manual String Concatenation
const filePath = baseDir + '/' + 'workspaces' + '/' + id;
const filePath = `${baseDir}/workspaces/${id}`;
const filePath = await join(baseDir, 'workspaces', id);
Never: Manual Parent Directory Extraction
const parentSegments = pathSegments.slice(0, -1);
const parentDir = await join(baseDir, ...parentSegments);
const parentDir = await dirname(filePath);
Never: Hardcoded Separators in Filesystem Operations
const configPath = appDir + '/config.json';
const configPath = await join(appDir, 'config.json');
Never: Assuming Path Format
const parts = filePath.split('/');
const dir = await dirname(filePath);
const file = await basename(filePath);
Import Pattern
Always import from @tauri-apps/api/path:
import {
appLocalDataDir,
dirname,
join,
basename,
extname,
normalize,
resolve,
sep,
} from '@tauri-apps/api/path';
Note on Async
All Tauri path functions are async because they communicate with the Rust backend via IPC. Always await them:
const baseDir = await appLocalDataDir();
const filePath = await join(baseDir, 'file.txt');
const parent = await dirname(filePath);
const separator = await sep();
Filesystem Operations
Use @tauri-apps/plugin-fs for file operations, combined with Tauri path APIs:
import { appLocalDataDir, dirname, join } from '@tauri-apps/api/path';
import { mkdir, readFile, writeFile } from '@tauri-apps/plugin-fs';
async function saveData(segments: string[], data: Uint8Array) {
const baseDir = await appLocalDataDir();
const filePath = await join(baseDir, ...segments);
const parentDir = await dirname(filePath);
await mkdir(parentDir, { recursive: true });
await writeFile(filePath, data);
}
Native Ownership Boundaries
Prefer app-owned identifiers over frontend-controlled paths when the native side owns data.
- Recording operations should pass a recording id when Rust owns the recordings directory.
- Model selection can pass a path, but Rust should canonicalize it and reject values outside the allowed app data model directory.
- Markdown export, downloads, and temporary files should use focused commands rooted in app-owned directories rather than broad filesystem permissions.
- Removing a capability from
capabilities/*.json, Cargo.toml, or package.json should be paired with removing stale docs and UI that still describe that permission.
The frontend can remember user intent. Rust enforces the filesystem boundary.