← Catalog

tauri-plugin-dev

Use when authoring a custom Tauri v2 plugin — scaffolding the crate (`tauri plugin new`), exposing commands, declaring permissions with auto-generated manifest, providing a JS API package, and handling mobile platform code (Swift/Kotlin) if needed.

Authoring a Tauri v2 Plugin

A Tauri plugin is a Cargo crate + optional npm package (and optionally an Android library + Swift package) that hooks into the Tauri lifecycle, exposes Rust/Kotlin/Swift commands to the WebView, and ships permissions describing what it lets the frontend do. Unlike app-local #[tauri::command]s, plugins are versioned and distributable — and every command needs a permission, or the frontend gets a denial error.

When to reach for a plugin (vs. an app command)

  • You want to reuse the capability across multiple Tauri apps.
  • You need native mobile code (Kotlin/Swift) — only plugins can host an android/ and ios/ project alongside Rust.
  • You want a stable command namespace (plugin:my-plugin|cmd) that survives app refactors.
  • You want consumers to opt into specific capabilities via your permissions/*.toml files.

If none of those apply, a plain #[tauri::command] in the app is simpler — see the tauri-commands skill.

Scaffold

Run from the parent directory where you want tauri-plugin-<name>/ to land:

bunx @tauri-apps/cli plugin new my-plugin
# Flags:
#   --no-api       skip the npm package (Rust-only plugin)
#   --android      include android/ library project
#   --ios          include ios/ Swift package

This produces:

tauri-plugin-my-plugin/
├── src/
│   ├── commands.rs       # #[tauri::command] handlers
│   ├── desktop.rs        # desktop impl of the public API struct
│   ├── mobile.rs         # mobile impl (calls run_mobile_plugin)
│   ├── error.rs          # crate Error + Result aliases
│   ├── lib.rs            # Builder + setup + invoke_handler + Ext trait
│   └── models.rs         # shared serde structs
├── permissions/          # *.toml manifests; autogen.toml is regenerated by build.rs
├── android/              # if --android: Kotlin library + Plugin class
├── ios/                  # if --ios: Swift package + Plugin class
├── guest-js/             # TypeScript source for the npm package
├── dist-js/              # transpiled output (published, gitignored at source)
├── build.rs              # calls tauri_plugin::Builder::new(COMMANDS).build()
├── Cargo.toml
└── package.json

To add mobile to an existing plugin: bunx @tauri-apps/cli plugin android add / plugin ios add.

Naming convention: Cargo crate is tauri-plugin-<name>; npm package is tauri-plugin-<name>-api or (preferred) @your-scope/plugin-<name>. The runtime plugin identifier passed to Builder::new(...) is just <name> — keep it short, snake_case, and matching the directory.

The plugin Builder (src/lib.rs)

The crate’s public entry point is an init() function returning TauriPlugin<R>. Consumers call tauri::Builder::default().plugin(tauri_plugin_my_plugin::init()).

use tauri::{
    plugin::{Builder, TauriPlugin},
    Manager, Runtime,
};

pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("my-plugin")
        .invoke_handler(tauri::generate_handler![
            commands::greet,
            commands::set_counter,
        ])
        .setup(|app, _api| {
            app.manage(MyState::default());
            Ok(())
        })
        .build()
}

The string "my-plugin" is the plugin identifier — it shows up in plugin:my-plugin|cmd from JS and as the prefix in permission identifiers. Don’t change it after release without a major version bump.

Lifecycle hooks (chain on the Builder)

HookSignatureUse for
`.setup(\app, api\{ … Ok(()) })`once at initregister state, spawn background threads, read plugin config via api.config()
`.on_navigation(\window, url\bool)`every navigationreturn false to cancel; track URL changes
`.on_webview_ready(\window\{ … })`each new webviewattach per-window listeners
`.on_event(\app, run_event\{ … })`every RunEventhandle ExitRequested, Exit (cleanup), window events
`.on_drop(\app\{ … })`plugin destructionflush state, close handles

Plugin configuration

If consumers can configure your plugin via tauri.conf.json > plugins.<name>, parameterize TauriPlugin with a Deserialize config type:

#[derive(serde::Deserialize)]
pub struct Config { timeout: usize }

pub fn init<R: Runtime>() -> TauriPlugin<R, Config> {
    Builder::<R, Config>::new("my-plugin")
        .setup(|app, api| {
            let _t = api.config().timeout;
            Ok(())
        })
        .build()
}

Use Builder::<R, Option<Config>> to make the config optional.

Commands

Commands live in src/commands.rs and look identical to app commands — #[tauri::command], async or sync, taking AppHandle<R>, Window<R>, State<'_, T>, and serde-deserializable args. The only differences:

  1. They must be registered on the plugin Builder’s invoke_handler, not the app’s.
  2. They’re invoked from JS as invoke('plugin:my-plugin|command_name', args).
  3. They need a permission (see next section) or every call is rejected.
// src/commands.rs
use tauri::{command, AppHandle, Runtime, State};

#[command]
pub async fn greet<R: Runtime>(
    _app: AppHandle<R>,
    state: State<'_, crate::MyState>,
    name: String,
) -> Result<String, String> {
    state.bump();
    Ok(format!("Hello, {name}!"))
}

Need raw access? Take tauri::ipc::Invoke<R> as the only parameter and handle serialization yourself — useful for streaming or non-JSON payloads.

Permissions — the part new plugin authors get wrong

By default no command is callable from JS. You must (a) declare a permission that allows it and (b) have the consuming app include that permission in a capability.

Autogenerated allow/deny pairs (the easy path)

In build.rs, list every command name (snake_case, matching the Rust fn) in COMMANDS. Tauri’s build script generates permissions/autogenerated/commands/<name>.toml with allow-<name> and deny-<name> identifiers, plus a regenerated permissions/autogenerated/reference.md.

// build.rs
const COMMANDS: &[&str] = &["greet", "set_counter"];

fn main() {
    tauri_plugin::Builder::new(COMMANDS).build();
}

Hand-written permission file (when you want control)

# permissions/allow-greet.toml
"$schema" = "schemas/schema.json"

[[permission]]
identifier = "allow-greet"
description = "Enables the greet command."
commands.allow = ["greet"]

Default permission set

Define a default set that consumers get by referencing my-plugin:default in a capability — pull together the commands the plugin is useless without:

# permissions/default.toml
"$schema" = "schemas/schema.json"
[default]
description = "Default permissions for my-plugin: greeting works."
permissions = ["allow-greet"]

Group related commands with [[set]]:

[[set]]
identifier = "allow-counter"
description = "Read and write the counter."
permissions = ["allow-get-counter", "allow-set-counter"]

Scopes (per-command or global restrictions)

For commands that act on resources (paths, binaries, URLs), define a scope struct in src/scope.rs, derive schemars::JsonSchema, and in build.rs pass it via .global_scope_schema(schemars::schema_for!(scope::Entry)). Read it from commands via tauri::ipc::CommandScope<'_, Entry> (per-command) or GlobalScope<'_, Entry> (plugin-wide). Add schemars = "0.8" to both [dependencies] and [build-dependencies] because the scope module is shared.

JS API package (guest-js/index.ts)

Wrap raw invoke calls in typed friendly functions. This is what consumers import from your npm package.

import { invoke } from '@tauri-apps/api/core';

export async function greet(name: string): Promise<string> {
  return await invoke<string>('plugin:my-plugin|greet', { name });
}

For event streaming, use Channel from @tauri-apps/api/core. For events emitted with trigger(...) on mobile, expose addPluginListener('my-plugin', 'event-name', handler).

package.json should declare "type": "module" and point main/module/types at dist-js/. Run a bundler (the template uses rollup) so consumers get prebuilt output.

State management

Same pattern as app commands — register in setup, access via State<'_, T>:

#[derive(Default)]
pub struct MyState(std::sync::Mutex<u64>);

impl MyState {
    pub fn bump(&self) { *self.0.lock().unwrap() += 1; }
}

// in init():
.setup(|app, _api| {
    app.manage(MyState::default());
    Ok(())
})

Expose a typed extension trait so consumers can read state from their own Rust code without knowing the struct name:

pub trait MyPluginExt<R: Runtime> {
    fn my_plugin(&self) -> &MyState;
}

impl<R: Runtime, T: Manager<R>> MyPluginExt<R> for T {
    fn my_plugin(&self) -> &MyState { self.state::<MyState>().inner() }
}

Now app.my_plugin().bump() works in the consumer app after .plugin(tauri_plugin_my_plugin::init()).

Mobile (Android / iOS)

When --android or --ios is passed at scaffold time, the template splits the public API:

  • src/desktop.rs — pure Rust implementation.
  • src/mobile.rs — calls run_mobile_plugin("commandName", payload) on a PluginHandle.
  • src/lib.rscfg-switches between the two and exposes a single shared struct.
// src/mobile.rs (sketch)
impl<R: Runtime> MyPlugin<R> {
    pub fn open_camera(&self, payload: CameraRequest) -> crate::Result<Photo> {
        self.0.run_mobile_plugin("openCamera", payload).map_err(Into::into)
    }
}

The mobile-side handler is a Kotlin class extending app.tauri.plugin.Plugin with @TauriPlugin and @Command-annotated methods (Android), or a Swift class extending Plugin with @objc func name(_ invoke: Invoke) methods (iOS). Args are parsed via invoke.parseArgs(MyArgs::class.java) / invoke.parseArgs(MyArgs.self); results go through invoke.resolve(...) / invoke.reject(...).

Use trigger("event-name", payload) from mobile code to emit events that JS picks up via addPluginListener. Listening from JS still requires the plugin’s permission in the consumer’s capability.

Mobile permission prompts (camera, notifications, etc.) — override checkPermissions / requestPermissions on the Plugin class. Tauri auto-exposes them as the checkPermissions and requestPermissions commands; no extra Rust wiring is needed beyond the run_mobile_plugin call from a Rust convenience method.

Calling Rust from mobile native code (when you want shared logic and the WebView may be suspended): add jni = "0.21" under [target.'cfg(target_os = "android")'.dependencies], define external fun declarations in Kotlin matching Java_<pkg>_<class>_<method> #[no_mangle] pub extern "system" fn exports in Rust. On iOS, @_silgen_name("fn_name") in Swift maps to #[no_mangle] pub unsafe extern "C" fn fn_name(...) in Rust — pass *const c_char for strings and ship a matching free_* to drop the CString.

Build script (build.rs)

Always present. Two responsibilities:

  1. Tell Tauri which commands to autogenerate permissions for (the COMMANDS const).
  2. Optionally register a scope schema with .global_scope_schema(...).

The build script regenerates permissions/autogenerated/ on every build — never edit those files by hand; edit COMMANDS and rebuild.

Publishing

Crate and npm package are versioned together — bump both, tag once, publish in this order:

# 1. Build the JS package
cd guest-js && bun run build && cd ..

# 2. Publish npm (uses dist-js/)
npm publish --access public

# 3. Publish crate
cargo publish

Consumers install:

cargo add tauri-plugin-my-plugin
bun add @your-scope/plugin-my-plugin   # or tauri-plugin-my-plugin-api

And wire it up:

tauri::Builder::default()
  .plugin(tauri_plugin_my_plugin::init())
  .run(tauri::generate_context!())
  .expect("error while running tauri application");
// src-tauri/capabilities/default.json — consumer must opt in
{
  "identifier": "default",
  "windows": ["main"],
  "permissions": ["my-plugin:default"]
}

Common failure modes

  • “Command plugin:foo|bar not allowed by ACL” — consumer’s capability is missing foo:default or foo:allow-bar. The plugin author can’t fix this from inside the plugin; document the required permission in your README.
  • Commands missing from JS — forgot to add to tauri::generate_handler![...] on the plugin Builder (not the app’s).
  • autogenerated/ not regeneratingCOMMANDS const in build.rs doesn’t include the new command name, or you edited the generated .toml directly (it gets clobbered).
  • State State<'_, T> panics with “state not managed”setup returned Err or app.manage(...) was never called. Make sure setup doesn’t short-circuit.
  • Mobile command never fires — Rust side called run_mobile_plugin("openCamera", ...) but the Kotlin/Swift method name is open_camera — names must match exactly (camelCase is convention on the mobile side).
  • Changing the plugin identifier breaks every consumer capability silently. Treat Builder::new("...") as a public API.

Templates

See templates/:

  • lib.rs — full plugin skeleton: Builder + setup + state + extension trait + one command.
  • permissions/allow-greet.toml — hand-written permission file.
  • guest-js-index.ts — typed JS API wrapper.