Extending Tauri: The Plugin Architecture and Extension Model
Prerequisites
- ›Articles 1-4: Architecture, Lifecycle, IPC, and Security
- ›Rust trait objects and dynamic dispatch
- ›Understanding of build.rs scripts and compile-time code generation
Extending Tauri: The Plugin Architecture and Extension Model
Tauri's plugin system isn't just for third-party extensions — it's how the framework implements its own core functionality. Events, window management, webview management, tray icons — all are plugins. This design choice reveals something fundamental about the architecture: if the framework's own features can be expressed as plugins, the plugin API must be powerful enough for anything.
The Plugin Trait: Lifecycle Hooks
The Plugin<R> trait defines the complete interface a plugin can implement:
| Hook | When Called | Purpose |
|---|---|---|
name() |
Always | Returns the plugin's string identifier |
initialize() |
During App::build() |
Receives the app handle and plugin config |
initialization_script() |
Webview creation | JS to inject before page load |
window_created() |
After window creation | React to new windows |
webview_created() |
After webview creation | React to new webviews |
on_navigation() |
Before navigation | Return false to cancel navigation |
on_page_load() |
Page loaded | React to page load events |
on_event() |
Every event loop tick | Receive RunEvent dispatches |
extend_api() |
IPC dispatch | Handle plugin commands |
sequenceDiagram
participant App as App::build()
participant Plugin as Plugin
participant WV as New Webview
App->>Plugin: initialize(app_handle, config)
Note over Plugin: Setup state, start services
App->>Plugin: initialization_script()
Note over Plugin: Return JS to inject
WV->>Plugin: window_created(window)
WV->>Plugin: webview_created(webview)
WV->>Plugin: on_navigation(webview, url)
WV->>Plugin: on_page_load(webview, payload)
loop Event Loop
App->>Plugin: on_event(app_handle, event)
end
Note over WV: IPC call arrives
WV->>Plugin: extend_api(invoke)
All hooks have default implementations that do nothing, so plugins only need to implement the hooks they care about. The trait requires Send — plugins are stored behind a Mutex and can be accessed from any thread.
Plugin Builder: Fluent Construction API
While you can implement Plugin<R> directly, the Builder provides a more ergonomic way:
pub struct Builder<R: Runtime, C: DeserializeOwned = ()> {
name: &'static str,
invoke_handler: Box<InvokeHandler<R>>,
setup: Option<Box<SetupHook<R, C>>>,
js_init_script: Option<InitializationScript>,
on_navigation: Box<OnNavigation<R>>,
on_page_load: Box<OnPageLoad<R>>,
on_window_ready: Box<OnWindowReady<R>>,
on_webview_ready: Box<OnWebviewReady<R>>,
on_event: Box<OnEvent<R>>,
on_drop: Option<Box<OnDrop<R>>>,
uri_scheme_protocols: HashMap<String, Arc<UriSchemeProtocol<R>>>,
}
The generic C: DeserializeOwned parameter is the plugin's configuration type. When initialize() is called, the framework extracts the plugin's config section from tauri.conf.json (keyed by plugin name) and deserializes it into C, making it available through the PluginApi.
classDiagram
class Builder~R, C~ {
+new(name) Self
+invoke_handler(handler) Self
+setup(hook) Self
+js_init_script(script) Self
+on_navigation(handler) Self
+on_page_load(handler) Self
+on_event(handler) Self
+register_uri_scheme_protocol(name, handler) Self
+build() TauriPlugin~R, C~
}
class TauriPlugin~R, C~ {
-name: &'static str
-app: Option~AppHandle~
-invoke_handler
-setup
// ... all fields from Builder
}
class Plugin~R~ {
<<trait>>
+name() &str
+initialize() Result
+extend_api(Invoke) bool
}
Builder --> TauriPlugin : build()
TauriPlugin ..|> Plugin : implements
The convention is to export an init() function that constructs and returns the plugin:
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("my-plugin")
.invoke_handler(tauri::generate_handler![my_command])
.setup(|app, api| {
// initialization logic
Ok(())
})
.build()
}
PluginStore and Command Namespacing
The PluginStore (in crates/tauri/src/app/plugin.rs) manages all registered plugins. It stores them as trait objects (Box<dyn Plugin<R>>) in a Vec and provides methods for initialization, event dispatch, and command routing.
Name collisions are prevented by the reserved names check:
const RESERVED_PLUGIN_NAMES: &[&str] = &["core", "tauri"];
The Builder::try_build() method checks against this list and returns a BuilderError::ReservedName if a plugin tries to use "core" or "tauri" as its name. This prevents collisions with Tauri's internal plugin namespace.
As we saw in Article 3, plugin commands are namespaced as plugin:{name}|{command}. When an IPC request with this prefix arrives, the framework iterates through registered plugins, finds the one with the matching name, and calls its extend_api() method with the invoke. The extend_api method returns bool — true if it handled the command, false to pass it through.
Core Internal Plugins
Tauri uses its own plugin system to implement core functionality. The event/plugin.rs file shows the event system registered as "core:event" with commands for listen, unlisten, emit, and emit_to.
Similarly, app/plugin.rs exposes app metadata commands like version, name, tauri_version, identifier, plus macOS-specific app_show and app_hide.
The full list of core plugins (registered in App::register_core_plugins()) includes:
| Plugin | Purpose | Commands |
|---|---|---|
core:event |
Event system | listen, unlisten, emit, emit_to |
core:app |
App metadata | version, name, tauri_version, identifier |
core:window |
Window management | create, close, set_title, etc. |
core:webview |
Webview management | create, navigate, eval, etc. |
core:resources |
Resource table | close (drop resources) |
core:menu |
Menu operations | new, set_text, etc. |
core:tray |
Tray icon | new, set_icon, set_tooltip, etc. |
flowchart TB
subgraph "Core Plugins"
EVENT["core:event"]
APP["core:app"]
WINDOW["core:window"]
WEBVIEW["core:webview"]
RES["core:resources"]
MENU["core:menu"]
TRAY["core:tray"]
end
subgraph "User Plugins"
FS["plugin:fs"]
HTTP["plugin:http"]
CUSTOM["plugin:my-plugin"]
end
FRAMEWORK["Tauri Framework"] --> EVENT
FRAMEWORK --> APP
FRAMEWORK --> WINDOW
FRAMEWORK --> WEBVIEW
JS["Frontend JS"] --> |"invoke('plugin:core:event|listen')"| EVENT
JS --> |"invoke('plugin:fs|read_file')"| FS
This "dogfooding" approach is a strong architectural signal — it proves the plugin API is expressive enough for real framework functionality and ensures the API stays well-maintained.
The tauri-plugin Build Helper
External plugins use the tauri-plugin crate in their build.rs to integrate with the ACL system. This crate provides a Builder (separate from the runtime plugin builder) that:
- Reads the plugin's permission definition files
- Generates the ACL manifest describing available permissions and default capabilities
- Autogenerates Rust code for permissions constants
- Produces the JSON schema for the plugin's configuration
This build-time work ensures that when a Tauri app depends on a plugin, the app's own build process can discover all available permissions and include them in the resolved ACL.
Tip: Plugin authors should define granular permissions (e.g.,
fs:allow-read-file,fs:allow-write-file) rather than coarse ones. This gives app developers fine-grained control over what capabilities to grant. Use permission sets (likefs:default) to group common combinations.
Mobile Plugin Bridges
Plugins can extend to Android and iOS through the mobile bridge in crates/tauri/src/plugin/mobile.rs. The PluginHandle type provides a run_mobile_plugin_method() function that serializes the request, invokes the native method (JNI on Android, Swift on iOS), and deserializes the response.
On Android, the android_binding! macro generates the JNI glue code. It creates two JNI functions:
handlePluginResponse— receives responses from Kotlin plugin methodssendChannelData— receives streaming channel data from Kotlin
A global PENDING_PLUGIN_CALLS map tracks in-flight mobile plugin invocations, keyed by a monotonically incrementing ID. When the native side completes, it calls back through JNI with the result, and the pending call's oneshot sender resolves the Rust future.
Example Plugin Walkthrough
The repository includes a sample plugin at examples/api/src-tauri/tauri-plugin-sample/ that demonstrates the full structure. It has:
- A
build.rsusingtauri-plugin's build helper - Permission definition files
- A Rust runtime implementation with
init()→Builder::new("sample")→.build() - Commands registered via
.invoke_handler(tauri::generate_handler![...])
This example serves as the canonical template for how all the pieces fit together — build-time ACL generation, runtime plugin registration, command handling, and security integration.
flowchart TB
BUILD["build.rs<br/>tauri-plugin::Builder"] --> MANIFEST["ACL manifest<br/>(permissions.json)"]
INIT["init() function"] --> BUILDER["plugin::Builder::new('sample')"]
BUILDER --> HANDLER[".invoke_handler(...)"]
HANDLER --> SETUP[".setup(|app, api| {...})"]
SETUP --> PLUGIN[".build() → TauriPlugin"]
PLUGIN --> APP["app.plugin(sample::init())"]
APP --> STORE["PluginStore"]
In the next article, we'll move from the framework layer to the toolchain — how the CLI orchestrates builds, how configuration is resolved, and how the bundler produces platform-specific packages.