How Tauri's Plugin System Works and How to Create a Custom Plugin
Tauri's plugin system treats extensions as first-class citizens that can inject JavaScript, register IPC commands, hook into lifecycle events, and define custom URI schemes through the Plugin<R> trait and Builder<R, C> abstraction.
The plugin architecture in the tauri-apps/tauri repository enables developers to extend both the Rust backend and JavaScript frontend without modifying core framework code. By leveraging the Builder<R, C> pattern and the Plugin<R> trait defined in crates/tauri/src/plugin.rs, you can create reusable extensions that integrate deeply with the application lifecycle across desktop and mobile platforms.
Core Architecture: Plugin Trait and Builder Pattern
Tauri's plugin system revolves around two fundamental abstractions that decouple extension logic from the core runtime.
The Plugin<R> Trait
The Plugin<R> trait in crates/tauri/src/plugin.rs (lines 36-62) defines the runtime contract every plugin must fulfill. It specifies callbacks for initialization, window events, navigation handling, and IPC command registration. When you implement this trait, you gain access to hooks like initialize, window_created, on_navigation, and extend_api, allowing your code to execute at precise moments in the application lifecycle.
The Builder<R, C> Ergonomic API
Rather than implementing Plugin<R> manually, most developers use the Builder<R, C> struct (lines 64-78 in the same file). This builder accumulates configuration through methods like invoke_handler(), js_init_script(), and on_navigation(), then constructs a TauriPlugin<R, C> that automatically implements the Plugin<R> trait. The builder stores callbacks as boxed closures (e.g., Box<OnNavigation<R>>), and the TauriPlugin implementation forwards framework calls to these stored closures.
Plugin Registration Flow
When the application starts, the plugin store (PluginStore<R>) orchestrates the following sequence:
- Registration:
PluginStore::registerstores the boxed plugin instance (lines 76-89). - Initialization:
PluginStore::initialize_allinvokes each plugin'sinitializemethod (lines 998-1009), executing thesetupclosure supplied to the builder. - Event Wiring: The runtime connects lifecycle callbacks (window creation, navigation, etc.) to the plugin's stored handlers.
Lifecycle Hooks and Capabilities
Tauri plugins can intercept or extend nearly every aspect of the application runtime through these specific hooks:
| Hook | Execution Timing | Common Use Case |
|---|---|---|
initialize |
Immediately after App creation, before any window builds |
Load native resources, register URI schemes |
window_created |
After a Window<R> instance constructs |
Attach window-specific state or menus |
webview_created |
After the Webview<R> initializes |
Inject JavaScript listeners or preload scripts |
on_navigation |
Before any navigation occurs | Block external URLs or validate deep links |
on_page_load |
After a page finishes loading | Emit initialization events to the frontend |
extend_api |
When an IPC invoke message arrives |
Provide custom commands via invoke_handler |
js_init_script |
Before any page script executes | Expose global JavaScript APIs |
register_uri_scheme_protocol |
During initialize |
Handle custom schemes like myapp:// |
How to Create a Custom Tauri Plugin
Building a plugin follows a standardized workflow that begins with CLI scaffolding and ends with configuration in tauri.conf.json.
Step 1: Scaffold the Plugin Structure
Execute the CLI command to generate a new plugin crate:
cargo tauri plugin new my_plugin
This creates a crate under src-tauri/plugins/my_plugin using the template at crates/tauri-cli/templates/plugin/src/lib.rs. The generated init function already returns a Builder::new("my_plugin"), establishing the plugin namespace.
Step 2: Add IPC Commands
Define Rust functions using the #[tauri::command] macro and register them via the builder's invoke_handler:
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {name}!")
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("my_plugin")
.invoke_handler(tauri::generate_handler![greet])
.build()
}
Step 3: Inject Frontend JavaScript
Expose a global API to the frontend by providing a js_init_script:
Builder::new("my_plugin")
.invoke_handler(tauri::generate_handler![greet])
.js_init_script(r#"
window.__MY_PLUGIN__ = {
greet: (name) => invoke('my_plugin:greet', { name })
};
"#)
.build()
Step 4: Hook Into Lifecycle Events
Intercept navigation or window events using builder methods:
Builder::new("my_plugin")
.on_navigation(|_webview, url| {
// Allow only tauri:// scheme
url.scheme() == "tauri"
})
.build()
Step 5: Configure the Application
Add the plugin name to tauri.conf.json under the plugins array, then ensure your main.rs calls the plugin's init function:
tauri::Builder::default()
.plugin(my_plugin::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
Complete Implementation Examples
Minimal Standalone Plugin
This example creates a functional "adder" plugin in a standalone crate:
// src/lib.rs
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
#[tauri::command]
fn add(a: i64, b: i64) -> i64 {
a + b
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("adder")
.invoke_handler(tauri::generate_handler![add])
.js_init_script(r#"
window.__ADDER__ = {
add: (a, b) => invoke('adder:add', { a, b })
};
"#)
.build()
}
After adding "adder" to tauri.conf.json, the plugin is available to both Rust and JavaScript.
Frontend JavaScript Usage
Access the plugin from any renderer process:
// In any webview script
window.__ADDER__.add(2, 3).then(result => console.log(result)); // prints 5
Rust-Side Plugin Access
The scaffolding generates an extension trait allowing direct access to plugin state from Rust:
fn handle_event(app: &tauri::AppHandle) {
// The generated extension trait provides the accessor method
let adder = app.adder();
// Call methods defined on the plugin's internal state
}
Custom URI Scheme Handler
Register a protocol handler for synchronous or asynchronous resource loading:
use tauri::http;
Builder::new("files")
.register_uri_scheme_protocol("myapp", |_ctx, request| {
let path = &request.uri().path()[1..];
let data = std::fs::read(path).unwrap_or_default();
http::Response::builder()
.header("Content-Type", "application/octet-stream")
.body(data)
.unwrap()
})
.build()
Mobile vs Desktop Plugin Development
While the core Builder and Plugin trait work across platforms, mobile-specific functionality resides in crates/tauri/src/plugin/mobile.rs. This module handles the Android/iOS bridge through PluginApi::run_command and manages pending call maps for asynchronous native communication. Desktop plugins may include platform-specific code using conditional compilation (e.g., #[cfg(target_os = "macos")]) within the plugin crate.
For a complete reference implementation including custom scopes and event channels, examine the official sample plugin at examples/api/src-tauri/tauri-plugin-sample/src/lib.rs.
Summary
- Core Abstractions: The
Plugin<R>trait defines the contract, whileBuilder<R, C>provides an ergonomic API for constructingTauriPlugininstances without manual trait implementation. - Registration: Plugins register via
PluginStore::registerand initialize throughPluginStore::initialize_all(lines 998-1009 incrates/tauri/src/plugin.rs). - Lifecycle Integration: Hooks like
initialize,window_created,on_navigation, andextend_apiallow deep integration with the Tauri runtime. - CLI Workflow: Use
cargo tauri plugin newto scaffold, then configureinvoke_handler,js_init_script, and optional URI scheme protocols. - Cross-Platform: The same plugin architecture supports desktop and mobile, with mobile-specific bridge code isolated in
mobile.rs.
Frequently Asked Questions
What is the difference between a Tauri plugin and a regular Rust module?
A Tauri plugin implements the Plugin<R> trait and registers with the PluginStore, granting it access to lifecycle hooks, automatic IPC registration, and JavaScript injection capabilities. Regular Rust modules can contain commands but lack the standardized lifecycle integration and frontend-facing APIs that the plugin system provides through Builder methods like js_init_script.
How do I share state between plugin commands?
Store state within the plugin's struct or use the setup closure to initialize shared data structures before returning the builder. The scaffolding generates an extension trait (e.g., MyPluginExt) that allows other Rust code to access this state through app_handle.my_plugin(). Alternatively, use Tauri's standard state management with app.manage() inside the plugin's initialize hook.
Can I use async/await in plugin commands?
Yes. Declare commands with async fn and Tauri's runtime will handle the async execution. When using invoke_handler(tauri::generate_handler![my_async_command]), the framework automatically awaits the future. For mobile plugins, the bridge in mobile.rs specifically manages pending asynchronous calls between the Rust core and native mobile code.
How do I debug a plugin during development?
Run your Tauri application with cargo tauri dev and use standard Rust debugging tools like println!, dbg!, or attach a debugger to the Rust process. For the JavaScript side, use the browser's DevTools to inspect the injected js_init_script and verify that global objects like window.__MY_PLUGIN__ are correctly initialized. Check the examples/api/src-tauri/tauri-plugin-sample directory for debugging patterns involving custom scopes and event channels.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →