Skip to content

Fix: Tauri Not Working — Command Not Found, IPC Error, File System Permission Denied, or Build Fails

FixDevs ·

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_command

Or 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 scope

Or tauri dev runs but invoke always returns undefined:

#[tauri::command]
fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}
// JS receives undefined instead of the string

Or the build fails with a Rust compilation error:

error[E0425]: cannot find function `greet` in this scope
  --> src-tauri/src/main.rs:12:5

Why 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 in generate_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 implement Serialize, the command silently returns undefined or fails.

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:

VariablePath
$APPDATAPlatform app data directory
$APPCONFIGPlatform app config directory
$APPLOGPlatform app log directory
$APPLOCALDATAPlatform local app data
$HOMEUser home directory
$DESKTOPUser desktop
$DOCUMENTUser documents
$DOWNLOADUser downloads
$TEMPSystem 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-expand

Still 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.

For related Rust issues, see Fix: Rust Error Handling Not Working and Fix: Rust Borrow Checker Error.

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