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/andios/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/*.tomlfiles.
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)
| Hook | Signature | Use for | ||
|---|---|---|---|---|
| `.setup(\ | app, api\ | { … Ok(()) })` | once at init | register state, spawn background threads, read plugin config via api.config() |
| `.on_navigation(\ | window, url\ | bool)` | every navigation | return false to cancel; track URL changes |
| `.on_webview_ready(\ | window\ | { … })` | each new webview | attach per-window listeners |
| `.on_event(\ | app, run_event\ | { … })` | every RunEvent | handle ExitRequested, Exit (cleanup), window events |
| `.on_drop(\ | app\ | { … })` | plugin destruction | flush 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:
- They must be registered on the plugin Builder’s
invoke_handler, not the app’s. - They’re invoked from JS as
invoke('plugin:my-plugin|command_name', args). - 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— callsrun_mobile_plugin("commandName", payload)on aPluginHandle.src/lib.rs—cfg-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:
- Tell Tauri which commands to autogenerate permissions for (the
COMMANDSconst). - 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:defaultorfoo: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 regenerating —COMMANDSconst inbuild.rsdoesn’t include the new command name, or you edited the generated.tomldirectly (it gets clobbered).- State
State<'_, T>panics with “state not managed” —setupreturnedErrorapp.manage(...)was never called. Make suresetupdoesn’t short-circuit. - Mobile command never fires — Rust side called
run_mobile_plugin("openCamera", ...)but the Kotlin/Swift method name isopen_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.