Fix: Tauri Not Working — Command Not Found, IPC Error, File System Permission Denied, or Build Fails
Part of: JavaScript & TypeScript Errors
Quick Answer
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.
The Problem
invoke throws “command not found”:
import { invoke } from '@tauri-apps/api/core';
const result = await invoke('my_command', { name: 'Alice' });
// Error: Command not found: my_commandOr file system operations are rejected:
import { readTextFile } from '@tauri-apps/plugin-fs';
const content = await readTextFile('/home/user/config.json');
// Error: path not allowed on the configured scopeOr tauri dev runs but invoke always returns undefined:
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
// JS receives undefined instead of the stringOr the build fails with a Rust compilation error:
error[E0425]: cannot find function `greet` in this scope
--> src-tauri/src/main.rs:12:5Why This Happens
Tauri has a strict separation between the Rust backend and the frontend:
- Commands must be registered in
generate_handler!— defining a#[tauri::command]function isn’t enough. You must list every command ingenerate_handler![my_command]and pass it to.invoke_handler(). - Tauri v1 vs v2 API differences — Tauri v2 restructured all APIs into plugins (
@tauri-apps/plugin-fs,@tauri-apps/plugin-shell, etc.). Using v1 import paths with a v2 project (or vice versa) fails at import time. - Permissions are locked down by default — in Tauri v2, all capabilities (file system, shell, HTTP) require explicit permission grants in
capabilities/*.json. No permission = rejected at runtime. - Return types must implement
serde::Serialize— Rust commands communicate with the frontend through JSON serialization. If your return type doesn’t implementSerialize, the command silently returnsundefinedor fails.
A second category of issues comes from Tauri’s WebView dependency model. Unlike Electron, which bundles a complete copy of Chromium with every app, Tauri uses the operating system’s native WebView — WebView2 (Chromium-based Edge) on Windows, WKWebView (Safari) on macOS, WebKitGTK on Linux. That keeps installer sizes around 5-10 MB instead of 80-150 MB, but it also means a CSS feature working in your dev browser may not work in your user’s WebView. Always test in the actual Tauri window, not in a regular browser tab. The mismatch between WKWebView and Chromium is the most common surprise on macOS, especially for newer CSS features like container queries.
The third trap is the permission system in v2. v1 had a coarse tauri.allowlist block that let you enable entire APIs at once. v2 replaced this with capability files and granular permissions per API method (fs:allow-read-text-file vs fs:allow-write-text-file), plus path scopes that restrict where each capability applies. The model is more secure, but it shifts work onto the developer — you must declare every method and every path your app touches. The migration from v1 to v2 frequently leaves capabilities under-declared, producing runtime errors that didn’t exist in v1.
How Other Tools Handle This
Tauri sits in a crowded field of native-shell frameworks, and the failure mode you hit usually maps to the architectural choices each one made.
Tauri vs Electron. Electron bundles a full Chromium runtime (~150 MB installer) and runs your app inside it. The renderer is identical across Windows, macOS, and Linux because the browser engine is identical. That eliminates the WKWebView vs Chromium drift that breaks Tauri’s CSS on macOS, but it costs you disk, memory (~80–100 MB resident even for a hello-world), and a long startup. Electron uses Node.js for the backend instead of Rust, so command registration is just an IPC handler in main.js — no generate_handler! macro, no Rust trait bounds. Electron has no permission system by default; you ship a process with full filesystem access unless you sandbox manually.
Tauri vs Wails (Go). Wails takes the same WebView-based approach as Tauri but uses Go for the backend. Go commands are exposed by reflecting struct methods, so you don’t write a registration list — every public method is callable. Wails inherits the same WKWebView/WebView2 quirks as Tauri. Binary sizes land around 10–20 MB. There is no granular capability system; bindings are exposed wholesale.
Tauri vs Neutralino. Neutralino is the lightest of the four — a single ~3 MB binary that talks to the OS WebView and a thin C++ runtime. There is no compiled backend language; all logic stays in JavaScript and talks to OS APIs via a JSON-RPC bridge. You give up Rust’s compile-time safety but skip the Cargo build entirely.
Picking the right tool. Use Electron when bundle size doesn’t matter and you need rendering parity. Use Tauri when binary size, memory, and security boundaries matter and you can pay the WebView-drift tax. Use Wails when your team already writes Go. Use Neutralino for tray utilities and small tools where 3 MB matters more than language choice.
Fix 1: Register Commands Correctly
Every Rust command must be registered in main.rs:
// src-tauri/src/main.rs
// WRONG — command defined but not registered
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
// Missing: .invoke_handler(tauri::generate_handler![greet])
}
// CORRECT — command defined AND registered
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
// Async commands must return Result<T, E> where E: Serialize
reqwest::get(&url)
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
greet,
fetch_data,
// List ALL commands here
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Organize commands in separate modules:
// src-tauri/src/commands/user.rs
#[tauri::command]
pub fn get_user(id: u32) -> User {
// ...
}
// src-tauri/src/commands/file.rs
#[tauri::command]
pub async fn read_config() -> Result<Config, String> {
// ...
}
// src-tauri/src/commands/mod.rs
pub mod user;
pub mod file;
// src-tauri/src/main.rs
mod commands;
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
commands::user::get_user,
commands::file::read_config,
])
.run(tauri::generate_context!())
.expect("error");
}Fix 2: Ensure Return Types Are Serializable
Commands must return types that implement serde::Serialize:
// WRONG — custom struct without Serialize
struct User {
id: u32,
name: String,
}
#[tauri::command]
fn get_user(id: u32) -> User { // Compile error: User doesn't implement Serialize
User { id, name: "Alice".to_string() }
}
// CORRECT — derive Serialize (and Deserialize for inputs)
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct User {
pub id: u32,
pub name: String,
pub email: Option<String>,
}
#[tauri::command]
fn get_user(id: u32) -> User {
User { id, name: "Alice".to_string(), email: None }
}
// Error handling — use Result for fallible operations
#[derive(Serialize)]
pub struct AppError {
message: String,
code: u32,
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError { message: e.to_string(), code: 1 }
}
}
#[tauri::command]
async fn read_file(path: String) -> Result<String, AppError> {
let content = tokio::fs::read_to_string(&path).await?; // ? converts via From
Ok(content)
}Frontend error handling:
import { invoke } from '@tauri-apps/api/core';
try {
const user = await invoke<User>('get_user', { id: 1 });
console.log(user.name);
} catch (e) {
// e is the serialized AppError from Rust
console.error(e); // { message: "...", code: 1 }
}Fix 3: Configure Permissions (Tauri v2)
Tauri v2 requires explicit capability grants in JSON files:
// src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default app capabilities",
"windows": ["main"],
"permissions": [
"core:default",
// File system — must specify exactly what's needed
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-dir",
"fs:allow-create-dir",
"fs:allow-remove-file",
// Shell
"shell:allow-open",
// HTTP client
"http:default",
// Dialog
"dialog:allow-open",
"dialog:allow-save",
// Clipboard
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
// Window
"window:allow-maximize",
"window:allow-minimize",
"window:allow-close",
"window:allow-set-title"
]
}File system scope — restrict which paths are accessible:
// src-tauri/capabilities/default.json
{
"permissions": [
{
"identifier": "fs:allow-read-text-file",
"allow": [
{ "path": "$APPDATA/**" }, // App data directory
{ "path": "$HOME/Documents/**" },
{ "path": "$DESKTOP/**" }
],
"deny": [
{ "path": "$HOME/.ssh/**" } // Explicitly deny sensitive paths
]
},
{
"identifier": "fs:allow-write-text-file",
"allow": [
{ "path": "$APPDATA/**" } // Only write to app data
]
}
]
}Available path variables:
| Variable | Path |
|---|---|
$APPDATA | Platform app data directory |
$APPCONFIG | Platform app config directory |
$APPLOG | Platform app log directory |
$APPLOCALDATA | Platform local app data |
$HOME | User home directory |
$DESKTOP | User desktop |
$DOCUMENT | User documents |
$DOWNLOAD | User downloads |
$TEMP | System temp directory |
Fix 4: Access the App State from Commands
Pass state (database connections, config) to commands via Tauri’s managed state:
// src-tauri/src/main.rs
use std::sync::Mutex;
use tauri::State;
// Define your state struct
pub struct AppState {
pub db: Mutex<DatabaseConnection>,
pub config: AppConfig,
}
// Command that uses state
#[tauri::command]
async fn get_users(state: State<'_, AppState>) -> Result<Vec<User>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.query_users().await.map_err(|e| e.to_string())
}
#[tauri::command]
fn get_config(state: State<'_, AppState>) -> AppConfig {
state.config.clone()
}
fn main() {
let db = DatabaseConnection::new("sqlite://app.db").expect("DB connect failed");
let config = AppConfig::load().expect("Config load failed");
tauri::Builder::default()
.manage(AppState {
db: Mutex::new(db),
config,
})
.invoke_handler(tauri::generate_handler![get_users, get_config])
.run(tauri::generate_context!())
.expect("error");
}Emit events from Rust to the frontend:
use tauri::{AppHandle, Manager, Emitter};
#[tauri::command]
async fn start_long_task(app: AppHandle) -> Result<(), String> {
for i in 0..=100 {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
// Emit progress event to all windows
app.emit("task-progress", i)
.map_err(|e| e.to_string())?;
}
app.emit("task-complete", ()).map_err(|e| e.to_string())?;
Ok(())
}// Frontend — listen for events
import { listen } from '@tauri-apps/api/event';
const unlisten = await listen('task-progress', (event) => {
progressBar.value = event.payload;
});
await listen('task-complete', () => {
console.log('Task done!');
unlisten(); // Stop listening
});
await invoke('start_long_task');Fix 5: Fix Common Build Errors
Cargo.toml missing plugin dependencies:
# src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2" # Add plugins you use
tauri-plugin-shell = "2"
tauri-plugin-http = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] } # For async commands
[build-dependencies]
tauri-build = { version = "2", features = [] }Register plugins in main.rs:
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Frontend package.json:
{
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-fs": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-http": "^2",
"@tauri-apps/plugin-dialog": "^2"
}
}Fix 6: Debug Tauri Apps
// Frontend — check if running inside Tauri
import { invoke } from '@tauri-apps/api/core';
const isTauri = typeof window !== 'undefined' && '__TAURI__' in window;
// Enable debug logging in Rust
// src-tauri/src/main.rs
fn main() {
// Set up logging
tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::new()
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::LogDir { file_name: Some("app.log".to_string()) }
))
.build())
// ...
}
// In Rust commands — use log macros
use log::{debug, info, error};
#[tauri::command]
fn process_data(data: String) -> Result<String, String> {
debug!("Processing data: {}", &data[..20.min(data.len())]);
let result = do_work(&data).map_err(|e| {
error!("Processing failed: {}", e);
e.to_string()
})?;
info!("Processing complete, result length: {}", result.len());
Ok(result)
}Run with verbose output:
# Show Rust log output
RUST_LOG=debug tauri dev
# Show Tauri-specific logs
RUST_LOG=tauri=debug tauri dev
# Check the generated code
cargo expand --manifest-path src-tauri/Cargo.toml # Requires cargo-expandStill Not Working?
invoke returns undefined for a void command — Rust commands that return () (unit type) correctly map to undefined in JavaScript. If you expect undefined, this is correct behavior. If you expected a value, add a return type to your Rust function and a corresponding Result<T, E>.
Window opens but stays blank (white screen) — check the devUrl in tauri.conf.json. In development, Tauri opens a URL (e.g., http://localhost:1420). If the dev server isn’t running, the window is blank. Run npm run dev in the frontend directory first, then tauri dev.
macOS code signing errors in production — Tauri apps require code signing for macOS distribution. Set up APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_ID, and APPLE_PASSWORD environment variables in your CI. For local testing without a certificate, use tauri build --debug or disable Gatekeeper temporarily.
Permission errors on Windows with fs plugin — Windows paths use backslashes, but Tauri’s path scope uses forward slashes internally. Use path variables ($APPDATA/**) instead of hardcoded Windows paths in your capability file. Use path.join() or Tauri’s path API to construct platform-appropriate paths at runtime.
Rust trait bound errors on command arguments — every command argument must implement Deserialize, and every return type must implement Serialize. If a struct nested inside your return type forgets #[derive(Serialize)], the compile error points at the outer type, not the missing derive. Walk the type graph from the failing command down to leaf structs.
tauri build succeeds locally but fails in CI on Linux — Linux builds require system packages (libwebkit2gtk-4.1-dev, build-essential, libssl-dev, libayatana-appindicator3-dev, librsvg2-dev) that aren’t on the default GitHub Actions runner. Install them in a setup step before invoking tauri build, otherwise cargo fails to link against WebKitGTK and you get cryptic linker errors.
Side-by-side v1 and v2 install confuses the CLI — if npm resolves @tauri-apps/cli@1 but Cargo.toml pins tauri = "2", the CLI runs the v1 dev flow against a v2 project and reports missing config keys. Pin the CLI version explicitly in package.json and verify with npx tauri --version that it matches your Rust dependency.
For related Rust issues, see Fix: Rust Error Handling Not Working and Fix: Rust Borrow Checker Error. For comparable desktop-shell debugging, see Fix: Electron Not Working and Fix: Rust Trait Not Implemented.
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 2 Not Working — Capabilities, Plugin Permissions, Mobile Build, and v1 Migration
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.
Fix: Electron Not Working — Window Not Showing, IPC Not Communicating, or Build Failing
How to fix Electron issues — main and renderer process setup, IPC communication with contextBridge, preload scripts, auto-update, native module rebuilding, and packaging with electron-builder.
Fix: Deno PermissionDenied — Missing --allow-read, --allow-net, and Other Flags
How to fix Deno PermissionDenied (NotCapable in Deno 2) errors — the right permission flags, path-scoped permissions, deno.json permission sets, and the Deno.permissions API.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.