Imported from publieople/hermes-config-kit (
skills/software-development/tauri-v2-development/SKILL.md). Install upstream withnpx skills add publieople/hermes-config-kit --skill tauri-v2-development. Copyright stays with the author.
Tauri v2 Development
When to Use
- Starting a new Tauri v2 project (desktop or mobile)
- Figuring out which plugins are available for a Tauri feature
- Planning architecture for a Tauri app with Rust backend + web frontend
- Setting up Android build target for a Tauri project
- Deciding between Tauri plugins vs custom implementation
Quick Start
# Scaffold a new project (pick one)
pnpm create tauri-app # PNPM
npm create tauri-app@latest # NPM
cargo install create-tauri-app --locked && cargo create-tauri-app # Cargo
# Install Tauri CLI (if not already)
cargo install tauri-cli
# Dev mode
pnpm tauri dev
# Build
pnpm tauri build
Plugin Ecosystem
Tauri v2 plugins are the primary way to access native platform features. Most are official, under tauri-apps/plugins-workspace.
| Plugin | npm package | Rust crate | Purpose |
|---|---|---|---|
| System Tray | built-in | tauri::tray |
Tray icon, right-click menu, left-click handler |
| Global Shortcut | @tauri-apps/plugin-global-shortcut |
tauri-plugin-global-shortcut |
Register system-wide hotkeys |
| Clipboard | @tauri-apps/plugin-clipboard-manager |
tauri-plugin-clipboard-manager |
Read/write text to system clipboard |
| Single Instance | built-in | tauri-plugin-single-instance |
Prevent duplicate app instances |
| Autostart | @tauri-apps/plugin-autostart |
tauri-plugin-autostart |
Launch app on system startup |
| Shell | @tauri-apps/plugin-shell |
tauri-plugin-shell |
Execute shell commands |
Install a plugin
pnpm tauri add <plugin-name> # Installs both npm + cargo deps
# OR manually:
cargo add tauri-plugin-<name> # In src-tauri/
pnpm add @tauri-apps/plugin-<name> # In frontend/
Then register in src-tauri/src/lib.rs:
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.run(tauri::generate_context!())
.expect("error while running tauri application");
And add permissions in src-tauri/capabilities/default.json:
{
"permissions": [
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"global-shortcut:allow-register",
"autostart:allow-enable",
"autostart:allow-disable"
]
}
Architecture Patterns
Recommended: Vanilla JS frontend + Rust backend
For simple apps (previewers, utilities, tools), skip React/Vue/Svelte. Use:
src/ # Vanilla HTML/CSS/JS (no framework)
src-tauri/ # Rust backend
src/
main.rs # Entry point: plugin init, setup hook
tray.rs # System tray logic
commands.rs # #[tauri::command] functions
config.rs # Settings persistence (serde + JSON)
Cargo.toml
tauri.conf.json
capabilities/
default.json # Permission whitelist
When to use React/Vue/etc
Only when the frontend has complex state management (forms, reactive data, routing). For a markdown previewer with a textarea and a render div, vanilla JS + Vite bundler is sufficient and keeps binary size small.
Tauri CLI conventions
pnpm tauri dev # Dev with hot reload
pnpm tauri build # Production build
pnpm tauri android dev # Android dev (requires Android Studio)
pnpm tauri android build # Android APK build
pnpm tauri icon <file> # Generate all icon sizes
Detailed API Gotchas (discovered in practice)
global-shortcut: matches() takes (Modifiers, Code), NOT a string
// ❌ WRONG — compile error "expected 2 arguments"
shortcut.matches("Ctrl+Shift+M")
// ✅ CORRECT
use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut};
let target = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyM);
shortcut == &target // compare Shortcut values directly
global-shortcut: plugin goes on Builder, NOT in setup()
// ❌ WRONG — setup() gives you App, not Builder; app.plugin() doesn't exist
builder.setup(|app| {
app.plugin(tauri_plugin_global_shortcut::Builder::new()...)?;
})
// ✅ CORRECT — register handler on Builder, shortcut in setup
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_global_shortcut::Builder::new()
.with_handler(|_app, _shortcut, event| { ... })
.build());
builder.setup(|app| {
app.global_shortcut().register(shortcut)?;
Ok(())
});
autostart v2.5+: init() takes extra args AND .autostart() on AppHandle doesn't exist
// API changed in v2.5+. init() requires MacosLauncher + Option<Vec<&str>> args.
// Additionally, `app.autostart().enable()` does NOT compile — the AutostartExt
// trait path changed. Skip for MVP; implement in later phase with version-pinned docs.
// Workaround: use placeholder commands returning Ok(false) / Ok(()) for now.
Emitter trait: must import explicitly for window.emit()
use tauri::Emitter; // ← REQUIRED, or emit() won't resolve
window.emit("event-name", payload)?;
Icon: must be RGBA (color type 6), not RGB
// generate_context!() panics with "icon is not RGBA" if PNG is color type 2 (RGB)
// Use color type 6 (RGBA) with alpha channel
// Quick RGBA PNG generator: see references/png-icon-generator.py
Vite + Tauri multi-page: use flat array for rollupOptions.input
// ✅ Preserves flat output: dist/index.html, dist/settings.html
rollupOptions: {
input: [
resolve(root, 'index.html'),
resolve(root, 'settings.html'),
],
}
// ❌ Named entries create subdirectories: dist/main/index.html
Vite publicDir when root is not project root
// When root: 'src', the default publicDir becomes 'src/public'.
// To keep static assets at project-root/public/, set explicitly:
export default defineConfig({
root: 'src',
publicDir: '../public', // ← REQUIRED when root ≠ '.'
// ...
});
Vite base for GitHub Pages subpath deployment
// MUST set base when deploying to GH Pages at a subpath (e.g., /repo-name/):
export default defineConfig({
base: '/repo-name/', // affects all asset URLs, manifest links, SW paths
// ...
});
// Without this, assets 404 because they resolve relative to root '/'.
npm 11 Arch bug: devDependencies silently skipped
npm 11 on Arch Linux does not install devDependencies into node_modules/.
Workaround: use pnpm instead (preferred for all Tauri projects), or merge all deps into dependencies.
# ✅ Preferred: use pnpm
pnpm install
# ❌ npm 11 on Arch: devDeps silently missing, build fails
npm install
pnpm 11: onlyBuiltDependencies moved to pnpm-workspace.yaml
pnpm 11 no longer reads the pnpm.onlyBuiltDependencies field in package.json (prints [WARN] The "pnpm" field in package.json is no longer read). Build scripts for esbuild / @tauri-apps/cli get silently blocked (ERR_PNPM_IGNORED_BUILDS), leaving node_modules/.bin/esbuild missing and vite build failing.
Fix — pnpm-workspace.yaml at the pnpm working dir:
allowBuilds:
esbuild: true
'@tauri-apps/cli': true
Then rm -rf node_modules && pnpm install. If it still blocks, pnpm rebuild esbuild runs the postinstall manually and fixes the binary (esbuild lives in the pnpm store; the .bin symlink being missing is cosmetic — vite finds it via the store).
Tauri src-tauri inside a Rust workspace: append [workspace] to isolate
If the repo root has its own Cargo.toml workspace (e.g. crates/core + crates/cli), cargo treats gui/src-tauri/ as a member and errors:
error: current package believes it's in a workspace when it's not:
current: /repo/gui/src-tauri/Cargo.toml
workspace: /repo/Cargo.toml
Fix: append an empty [workspace] table to gui/src-tauri/Cargo.toml — declares it an independent workspace, keeps tauri-build's dependency tree separate from the root. Then path-depend on the core crate (bootkeeper-core = { path = "../../crates/core" }).
Cross-compiling src-tauri on WSL: llvm-rc missing, use CI instead
cargo check --target x86_64-pc-windows-msvc in src-tauri fails at the tauri-build build script:
thread 'main' panicked at tauri-winres: NotAttempted("llvm-rc")
WSL lacks the Windows resource compiler. Environment limit, not code — CI's windows-latest runner has full MSVC + rc.exe. Add a dedicated gui job:
gui:
runs-on: windows-latest
defaults: { run: { working-directory: gui } }
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm, cache-dependency-path: gui/pnpm-lock.yaml }
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: cargo check
working-directory: gui/src-tauri
Make sure .gitignore covers gui/src-tauri/target (root /target pattern only matches the root dir).
Key Pitfalls
1. Permission must be explicitly added
Every Tauri plugin command needs explicit permission in capabilities/default.json. Missing permission = silent failure or runtime error. Check the plugin docs for exact permission strings.
2. Plugin registration order matters
Register plugins with .plugin() BEFORE .setup() in the builder chain. Some plugins need to be initialized before the setup hook runs.
3. Window management: hide vs close
Use window.hide() not window.close() for tray apps. Closing destroys the WebView; hiding preserves state. On app exit, explicitly call app.exit(0).
4. System tray onActionEvent in Rust vs JS
Rust tray APIs use TrayIconBuilder::on_menu_event(). JS tray APIs use Menu.new({ items: [{ action: fn }] }). Choose one — don't mix. For a tray-heavy app, Rust side is cleaner since it has direct access to window handles.
5. Android clipboard: foreground-only without Shizuku
On Android, ClipboardManager.OnPrimaryClipChangedListener only fires when the app is in the foreground. For background monitoring, Shizuku (ADB-level privileged process) is required. See references/tauri-android-clipboard.md for details.
6. Single instance requires desktop cfg guard
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
let _ = app.get_webview_window("main")
.expect("no main window")
.set_focus();
}));
}
Mobile platforms don't need single-instance enforcement (OS handles it).
7. Window visibility state management
8. Android requires [lib] with cdylib in Cargo.toml
error: no library targets found in package `app-name`
Android builds (tauri android build) compile with --lib flag. Without [lib], it fails. Fix:
# src-tauri/Cargo.toml
[lib]
name = "app-name"
crate-type = ["lib", "cdylib"]
Then move run() to src/lib.rs:
// src/lib.rs
pub fn run() { /* plugin setup, builder chain */ }
// src/main.rs
fn main() { app_name::run(); }
9. Entire tray module needs #![cfg(desktop)] for Android
// src-tauri/src/tray.rs — FIRST line:
#![cfg(desktop)]
And in lib.rs: #[cfg(desktop)] mod tray;
Without this, Android builds fail because tauri::menu, tauri::tray, window.center() etc. don't exist on mobile targets.
10. .ico must be valid ICO format (not PNG renamed)
error RC2175: resource file icon.ico is not in 3.00 format
Windows Resource Compiler validates .ico structure. Generate proper ICO (PNG data in ICO wrapper). See references/icon-generation.md.
11. pnpm tauri android init required before first Android build
Android Studio project directory .../gen/android doesn't exist
CI must run pnpm tauri android init before pnpm tauri android build on clean checkouts.
When toggling a preview window (show/hide via hotkey or tray):
- Track visibility in Rust state:
Mutex<bool>orAtomicBool - Use
window.is_visible()to check current state - Use
window.show()/window.hide()plus.set_focus()when showing
12. Bundling external binaries as resources (CLI + helper pattern)
When the app ships sidecar binaries (CLI, elevated helper) that Rust code invokes at runtime:
- tauri.conf.json
bundle.resourcesmaps them into the installer:
"bundle": {
"resources": {
"resources/bootkeeper.exe": "bootkeeper.exe",
"resources/bootkeeper-helper.exe": "bootkeeper-helper.exe"
}
}
- beforeBuildCommand must build them BEFORE the Tauri build, then copy into
src-tauri/resources:
"beforeBuildCommand": "pnpm build && cargo build --release -p bootkeeper-cli -p bootkeeper-helper && node scripts/prepare-resources.mjs"
- prepare-resources.mjs path trap: output MUST be
gui/src-tauri/resources(where tauri.conf'sresources/resolves from — relative tosrc-tauri/), NOTgui/scripts/resources. Source is the workspace roottarget/release. Wrong output dir =resource path 'resources\bootkeeper.exe' doesn't existat build script time. - CI gui job must build before
cargo check: tauri.conf references resources, so a barecargo checkfails with "resource path doesn't exist" unless the CLI+helper were compiled first:
- run: cargo build --release -p bootkeeper-cli -p bootkeeper-helper
working-directory: .
- run: node scripts/prepare-resources.mjs
- run: cargo check
working-directory: gui/src-tauri
- Runtime helper discovery: bundled install =
bootkeeper-helper.exenext to the GUI exe; dev mode = walk up to repo root and checktarget/{release,debug}andsrc-tauri/target/{release,debug}. AllowBOOTKEEPER_HELPERenv override. (current_exe().parent()is the installer root where resources land.) .gitignoremust coversrc-tauri/resources(generated artifacts).
13. Release workflow with version auto-injection
Tauri reads the product version from tauri.conf.json (and Cargo.toml). If these are hardcoded ("0.1.0"), every release produces the same filename. Use a CI step that extracts the semver from the git tag and injects it before pnpm tauri build:
- name: Set version from git tag
shell: bash
working-directory: . # repo root — Cargo.toml is here, not under gui/
env:
VER: ${{ github.ref_name }}
run: |
VER="${VER#v}" # v0.1.1 -> 0.1.1
python3 -c "
import os, re, json
v = os.environ['VER']
c = open('Cargo.toml').read()
c = re.sub(r'^version\s*=\s*\"[^\"]+\"', f'version = \"{v}\"', c, count=1, flags=re.M)
open('Cargo.toml', 'w').write(c)
d = json.load(open('gui/src-tauri/tauri.conf.json'))
d['version'] = v
open('gui/src-tauri/tauri.conf.json', 'w').write(json.dumps(d, indent=2) + '\n')
"
After injection, pnpm tauri build produces BootKeeper_0.1.1_x64-setup.exe matching the tag.
Key detail: this step runs at repo root (working-directory: .), NOT under gui/.
14. Cross-privilege data directory (elevated helper pattern)
When the app spawns an elevated helper process (ShellExecuteW runas), the helper runs under a different user profile (admin). %APPDATA% in the helper resolves to C:\Windows\System32\config\systemprofile\AppData\Roaming, NOT the user's AppData. Temp files written via APPDATA by the non-elevated app are invisible to the helper.
Fix: use %ProgramData% (machine-wide, all-privilege, always the same path):
pub fn data_root() -> PathBuf {
if let Ok(p) = std::env::var("MYAPP_DATA") { return PathBuf::from(p); }
let base = std::env::var("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("C:\\ProgramData"));
base.join("MyApp")
}
Request files and results pass via absolute paths in argv, so they survive the elevation boundary — but snapshot stores, temp dirs, and any persistent state that BOTH processes access must live under ProgramData.
15. MSI target rejects semver pre-release identifiers
Tauri v2's MSI bundler enforces numeric-only version components. Semver pre-release labels crash the build:
failed to bundle project: `optional pre-release identifier in app version must be numeric-only and cannot be greater than 65535 for msi target`
- ❌
0.2.2-pre,0.2.2-alpha,0.2.2-beta.1— all rejected. - ✅
0.2.2,0.2.2.1(4-component numeric) — accepted.
If you need a pre-release build, use a 4-part numeric version (0.2.2.1, 0.2.2.2) instead of a semver pre-release tag. The version-injection step already strips leading v — but the tag itself must have no - segments.
16. beforeBuildCommand and CI: --manifest-path for workspace-dependent crates
When src-tauri is its own workspace, the beforeBuildCommand runs inside gui/ where root-workspace crate names aren't visible. Use --manifest-path:
"beforeBuildCommand": "pnpm build && cargo build --release --manifest-path ../Cargo.toml -p cli -p helper && node scripts/prepare-resources.mjs"
CI's build-windows job with defaults: { run: { working-directory: gui } } needs its pre-build step at repo root:
- run: cargo build --release -p cli -p helper
working-directory: .
- run: node gui/scripts/prepare-resources.mjs
working-directory: .
WSL2 Development Notes
- Tauri dev (
pnpm tauri dev) works on WSL2 Linux, but plugins requiring native OS features (tray, global shortcut) may behave differently or not work - Frontend dev and testing works fully in WSL2 — use
pnpm tauri devfor quick UI iteration - For full integration testing of Windows-specific features (tray, hotkeys), build and run on Windows natively
cargo tauri buildfor Windows target requiresx86_64-pc-windows-msvctarget or cross-compilation viamingw-w64
Reference Files
| File | Contents |
|---|---|
references/scaffold-files.md |
Complete file templates for manual Tauri v2 project scaffolding |
references/tauri-plugins-reference.md |
Complete API reference for all major Tauri v2 plugins |
references/tauri-android-clipboard.md |
Android clipboard restriction history and approaches |
references/pwa-github-pages-deploy.md |
GitHub Pages PWA deployment workflow |
references/github-actions-build.md |
Multi-platform CI: Windows .msi + Android APK + Release |
references/icon-generation.md |
Programmatic icon generation (RGBA PNG, multi-size ICO) |
- Official docs: https://v2.tauri.app (always check before implementing plugin-specific code)