Tauri 2 App Scaffolder
Generate a production-grade Tauri 2 desktop app that keeps the good patterns from a battle-tested codebase (thin TS frontend that only invokes commands, rich Rust backend with modular commands/, state/, storage/, platform/, error/, plugin-based features wired in lib.rs, capability JSON per window, encrypted secrets at rest, single-instance enforcement, updater wired to a self-hosted JSON manifest, cross-platform CI matrix, target-specific Cargo dependencies, release-profile LTO, typed frontend command hooks) and eliminates the bad ones often seen in Tauri codebases (committed .backup / .orig / .temp files in src-tauri/src/, API keys in plaintext settings JSON, tokens in localStorage, cfg!(target_os) scattered through command bodies instead of trait-based platform code, hand-rolled date math instead of chrono::Utc::now(), raw std::fs reads/writes that bypass Tauri's capability checks, blocking I/O inside #[tauri::command] without tokio::task::spawn_blocking, missing #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] in main.rs causing a console flash on Windows, multi-instance apps without tauri-plugin-single-instance, devtools: true in release config, generic catch-all String errors with no thiserror enum at module boundaries, hardcoded company-specific bundle identifiers / R2 URLs / updater pubkeys / signing identities baked into the skill).
When to use this skill
Trigger on any of:
- "create a new Tauri app" / "scaffold a Tauri 2 project" / "new desktop app with Tauri"
- "Tauri + React project" / "Tauri + vanilla TS project" / "Rust backend desktop app"
- "use my Tauri patterns" / "the Tauri conventions I like" (in a Tauri context)
- "add a Tauri command end-to-end" / "wire up a command from Rust to React"
- "add a Rust module to my Tauri app" / "add a state slice"
- The user pastes a feature spec for a desktop feature (hotkey, tray, file I/O, system notification, OS-specific behavior) and asks you to wire it through the Rust backend and frontend
If unsure whether the user wants a brand-new app vs. an addition to an existing one, ask once — don't guess.
Three operating modes
Pick the mode from the user's request. If ambiguous, ask.
| Mode | Trigger | Output |
|---|---|---|
scaffold-app |
"new Tauri app", "scaffold Tauri 2 project", empty directory | Full Tauri 2 app: package.json, vite.config.ts, tsconfig.json, index.html, src/ (React or vanilla TS), src-tauri/ with modular commands/, state/, storage/, platform/, error/, settings/, capability JSON, Info.plist + entitlements.plist (macOS), build.rs, GitHub Actions CI, single-instance + updater plugins wired. |
add-command |
"add a <name> command end-to-end", "wire up a command from Rust to TS" |
New #[tauri::command] in the right src-tauri/src/commands/*.rs module, registered in lib.rs's invoke_handler, granted permissions in capabilities/default.json if it uses a plugin, plus a typed frontend hook in src/hooks/ that wraps invoke() with loading/error state. |
add-module |
"add a <name> module", "add a state/storage slice for X" |
New src-tauri/src/<name>/mod.rs (+ optional submodules), with serde-derived types, a thiserror error enum, JSON persistence via the shared storage module, and a unit-test module + an integration test file in src-tauri/tests/. |
Workflow
Step 1 — Resolve versions (don't hard-code)
The user does not want hard-coded Cargo / npm versions baked into the skill. Before writing Cargo.toml or package.json:
- Check the user's environment first: run
cargo --version,rustc --version,node --version,npm --version(orpnpm --version). - Resolve the latest stable versions of the core stack at scaffold time — never hand-paste:
- Rust crates (look up on crates.io or via context7):
tauri(must be 2.x),tauri-build(2.x),tauri-plugin-opener,tauri-plugin-notification,tauri-plugin-dialog,tauri-plugin-fs,tauri-plugin-global-shortcut,tauri-plugin-single-instance,tauri-plugin-updater,tauri-plugin-process,serde,serde_json,thiserror,anyhow,tokio(withsync,rt-multi-thread,macros),tracing,tracing-subscriber(withenv-filter),chrono(don't hand-roll date math),dirs. - npm packages:
@tauri-apps/api(2.x),@tauri-apps/cli(2.x),@tauri-apps/plugin-*matching the Rust plugins,react+react-dom(if React),typescript,vite,@vitejs/plugin-react(if React).
- Rust crates (look up on crates.io or via context7):
- Pin the Rust edition to
2021unless the user asks otherwise. - Quote the resolved versions back to the user before generating, so they can object.
- Default to npm unless
pnpmorbunis present and the user prefers it. - Concrete resolution commands when context7 is unavailable:
npm view @tauri-apps/api version,cargo search <crate> --limit 1, or WebFetchhttps://crates.io/api/v1/crates/<crate>(max_stable_version). Never write a version you did not just resolve this session — no versions from memory, no invented numbers. - Tauri 1.x is poison. Training data is full of v1 patterns that look plausible and fail on v2. Never emit:
import { invoke } from '@tauri-apps/api/tauri'(the v2 path is@tauri-apps/api/core), atauri.allowlistor top-leveltaurikey intauri.conf.json(v2 usesapp/bundle/plugins+capabilities/*.json), or v1 plugin names/APIs. If you are not certain a conf key, capability permission string, or plugin API exists in Tauri 2, verify it against the installed schema (node_modules/@tauri-apps/cli/config.schema.json; valid permission strings appear insrc-tauri/gen/schemas/after the first build) or the plugin's docs before writing code that depends on it. Capabilitypermissionsentries use the short plugin name (dialog:allow-open,fs:allow-read-file) — never atauri-plugin-prefix. Note thatcargo checkcompilestauri-build, which validatestauri.conf.jsonandcapabilities/*.json— a schema or unknown-permission error there is a JSON problem; fix the conf/capability file against the schema, don't edit Rust code to appease it.
Step 2 — Gather inputs (ask once, in one batch)
For scaffold-app, use AskUserQuestion to collect:
- App name (kebab-case for the directory; the productName in
tauri.conf.jsoncan be Title Case). - Bundle identifier in reverse-DNS form (e.g.
com.example.myapp) — must come from the user, never hardcoded. This goes intauri.conf.jsonidentifierand the macOSInfo.plistCFBundleIdentifier. - Frontend flavor: React + Tailwind, React (no Tailwind), vanilla TypeScript, or "I'll wire it up myself".
- Plugins to wire: opener, notification, dialog, fs, global-shortcut, single-instance, updater, process. Default-ON:
opener,dialog,fs,single-instance. Updater is OFF by default — only wire it if the user has (or will have) a manifest URL. - Tray icon (yes/no). If yes, generate a minimal tray with Show/Hide/Quit.
- Encrypted-secrets module (yes/no). If yes, generate an
encryption/module usingaes-gcm+argon2for at-rest encryption of API keys/tokens. The salt-derivation source (machine UUID, keychain, etc.) is platform-specific — generate stubs, but do not ship a hardcoded "pepper" string. - Updater endpoint (optional). If provided, write it into
tauri.conf.jsonunderplugins.updater.endpoints. Never invent a URL or paste a public key from another project — ask the user to generate one withtauri signer generateand paste it in afterwards. - CI (default ON): GitHub Actions matrix building on macOS/Windows/Ubuntu.
- First command/module (optional) — if provided, also run the matching mode after scaffold.
For add-command: command name (snake_case), the module it belongs in (commands/<module>.rs), inputs + return type, whether it's async, whether it needs AppHandle / State<T> / a Tauri plugin. If the command does blocking I/O, confirm it needs tokio::task::spawn_blocking.
For add-module: module name (snake_case), the data type(s) it owns, whether it persists to JSON in app_data_dir, what error variants it needs.
Step 3 — Generate files
Use the templates in references/templates/ as the source of truth. Apply these rules:
- Use
Writefor new files. Never useEditon files you're creating fresh. - Replace all
{{AppName}},{{app_name}},{{BundleId}},{{Command}},{{Module}}placeholders consistently.{{app_name}}is snake_case for Rust crate / lib names;{{AppName}}is Title Case for product name; the directory is kebab-case. - Create directories before files. On Windows shell use PowerShell
New-Item -ItemType Directory -Force. - Do not run
create-tauri-appto bootstrap — write the files directly from templates so the layout matches the conventions inreferences/folder-layout.md. Usenpm installafterpackage.jsonis written, thencargo fetchfromsrc-tauri/to seed the Cargo cache. - For icons, run
npx @tauri-apps/cli icon <path-to-source.png>once a source image exists, or generate a 1024×1024 placeholder withsharpif the user wants. Do not commit a placeholder square as the final icon — surface it as a TODO. - If a generator IS run (
create-tauri-appat the user's insistence,npx @tauri-apps/cli icon,tauri signer generate) and its output differs from this skill's layout: Tauri wins on file locations it mandates (src-tauri/shape,capabilities/,gen/, icon paths), this skill wins on everything Tauri doesn't mandate (thecommands//state//storage//platform/module split, frontend hooks layout). Reconcile deliberately and list every deviation in your report — never force the skill layout over a framework requirement, never silently abandon the skill's patterns.
Step 4 — Verify and report
- Run
npm install(or chosen PM). - Run
cd src-tauri && cargo build— must exit 0. (Don't runtauri devin CI — it opens a window.) If a fullcargo buildis prohibitively slow in this environment,cargo checkis the minimum acceptable bar — state which one you ran. - Run
cd src-tauri && cargo test— must exit 0 if any tests were generated. - Run
npm run build(frontend) — must exit 0.cargo check+npm run buildtogether are the floor; a fulltauri buildis optional (slow). - Optionally run
npm run tauri build -- --debugif the user wants a debug bundle; skip in CI by default since it's slow. - A scaffold that hasn't compiled is not delivered. Paste the actual proof lines in your report (cargo's
Finishedprofile line,test result: ok, Vite's✓ built in ...). Never report success from memory of the steps you intended, and never report partial success as success — if something is red, say exactly what is red. - CLI failure protocol (applies to
npm install,cargo build/check/test,npm run build): read the full error output — for cargo, fix the first error; later ones are usually cascade. Change exactly one thing, retry once. If the same step fails twice, stop scaffolding and surface the verbatim error to the user — do not keep generating files on a broken base, and do not re-run the identical command hoping for a different result. - Reply with a short summary: project path, versions chosen, plugins wired, bundle identifier, next steps (e.g. "fill in the macOS
Info.plistusage descriptions for any permissions you'll request", "generate updater keys withnpx tauri signer generate", "runnpm run tauri dev").
Project layout (canonical)
See references/folder-layout.md for the full tree, file-by-file purpose, and the rules that govern it.
Top-level shape:
{{app-name}}/
package.json
tsconfig.json # strict: true, noUnusedLocals, noUnusedParameters
vite.config.ts # port 5173, ignores src-tauri, base: './'
index.html # anti-flash theme script if dark/light theming is in scope
.gitignore # MUST include src-tauri/target/, dist/, .env, *.backup, *.orig, *.temp
.env.example # NEVER .env — only .env.example committed
src/ # frontend (TS / React)
main.tsx # entry: createRoot + render
App.tsx
tauriReady.ts # isTauriReady() guard
hooks/
useTauriCommand.ts # generic typed invoke() wrapper with loading/error
use<Domain>.ts # per-domain hooks composing useTauriCommand
components/
styles/
src-tauri/
Cargo.toml # [lib] name = "{{app_name}}_lib", target-specific deps via [target.'cfg(...)']
build.rs # links macOS frameworks, Windows libs, Linux pthread/m as needed
tauri.conf.json # productName, identifier, bundle, plugins.updater (if enabled)
Info.plist # macOS bundle identifier + usage descriptions for permissions requested
entitlements.plist # macOS hardened-runtime entitlements (audio-input, apple-events, etc.)
capabilities/
default.json # main-window capability with granular permission scopes
icons/ # icon.png (1024), icon.ico, icon.icns, 32x32.png, 128x128.png, 128x128@2x.png
src/
main.rs # #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + embed_plist on macOS
lib.rs # pub mod ...; pub fn run() builds Tauri, registers plugins + invoke_handler
commands/ # ONE file per domain (recording.rs, settings.rs, system.rs, ...)
mod.rs # pub mod ...; pub use *;
system.rs # generic commands (write_file, dialogs, notifications)
<domain>.rs # per-domain command modules
state/ # pub struct AppState { ... } managed via tauri::Manager
mod.rs
storage/ # JSON-on-disk utilities: get_app_data_dir, load_json, save_json
mod.rs
settings/ # Settings struct (camelCase serde) + load/save + migrations
mod.rs
defaults.rs
storage.rs
commands.rs
platform/ # trait-based platform abstractions
mod.rs
traits.rs # PermissionChecker, FileOpener, AutostartManager
macos.rs # #[cfg(target_os = "macos")]
windows.rs
linux.rs
wrappers.rs # PlatformXxx wrappers managed in Tauri State
error/ # into_string_err! macro + ResultExt trait
mod.rs
encryption/ # (optional) AES-GCM + Argon2id for secrets at rest
mod.rs
tray.rs # (optional) tray icon, menu, click handlers
tests/ # integration tests; one file per domain
common/
mod.rs
<domain>_test.rs
.github/
workflows/
unit-tests.yml # cross-platform matrix: cargo test on macos/windows/ubuntu
publish.yml # (optional) tag-triggered release pipeline; user fills in CDN secrets
Dependency rules (enforced):
commands/files call intostate/,settings/,storage/,platform/,<domain>/modules — never the other way.- Domain modules (
export,history,sync, …) may depend onstorage/andplatform/traits, but not oncommands/or Tauri's runtime types directly (useAppHandleonly when persisting viastorage::get_app_data_dir). platform/<os>.rsfiles are gated with#[cfg(target_os = "...")]and only these files containcfg!(target_os)checks. All other modules go throughplatform::traits::*and aPlatform<X>wrapper managed inState.- Frontend
hooks/use<Domain>.tsfiles composeuseTauriCommand— they never callinvoke()directly inline in a component.
Required code patterns
Full templates are in references/templates/. The full Keep/Eliminate rationale is in references/good-patterns.md and references/anti-patterns.md.
Keep
- Thin frontend, rich Rust backend. All file I/O, audio, system permissions, OS-specific behavior, and long-running work happen in Rust. The TS layer only invokes commands and renders state.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]inmain.rsto prevent a console window on Windows in release.embed_plist::embed_info_plist!("../Info.plist")inmain.rsunder#[cfg(target_os = "macos")]so the binary carries its plist when run standalone.tauri-plugin-single-instanceregistered first in the builder chain, with a callback that focuses the existing window when a second instance launches. Seereferences/templates/lib-rs.md.- Modular
commands/— one file per domain, all re-exported fromcommands/mod.rs, registered in a singletauri::generate_handler![...]call inlib.rs. State<AppState>for shared app state managed viaapp.manage(Mutex::new(AppState::new())). Inside async commands, hold the lock for the minimum time and drop it before any.await.- Trait-based platform abstractions (
PermissionChecker,FileOpener,AutostartManager) inplatform/traits.rs, with#[cfg(target_os = "...")]implementations inplatform/macos.rs,windows.rs,linux.rs, andSend + Syncwrappers inplatform/wrappers.rsmanaged in Tauri State. Tests assertSend + Syncon the wrappers. storage/mod.rsshared utilities —get_app_data_dir,get_storage_path,load_json<T>,save_json<T>,generate_id,Timestampedtrait,prune_entries_by_age— used bysettings/,history/,error_log/, and any other persistent JSON store.- Settings with
#[serde(rename_all = "camelCase", default)]so the on-disk JSON matches frontend naming, and missing fields fall back toDefault. Add a migration pass indeserialize_settings(json: &Value) -> Settingswhen fields change shape. thiserrorenums at module boundaries;anyhow::Resultonly inside private helpers. Commands returnResult<T, String>with errors stringified at the#[tauri::command]boundary — use theinto_string_err!macro /ResultExt::into_string()fromerror/mod.rsto keep this clean.tokio::task::spawn_blockingfor any synchronous I/O or CPU-bound work inside anasynccommand — file pickers, native dialogs, ML inference, encryption. Awaiting blocking work directly stalls Tauri's IPC runtime.tracing+tracing-subscriberfor logs, initialized inpub fn run()before the builder. UseEnvFilter::from_default_env().add_directive(Level::INFO.into())soRUST_LOG=debugworks.- Encrypted secrets at rest (
encryption/mod.rs): AES-256-GCM ciphertext + Argon2id key derivation + platform-specific hardware-bound salt (machine UUID on macOS viaioreg IOPlatformExpertDevice, Windows viawmic csproduct get uuid, Linux via/etc/machine-idormachine-uidcrate). StoreEncryptedApiKey { ciphertext, nonce, salt }in settings. Auto-migrate legacy plaintext keys on load. capabilities/default.jsonwith granular permissions — only the scopes the app actually uses (e.g.fs:allow-read-file,dialog:allow-open), not blanket"*". One capability file per window (e.g.default.jsonformain,<window>.jsonfor any auxiliary windows).- macOS
Info.plistusage descriptions for every permission the app requests (NSMicrophoneUsageDescription,NSAccessibilityUsageDescription, etc.). The app will silently fail without them. - macOS
entitlements.plistfor hardened runtime with only what's needed (com.apple.security.cs.allow-jit,com.apple.security.device.audio-input,com.apple.security.automation.apple-events— but only if used). build.rsthat links platform frameworks explicitly (Accelerate,Metal,CoreGraphics,Foundation,ApplicationServiceson macOS;user32on Windows;pthread,mon Linux). UseCARGO_CFG_TARGET_OSat build time, not host OS.- Target-specific Cargo deps via
[target.'cfg(target_os = "macos")'.dependencies]for platform crates (security-framework,cocoa,winapi,windows-sys,machine-uid). The default deps list stays portable. - Release profile in
Cargo.toml:lto = "fat",codegen-units = 1,opt-level = 3,strip = true,panic = "abort". Dev profile:opt-level = 1for tolerable rebuild times. - Vite config tuned for Tauri:
base: './'(relative paths so the bundled HTML loads fromtauri://localhost),server.port: 5173,server.strictPort: true,server.watch.ignored: ['**/src-tauri/**'],clearScreen: falseso Rust errors aren't hidden. - TypeScript
strict: truewithnoUnusedLocals,noUnusedParameters,noFallthroughCasesInSwitch, andisolatedModules. - Typed frontend
useTauriCommand<T>hook insrc/hooks/useTauriCommand.tsthat wrapsinvoke<T>(name, args)withdata,isLoading,error, andexecute. Per-domain hooks (useSettings,useRecording, …) compose it; components never callinvoke()inline. Seereferences/templates/use-tauri-command.md. isTauriReady()guard insrc/tauriReady.ts— frontend code that runs before the Tauri IPC bridge is ready (e.g. early auto-load) must call it.- GitHub Actions cross-platform matrix that runs
cargo teston macOS, Windows, and Ubuntu. Ubuntu needslibgtk-3-dev libayatana-appindicator3-dev pkg-config. tauri.conf.jsonwindows[].devtools: false(or omitted — Tauri defaults to off in release). DevTools should be opened from a build-time feature flag, not the production config.- Reset-state-on-startup pattern — in
pub fn run()'s.setup(|app| { ... }), clear any in-progress / stuck state from a previous run before showing the window. Crashes leave globals in odd places; treat each startup as recovering from "the app was force-quit". - Show window FIRST in
.setup()before any blocking initialization. Heavy work (ML model loading, large config parses) goes on a background thread that emits events back to the frontend. - Minimal comments in generated code (Rust and TS). Default to no comments. Only add one when the why is non-obvious —
// SAFETY:on anunsafeblock, a workaround for a specific upstream bug (with a link), a non-trivial invariant the code depends on, a platform quirk that isn't visible from the names. Never write Rust doc-comment blocks (///) on internal items; reserve them for genuinely public API surface (pubtypes crossing crate boundaries). Never restate what the next line does, never leave// TODOwithout an issue link. One short line max — no multi-line comment blocks.// SAFETY:onunsafeis the one place verbose justification is required; everywhere else, well-named identifiers carry the what and comments earn their place only when they carry why.
Eliminate (anti-patterns)
Every one of these is forbidden in generated code. Rationale for each is in references/anti-patterns.md.
- ❌ Committed
*.backup,*.orig,*.tempfiles insrc-tauri/src/(e.g.lib.rs.backup,macos.rs.orig). Add them to.gitignoreand never commit. These are merge-conflict / WIP artefacts, not source of truth. - ❌ Plaintext API keys / OAuth tokens in
settings.json. If the app stores any secret, route it throughencryption/and persist onlyEncryptedApiKey { ciphertext, nonce, salt }. Auto-migrate any plaintext encountered on load. - ❌ Tokens in browser
localStorage/sessionStorage. Anything sensitive lives in Rust-side encrypted storage; the frontend asks for it only when needed. - ❌
devtools: truein the releasetauri.conf.json. Gate dev tools behind a build-time feature or a separatetauri.dev.conf.json. - ❌
cfg!(target_os = "...")checks scattered through command bodies. Platform-specific behavior goes through aplatform::traits::*impl +cfg(target_os = "...")modules; commands stay portable. - ❌ Hand-rolled date / leap-year / ISO-8601 parsing. Use
chrono::Utc::now(),DateTime::parse_from_rfc3339,Duration::days(n). The "hand-rolled time math" pattern is an anti-pattern even when it works. - ❌ Raw
std::fs::read/std::fs::writeagainst user-chosen paths from inside a#[tauri::command]. Either go throughtauri-plugin-fs(which respects capability scopes) or restrict raw I/O to paths derived fromapp_handle.path().app_data_dir(). - ❌ Blocking I/O inside
async fncommands withouttokio::task::spawn_blocking. The Tauri IPC runtime is shared — a blocked task stalls all command throughput. - ❌
#[tauri::command] fn long_running(...) -> Result<...>that holds astd::sync::Mutexlock across an.await. Usetokio::sync::Mutex, or drop the guard before awaiting. - ❌ Missing
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]at the top ofmain.rs. Without it, a console window flashes behind the Tauri window on Windows in release. - ❌ App built without
tauri-plugin-single-instance. Double-clicking the app launches a second process, both fighting over global hotkeys, audio devices, sockets, and lock files. - ❌ Builder calls in
lib.rsthat bootinit_*before showing the window — model loading, audio device enumeration, network calls — block the user from seeing UI for seconds. - ❌ Empty
catch {}/let _ = result;swallowing errors silently. Either log viatracing::warn!/error!or propagate via?. - ❌ Mutex-locking the AppState for the duration of a command. Read or clone what you need, drop the guard, then do the work.
- ❌ Hardcoded bundle identifiers, R2 / Cloudflare / S3 URLs, updater public keys, Apple Team IDs, Windows certificate thumbprints in any generated file. These are user-specific; ask, don't assume.
- ❌ Inventing a Tauri updater pubkey. The user generates one with
npx tauri signer generate -w ~/.tauri/myapp.keyand pastes the public key intotauri.conf.json. Never paste another project's pubkey. - ❌
Stringerrors at module boundaries (fn foo() -> Result<T, String>). Internal APIs usethiserrorenums; only#[tauri::command]returns convert toResult<T, String>at the IPC boundary. - ❌ Multiple
createRoot(...)calls, or rendering React before the DOM root is wired. One entry point,main.tsx, renders into#appafterindex.htmlis parsed. - ❌ Frontend invoking
@tauri-apps/api/coreinvoke()inline in components instead of through a typed hook. Loses TS safety, scatters error handling, makes refactors painful. - ❌ Capability files with blanket permissions (
"core:default"only, no granular scopes; or"fs:default"withoutallow-read-file/allow-write-fileactually being used). Capabilities should describe exactly what the app needs and nothing more. - ❌
tauri.conf.jsonapp.security.csp: nullleft in production without an explicit decision. If CSP is genuinely too restrictive for the app, add a CSP that whiteliststauri://localhost+ your trusted origins, and document the choice. - ❌ Bundling external binaries (FFmpeg, ML models) without listing them in
tauri.conf.jsonbundle.externalBin/bundle.resources. They'll work incargo runbut be missing in the installer. - ❌ macOS
Info.plistmissingNSMicrophoneUsageDescription/NSAccessibilityUsageDescription/NSCameraUsageDescriptionfor permissions the app actually requests. The OS will refuse the prompt and the call returns "denied" silently. - ❌ macOS
entitlements.plistrequesting entitlements the app doesn't use (com.apple.security.cs.allow-unsigned-executable-memory,com.apple.security.cs.allow-jit) — they break notarization or weaken the sandbox. Only enable what's required. - ❌ Two binaries in
Cargo.toml([[bin]]× 2) without a clear sidecar reason. If the app has one main binary, keep one[[bin]]. Sidecars get their own crate inside a Cargo workspace, not a second[[bin]]in the same crate. - ❌
.envcommitted to git. Only.env.exampleis tracked;.envis in.gitignore.
Operating-mode playbooks
Mode 1 — scaffold-app
- Resolve versions per Step 1. Quote them.
- Ask the inputs per Step 2. Wait for answers.
- Generate, in this order:
-
Root:
package.json,tsconfig.json,tsconfig.node.json,vite.config.ts,index.html,.gitignore,.env.example,README.md,.github/workflows/unit-tests.yml. -
Frontend:
src/main.tsx(ormain.ts),src/App.tsx,src/tauriReady.ts,src/hooks/useTauriCommand.ts,src/styles.css. Tailwind config +postcss.config.jsonly if the user chose Tailwind. -
src-tauri/Cargo.toml(with target-specific deps + release profile),src-tauri/build.rs,src-tauri/tauri.conf.json,src-tauri/Info.plist,src-tauri/entitlements.plist,src-tauri/capabilities/default.json. -
src-tauri/icons/— placeholder 1024×1024 PNG only if the user hasn't supplied one. Surface as TODO; tell them to runnpx @tauri-apps/cli icon icons/icon.png. -
src-tauri/src/main.rs,src-tauri/src/lib.rs,src-tauri/src/error/mod.rs,src-tauri/src/state/mod.rs,src-tauri/src/storage/mod.rs,src-tauri/src/commands/mod.rs+src-tauri/src/commands/system.rs,src-tauri/src/platform/{mod,traits,macos,windows,linux,wrappers}.rs,src-tauri/src/settings/{mod,defaults,storage,commands}.rs.Checkpoint: run
npm installthen a firstcd src-tauri && cargo checkhere, before optional modules — errors localize to the core tree, and this check runstauri-build, which validatestauri.conf.json+capabilities/*.jsonand generatessrc-tauri/gen/schemas/(the authority for valid permission strings). Fix any conf/capability error now, per the Step-4 failure protocol, before writing more capability entries. -
Optional:
src-tauri/src/encryption/mod.rs,src-tauri/src/tray.rs,src-tauri/src/commands/<more>.rsbased on user answers. -
src-tauri/tests/common/mod.rs+ at least one integration test file (e.g.system_test.rs) so CI has something real to run.
-
npm install.cd src-tauri && cargo build && cargo test— must succeed.npm run build— must succeed (catches TS / Vite errors).- If a first command/module was requested, run Mode 2 or Mode 3 for it.
- Report.
Mode 2 — add-command
For command {{command}} (snake_case) in module commands/{{domain}}.rs:
- Rust:
- Add (or extend)
src-tauri/src/commands/{{domain}}.rswith a#[tauri::command]async fn. - If the function does blocking work, wrap it in
tokio::task::spawn_blocking(move || { ... }).await.map_err(|e| e.to_string())?. - If it needs
AppHandle, take it as a parameter (Tauri injects). If it needs shared state, takeState<'_, Mutex<AppState>>. - Errors: return
Result<T, String>. Internally usethiserror+ theinto_string_err!macro fromerror/mod.rsto convert at the boundary. - Re-export from
commands/mod.rs(pub use {{domain}}::*;). - Register in
lib.rsinsidetauri::generate_handler![..., {{command}}].
- Add (or extend)
- Capabilities: if the command uses a plugin (notification, fs, dialog, global-shortcut), add the specific permission scope to
capabilities/default.json(e.g."dialog:allow-open"). Do not widen todefaultif a narrower scope works. - Frontend:
- Add a typed wrapper in
src/hooks/use{{Domain}}.tsthat composesuseTauriCommand<ReturnType>({ command: "{{command}}" }). - Export the hook. Do not call
invoke()inline in a component.
- Add a typed wrapper in
- Test: add an integration test in
src-tauri/tests/{{domain}}_test.rsthat exercises the command's underlying function (the#[tauri::command]itself is hard to call without a full Tauri context — test the inner function).
After generating: cd src-tauri && cargo build && cargo test then npm run build from the project root. Apply the Step-4 proof-and-failure protocol — paste the green lines; the same step failing twice = stop and surface the verbatim error.
Mode 3 — add-module
For module {{module}} (snake_case):
- Create
src-tauri/src/{{module}}/mod.rs(split into submodules if multiple concerns). - Define data types with
#[derive(Debug, Clone, Serialize, Deserialize)]+#[serde(rename_all = "camelCase")]so JSON matches the frontend. - Define a
thiserrorerror enum:#[derive(Debug, thiserror::Error)] pub enum {{Module}}Error { ... }. - If the module persists JSON to disk, use the shared
storagemodule —get_app_data_dir,load_json,save_json. Don't roll your own file I/O. - Add
pub mod {{module}};tolib.rs. Re-export the public types you want callable from commands (pub use {{module}}::{Type, OtherType};). - Add a
#[cfg(test)]mod tests { ... }block at the bottom ofmod.rscovering pure logic (no Tauri context required). - Add a
src-tauri/tests/{{module}}_test.rsintegration test if the module has cross-cutting behavior.
After generating: cd src-tauri && cargo build && cargo test — Step-4 proof-and-failure protocol applies.
Cargo + npm packages (resolve latest stable at scaffold time)
Look these up at scaffold time — do not hand-paste versions:
Cargo (always)
tauri(2.x),tauri-build(2.x)serde,serde_json,thiserror,anyhowtokio(features:sync,rt-multi-thread,macros)tracing,tracing-subscriber(env-filter)chronodirs
Cargo (conditional, only if used)
tauri-plugin-opener,tauri-plugin-notification,tauri-plugin-dialog,tauri-plugin-fstauri-plugin-global-shortcut,tauri-plugin-single-instance,tauri-plugin-updater,tauri-plugin-processaes-gcm,argon2,base64,rand(encryption module)reqwest(withrustls-tls,json) — only if the app makes HTTP callstokio-tungstenite(withrustls-tls-native-roots) — only if WebSocket neededregex(withdefault-features = false,features = ["std", "perf"])embed_plist(macOS only, gated inmain.rs)
Cargo (target-specific)
- macOS:
security-framework,objc,cocoa,core-graphics,block - Windows:
winapi,windows-sys(with the specific feature flags the code uses, not blanket) - Linux:
machine-uid(only if the encryption module needs a machine ID)
Dev-dependencies
mockall,mockito(only if the code is structured for mocking)tokio-test,tempfile,assert_matches,pretty_assertionsproptest(only if a domain has property-test-shaped invariants)serde_yaml(only if tests parse CI YAML)
npm
@tauri-apps/api(2.x),@tauri-apps/cli(2.x)@tauri-apps/plugin-*matching the wired Rust plugins- React stack only if chosen:
react,react-dom,@types/react,@types/react-dom,@vitejs/plugin-react vite,typescript- Tailwind only if chosen:
tailwindcss,postcss,autoprefixer,@fontsource/*for any fonts used rimraf(clean scripts)sharponly if the user wants icon generation locally
Verification checklist before reporting "done"
Run this as a mechanical pass over the generated tree — grep, don't recall. The Eliminate list is easy to hold at file 1 and forgotten by file 30. Start with this grep block; every command must return nothing:
grep -rn "cfg!(target_os" src-tauri/src --include='*.rs' | grep -v "src/platform/" # platform checks outside platform/
grep -rn "@tauri-apps/api/tauri" src/ # v1 import path (v2 is /core)
grep -n "allowlist\|systemTray" src-tauri/tauri.conf.json # v1 config schema leaking in (v2: capabilities/, app.trayIcon)
grep -rn "tauri::api::" src-tauri/src --include='*.rs' # v1 Rust API paths (v2 moved these to plugins)
grep -rnE "pub (api_key|token|secret)\w*: String" src-tauri/src/ # plaintext secret fields (must be EncryptedApiKey)
grep -rn "localStorage.setItem\|sessionStorage.setItem" src/ # tokens/secrets in web storage
grep -n "\"devtools\": true" src-tauri/tauri.conf.json # devtools enabled in release config
grep -rn "std::fs::" src-tauri/src/commands/ # raw fs in command bodies
find src-tauri/src -name "*.backup" -o -name "*.orig" -o -name "*.temp" # WIP artefacts
A hit means fix it and re-run the grep — never rationalize it away. Then the full checklist:
-
npm installsucceeds. -
cd src-tauri && cargo buildexits 0. -
cd src-tauri && cargo testexits 0 (if tests were generated). -
npm run buildsucceeds (frontend bundles). -
npm run tauri build -- --debugsucceeds if the user wants a smoke check on the bundler (optional, slow). -
main.rscontains#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]. -
lib.rsregisterstauri_plugin_single_instance::initfirst in the builder chain when single-instance is wired. -
tauri.conf.jsonidentifieris not empty and matches what the user provided — not a genericcom.example.*. -
tauri.conf.jsonplugins.updater.pubkeyis empty / removed unless the user pasted a real one. Never invent a pubkey. -
tauri.conf.jsonapp.windows[].devtoolsisfalse(or omitted). -
capabilities/default.jsonhas granular permission scopes — no blanket allows beyondcore:default. -
Info.plisthas a usage description for every permission the generated code requests. -
.gitignoreincludessrc-tauri/target/,dist/,node_modules/,.env,*.backup,*.orig,*.temp. - No
.backup/.orig/.tempfiles exist anywhere undersrc-tauri/src/. - No
cfg!(target_os = "...")appears insrc-tauri/src/commands/**orsrc-tauri/src/<domain>/**— only insrc-tauri/src/platform/**. - No
localStorage.setItem("token"/sessionStorage.setItem("token"insrc/. - No raw
std::fs::write(&user_supplied_path, ...)in command bodies (must go through plugin orapp_data_dir). - All
#[tauri::command]async fns that do blocking work usetokio::task::spawn_blocking. - No
cfg_if!or#[cfg]guard aroundembed_plist::embed_info_plist!is missing on macOS. -
Cargo.toml[profile.release]haslto,codegen-units,opt-level,strip,panicset. - GitHub Actions matrix workflow has macOS, Windows, and Ubuntu, and Ubuntu installs the GTK/AppIndicator system deps.
If any check fails, fix before reporting. Don't claim success with a known-broken scaffold.
Examples
Example 1: Fresh project
User: "Scaffold a new Tauri 2 desktop app called acme-notes. React frontend with Tailwind. Bundle identifier com.acme.notes. Wire single-instance, dialog, fs, opener. Skip updater for now."
Claude:
- Runs
cargo --version,rustc --version,node --version. Resolves latest stable Tauri 2.x, React, Vite, Tailwind versions via context7 / crates.io. - Reports chosen versions; waits for confirmation if anything looks off.
- Asks the Step-2 questions for any inputs not already provided (tray? encryption? first command?).
- Generates the full project skeleton per Mode 1.
- Runs
npm install,cargo build,cargo test,npm run build— all must pass. - Reports the tree, the bundle identifier set, and next steps ("create your icon source and run
npx @tauri-apps/cli icon icons/icon.png", "runnpm run tauri dev").
Example 2: Add a command
User: "Add a get_disk_usage command to my Tauri app that returns the size of a directory."
Claude: Runs Mode 2. Adds #[tauri::command] pub async fn get_disk_usage(path: String) -> Result<u64, String> to commands/system.rs (or a new commands/disk.rs if it warrants its own module), wraps the recursive walk in tokio::task::spawn_blocking, registers it in lib.rs, adds a typed hook useDiskUsage to src/hooks/, and writes a unit test for the inner function. Builds and tests.
Example 3: Add a module
User: "Add a notes module that stores user notes as JSON in the app data dir, with create/list/delete operations."
Claude: Runs Mode 3. Creates src-tauri/src/notes/mod.rs with a Note { id, title, body, created_at, updated_at } struct (using chrono::DateTime<Utc>, not hand-rolled timestamps), a NotesError thiserror enum, and create_note, list_notes, delete_note functions backed by storage::load_json / save_json (file: notes.json in app_data_dir). Adds pub mod notes; to lib.rs. Generates a #[cfg(test)] mod tests block covering create/list/delete on a tempfile::TempDir. Builds and tests.
Notes
- Don't over-engineer. Don't add Redux/Zustand/MobX on the frontend, Diesel/SQLx on the backend, gRPC, OpenTelemetry, Sentry, or a state-machine library unless the user asks. The default scaffold is intentionally lean.
- Don't rewrite the user's existing project as part of this skill. This skill is for new scaffolds (and additive command/module modes), not migrations. Migrations are a different conversation.
- Tauri version: the skill targets Tauri 2.x. If the user's environment somehow pins 1.x (legacy
tauri.conf.jsonshape,@tauri-apps/api@1), stop and ask whether they want to upgrade — don't silently downgrade the patterns. - Bundle identifier: always reverse-DNS, always provided by the user, never hardcoded. It cannot change after the app is in distribution (it's the OS-level identity for permissions, keychain entries, settings, and update channels).
- Updater: only wire it if the user has, or commits to setting up, a JSON manifest at a known URL and has generated a signing key. Half-wired updaters silently fail in production.
- Icons: a single high-res source PNG (1024×1024+) generates every platform-specific format via
npx @tauri-apps/cli icon <path>. Don't ship the auto-generated placeholder as the real icon. - Naming: kebab-case for the project directory, Title Case for the
productName, snake_case for the Rust crate ({{app_name}}inCargo.toml), reverse-DNS for the bundle identifier. Mismatches in casing are a top source of "works locally, broken installer" bugs. - Versions: always quote the resolved versions before writing
Cargo.toml/package.json. They are resolved at scaffold time, never hard-coded.
