Fix: Tauri 2 Not Working — Capabilities, Plugin Permissions, Mobile Build, and v1 Migration
Part of: Go, Rust & Systems Errors
Quick Answer
How to fix Tauri 2 errors — invoke command not allowed by capabilities, plugin permission missing, tauri.conf.json schema, mobile init/build failures, updater migration, and v1 allowlist conversion.
The Error
You upgrade to Tauri 2 and a working invoke call from the frontend fails:
import { invoke } from "@tauri-apps/api/core";
await invoke("greet", { name: "Alice" });
// Error: Command greet not allowed by ACLOr you add the dialog plugin and it refuses to open:
import { open } from "@tauri-apps/plugin-dialog";
const path = await open();
// Error: dialog.open not allowed. Required: dialog:defaultOr cargo tauri android init fails on a fresh project:
Error: NDK not found. Set ANDROID_NDK_HOME or install via Android Studio.Or your v1 tauri.conf.json doesn’t validate:
Error parsing tauri.conf.json:
unknown field `allowlist`, expected one of `productName`, `version`, `identifier`, ...Why This Happens
Tauri 2 introduced a permissions-and-capabilities model that replaces v1’s allowlist. Every command (built-in or custom) and every plugin call must be explicitly permitted, scoped to specific windows/webviews. Four sources of friction:
- Capabilities are required. v1’s “expose everything by default in dev, lock down in prod” pattern is gone. Tauri 2 requires you to grant explicit permissions in capability files. Missing permissions → command blocked, even in dev.
- Plugin permissions are separate from app permissions. Every plugin (
dialog,fs,shell,updater,notification) ships its own permission set. Adding a plugin toCargo.tomldoesn’t grant permission to call it — you also add it to a capability. - Mobile is a separate build target.
cargo tauri buildproduces desktop binaries; mobile needscargo tauri android/cargo tauri ios. Toolchain setup (NDK, Xcode) trips up most first-time mobile builds. - Config schema is different.
tauri.conf.jsonwas completely restructured.allowlistis gone;bundle,app,buildare flatter. Old configs don’t parse.
The deeper cause is a deliberate inversion of trust. Tauri 1 treated the webview as a privileged process and used allowlist as a flat on/off list for entire API families — once fs was enabled, every window could read any path the OS allowed. Tauri 2 treats the webview as untrusted by default and forces every IPC surface to declare which window can call which command with which scope. That model is more secure, but it also means even your own bespoke commands need an explicit grant, which is the single most common upgrade surprise.
The other recurring driver is the move to a plugin-first architecture. In v1, things like dialog, notification, and shell were core. In v2, they ship as separate Cargo crates and JavaScript packages with their own permission identifiers (dialog:default, shell:allow-open). The split lets you ship a smaller binary by excluding plugins you do not use, but it also means three files — Cargo.toml, lib.rs, and a capability JSON — must agree before a plugin call actually works. Missing any one of them produces a confusing “not allowed by ACL” error instead of an honest “plugin not registered” message.
Fix 1: Create a Capability File
Capabilities live in src-tauri/capabilities/. Create default.json:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability granted to the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:webview:default",
"core:event:default",
"dialog:default",
"fs:default",
"shell:default"
]
}Reference it in tauri.conf.json:
{
"app": {
"security": {
"csp": null
},
"windows": [{ "label": "main", "title": "My App" }]
}
}The capabilities/ directory is auto-loaded — you don’t list capability files in tauri.conf.json. They apply by matching the windows field.
To grant a permission to all windows:
{
"identifier": "all",
"windows": ["*"],
"permissions": [...]
}Pro Tip: Split capabilities by feature. default.json for the main window, admin.json scoped to an admin window with broader permissions. Reduces the risk of widening one window’s surface accidentally.
Fix 2: Add Plugin Permissions
When you add a plugin to Cargo.toml:
[dependencies]
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"And register it in src-tauri/src/lib.rs:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}You also need to grant the plugin’s permissions in a capability. The plugin docs list available permission identifiers:
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"shell:default",
"shell:allow-open"
]
}For finer-grained scopes (e.g. only read files under a specific directory):
{
"identifier": "fs:scope-app",
"allow": [{ "path": "$APPDATA/*" }],
"deny": [{ "path": "$APPDATA/secrets/*" }]
}The $APPDATA, $DOCUMENT, $HOME placeholders resolve to platform-appropriate paths at runtime.
Common Mistake: Adding tauri-plugin-fs to Cargo.toml but forgetting permissions: ["fs:default"] in the capability. The plugin loads, the API is callable, but every call returns “not allowed by ACL.”
Fix 3: Custom Commands Need Permissions Too
A #[tauri::command] function isn’t automatically callable from the frontend — you must register it as a permission in your app’s manifest:
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}For the command to be callable from the frontend, add it to the capability’s permissions. The identifier is <plugin-or-app>:allow-<command-name>. For app-level commands, the convention is to reference them via the app’s permission set generated under src-tauri/permissions/:
// src-tauri/permissions/default.toml
"$schema" = "schemas/schema.json"
[default]
description = "Allow app commands"
permissions = ["allow-greet"]Then in the capability:
"permissions": [
"core:default",
"<your-app-identifier>:allow-greet"
]The exact identifier depends on your app’s bundle identifier. Run cargo tauri build once to generate the schemas and inspect src-tauri/gen/schemas/.
Pro Tip: Start with broad core:default while developing, then narrow as you go. Don’t try to write a tight ACL on day one — you’ll fight the system instead of building features.
Fix 4: Migrate tauri.conf.json Schema
v1 → v2 config structure changed significantly:
// v1 (does NOT work in v2):
{
"tauri": {
"allowlist": {
"all": false,
"dialog": { "open": true }
},
"windows": [{ "title": "..." }],
"bundle": { ... }
}
}
// v2:
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"productName": "My App",
"version": "0.1.0",
"identifier": "com.example.myapp",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"app": {
"windows": [{ "title": "My App", "width": 1200, "height": 800 }],
"security": { "csp": null }
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.icns", "icons/icon.ico"]
}
}Key changes:
- Top-level
tauri.*is gone. Sections moved to top-level:app,build,bundle. allowlistremoved — replaced bycapabilities/*.jsonfiles (see Fix 1).devPath→devUrl;distDir→frontendDist.identifier(bundle ID likecom.example.myapp) is now required at the top level.
The official migrate tool handles most of this. Install the Tauri 2 CLI, then run migrate from your project root:
# Via Cargo
cargo install tauri-cli --version "^2.0.0" --locked
# Or via npm (recommended for JS-heavy projects)
npm install -D @tauri-apps/cli@latest
cargo tauri migrate
# or: npx @tauri-apps/cli migrateRun on a clean git branch, review the diff, fix anything migrate couldn’t infer.
Fix 5: Mobile Setup (Android)
cargo tauri android init requires the Android SDK and NDK:
# Set environment variables (add to ~/.zshrc or ~/.bashrc):
export ANDROID_HOME=$HOME/Library/Android/sdk # macOS
export ANDROID_HOME=$HOME/Android/Sdk # Linux
export ANDROID_HOME="$env:LOCALAPPDATA/Android/Sdk" # Windows PowerShell
export NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125 # Use installed NDK version
# Install Rust targets:
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
# Init:
cargo tauri android init
cargo tauri android devInstall Android Studio first — it manages SDK/NDK versions through SDK Manager. The exact NDK version path changes with each release.
For iOS (macOS only):
xcode-select --install
rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim
cargo tauri ios init
cargo tauri ios devCommon Mistake: Trying mobile builds on Windows for iOS. iOS builds require Xcode and macOS. Use a Mac mini, GitHub Actions macOS runners, or a cloud-based macOS service.
Fix 6: Updater Plugin Migration
The updater is a plugin in v2:
# Cargo.toml
[dependencies]
tauri-plugin-updater = "2"// lib.rs
use tauri_plugin_updater::UpdaterExt;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Ok(Some(update)) = handle.updater().unwrap().check().await {
let _ = update.download_and_install(|_, _| {}, || {}).await;
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}// tauri.conf.json
{
"plugins": {
"updater": {
"endpoints": ["https://releases.example.com/{{target}}/{{arch}}/{{current_version}}"],
"pubkey": "..."
}
}
}Permissions in the capability:
"permissions": [
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install"
]The signature key flow is unchanged — generate with tauri signer generate and sign releases with the private key.
Fix 7: CSP and Webview Security
Tauri 2 enforces stricter CSP. The default is null (no enforcement) for dev convenience, but production builds should set one:
{
"app": {
"security": {
"csp": "default-src 'self' tauri:; script-src 'self' tauri:; style-src 'self' 'unsafe-inline'"
}
}
}tauri: is the scheme for Tauri’s IPC bridge. Without tauri: in script-src/default-src, the frontend can’t call commands.
For frontends that need unsafe-inline styles (most frameworks), include 'unsafe-inline' in style-src. For Vite dev mode, also add ws:// for HMR:
"csp": "default-src 'self' tauri: ws://localhost:1420; script-src 'self' tauri:; style-src 'self' 'unsafe-inline'"Fix 8: IPC and Type-Safe Bindings
In v2, invoke lives in @tauri-apps/api/core:
import { invoke } from "@tauri-apps/api/core";
const result = await invoke<string>("greet", { name: "Alice" });For type safety across many commands, use tauri-specta to generate TypeScript bindings from your Rust commands:
[dependencies]
specta = "2.0.0-rc"
tauri-specta = { version = "2.0.0-rc", features = ["typescript"] }use specta::Type;
use tauri_specta::{collect_commands, Builder};
#[derive(Type, serde::Serialize)]
struct Greeting { message: String }
#[tauri::command]
#[specta::specta]
fn greet(name: String) -> Greeting {
Greeting { message: format!("Hello, {}!", name) }
}
pub fn run() {
let builder = Builder::<tauri::Wry>::new()
.commands(collect_commands![greet]);
#[cfg(debug_assertions)]
builder.export(specta_typescript::Typescript::default(), "../src/bindings.ts").unwrap();
tauri::Builder::default()
.invoke_handler(builder.invoke_handler())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}src/bindings.ts is regenerated on each debug build. Import typed commands:
import { commands } from "./bindings";
const result = await commands.greet("Alice"); // Fully typedVersion History: What Changed Between Tauri 1 and 2
Knowing which release introduced which concept saves hours when you are reading old blog posts or copying snippets from Stack Overflow.
- Tauri 1.0 (Jun 2022). First stable release. Flat
allowlistmodel, no plugin permission system, desktop-only (Linux, macOS, Windows). The IPC layer used__TAURI_INVOKE__directly andinvokelived in@tauri-apps/api/tauri. - Tauri 1.5 (Sep 2023). Final 1.x line. Added the
tauri-plugin-*ecosystem in preview form, but plugins still ran inside the legacy allowlist gate. Useful as a reference point because most third-party guides target this version. - Tauri 2.0 beta (Apr 2024). First public preview of the capabilities/permissions model. Mobile targets (Android, iOS) entered beta.
cargo tauri migrateshipped in the CLI to rewritetauri.conf.jsonand turnallowlistentries into capability files. Expect breakage if you mix 2.0 beta plugins with later RC plugins — pin everything. - Tauri 2.0 stable (Oct 2024). GA release. Stabilized the capabilities schema, the new
@tauri-apps/api/coremodule path forinvoke, the IPC v2 protocol (no more__TAURI__global on the window), and runtime-configurable windows. Mobile builds reached production-ready status on both platforms. - Tauri 2.1 (late 2024 / early 2025). Quality-of-life: better permission error messages that name the missing identifier, support for capability remote conditions (window labels matched by glob), and the
tauri.conf.jsonapp.security.assetProtocolsetting for serving local files withoutconvertFileSrccalls. - Plugin v2 line. Every official plugin was rewritten for v2. The naming convention is
tauri-plugin-<name>(Rust) and@tauri-apps/plugin-<name>(JS). Versions track the Tauri core:tauri-plugin-dialog = "2"is the v2 line;tauri-plugin-dialog = "0.x"is the v1-era preview and will not load on Tauri 2.
If you see code that imports from @tauri-apps/api/tauri or references window.__TAURI__.invoke, that is Tauri 1 code and will not run on v2 without changes. The path moved to @tauri-apps/api/core in the 2.0 release, and the global was removed entirely. Likewise, any tauri.conf.json snippet with a top-level "tauri": { ... } key is pre-2.0 and needs to be flattened.
The capabilities directory itself also evolved during the beta. Early 2.0 beta builds used a single tauri.conf.json permissions array; this was replaced by per-file capabilities under src-tauri/capabilities/ before stable. If a tutorial puts permissions directly in tauri.conf.json, it predates GA — use the directory layout from Fix 1 instead.
Still Not Working?
A few less-obvious failures:
tauri devruns but window is blank. Frontend dev server didn’t start, ordevUrldoesn’t match. CheckbeforeDevCommandruns your dev server first.- CSP blocks IPC. Add
'self' tauri:todefault-src/script-src. Without it, the frontend can’t invoke any command. fs:allow-read-fileworks but reads fail. You also need a scope. Add afs:scope-apppermission that lists allowed paths.- Bundle is huge. Build with
--releaseand strip debug symbols: addstrip = trueto[profile.release]inCargo.toml. - Android:
gradle build failed. Open the project in Android Studio once to let it fix Gradle plugin versions. Then go back tocargo tauri android build. - macOS: notarization fails after
cargo tauri build. SetsigningIdentityand configurebundle.macOS.providerShortName. Without notarization, users see “App is damaged” errors. - Cross-compiling from Linux to Windows: missing system libs. Use the official Tauri GitHub Actions workflow templates. Cross-compile is fragile; use OS-native runners.
- Frontend works in browser but blank in Tauri. Hard-coded
localhost:5173URLs in your code don’t work — Tauri serves fromtauri://localhost. Use relative paths. invokereturnsundefinedeven though the command logs in Rust. The command panicked silently and Tauri swallowed the error. Wrap return values inResult<T, String>and inspecterron the frontend instead of assuming success.@tauri-apps/api/tauriimport resolves but throws at runtime. That path is a leftover from v1. Switch every import to@tauri-apps/api/core(forinvoke) or@tauri-apps/api/event(forlisten/emit). The legacy module still exists as a compatibility stub that fails on first call.- Mobile dev server cannot reach your laptop.
cargo tauri android devruns the app on a device while loading assets from your machine. If the phone is on a different Wi-Fi network or your firewall blocks port 1420, the webview shows a blank screen. Bind to0.0.0.0and open the dev port, or useadb reverse tcp:1420 tcp:1420for USB-tethered debugging.
For related desktop and cross-platform issues, see Tauri not working, Electron not working, Rust trait not implemented, and Vite failed to resolve import.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Tauri Not Working — Command Not Found, IPC Error, File System Permission Denied, or Build Fails
How to fix Tauri app issues — Rust command registration, invoke IPC, tauri.conf.json permissions, fs scope, window management, and common build errors on Windows/macOS/Linux.
Fix: Electron Forge Not Working — Makers, Code Signing, Native Modules, and Publishers
How to fix Electron Forge errors — forge.config.js makers per OS, code signing on macOS (notarytool) and Windows, native module rebuild via electron-rebuild, Vite/Webpack plugin, auto-updater, and GitHub publisher.
Fix: Node.js fs.watch Not Working — Cross-Platform Quirks, chokidar Migration, Recursive Watch
How to fix Node.js file watching — fs.watch unreliable on Linux, missing events on save, recursive watch on Windows/macOS, chokidar polling fallback, ignoring patterns, debouncing, and EMFILE errors.
Fix: Maturin Not Working — develop Errors, ABI3 Wheels, manylinux, and macOS Universal Builds
How to fix Maturin errors — maturin develop fails outside venv, abi3 forward compatibility, manylinux wheel auditing, macOS universal2 cross-compile, pyproject.toml vs Cargo.toml conflicts, and PyO3 feature flags.