Skip to content

Fix: Tauri 2 Not Working — Capabilities, Plugin Permissions, Mobile Build, and v1 Migration

FixDevs · (Updated: )

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 ACL

Or 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:default

Or 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 to Cargo.toml doesn’t grant permission to call it — you also add it to a capability.
  • Mobile is a separate build target. cargo tauri build produces desktop binaries; mobile needs cargo tauri android / cargo tauri ios. Toolchain setup (NDK, Xcode) trips up most first-time mobile builds.
  • Config schema is different. tauri.conf.json was completely restructured. allowlist is gone; bundle, app, build are 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.
  • allowlist removed — replaced by capabilities/*.json files (see Fix 1).
  • devPathdevUrl; distDirfrontendDist.
  • identifier (bundle ID like com.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 migrate

Run 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 dev

Install 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 dev

Common 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 typed

Version 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 allowlist model, no plugin permission system, desktop-only (Linux, macOS, Windows). The IPC layer used __TAURI_INVOKE__ directly and invoke lived 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 migrate shipped in the CLI to rewrite tauri.conf.json and turn allowlist entries 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/core module path for invoke, 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.json app.security.assetProtocol setting for serving local files without convertFileSrc calls.
  • 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 dev runs but window is blank. Frontend dev server didn’t start, or devUrl doesn’t match. Check beforeDevCommand runs your dev server first.
  • CSP blocks IPC. Add 'self' tauri: to default-src/script-src. Without it, the frontend can’t invoke any command.
  • fs:allow-read-file works but reads fail. You also need a scope. Add a fs:scope-app permission that lists allowed paths.
  • Bundle is huge. Build with --release and strip debug symbols: add strip = true to [profile.release] in Cargo.toml.
  • Android: gradle build failed. Open the project in Android Studio once to let it fix Gradle plugin versions. Then go back to cargo tauri android build.
  • macOS: notarization fails after cargo tauri build. Set signingIdentity and configure bundle.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:5173 URLs in your code don’t work — Tauri serves from tauri://localhost. Use relative paths.
  • invoke returns undefined even though the command logs in Rust. The command panicked silently and Tauri swallowed the error. Wrap return values in Result<T, String> and inspect err on the frontend instead of assuming success.
  • @tauri-apps/api/tauri import resolves but throws at runtime. That path is a leftover from v1. Switch every import to @tauri-apps/api/core (for invoke) or @tauri-apps/api/event (for listen/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 dev runs 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 to 0.0.0.0 and open the dev port, or use adb reverse tcp:1420 tcp:1420 for 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles