Read OSS

Extending Tauri: The Plugin Architecture and Extension Model

Advanced

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 booltrue 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:

  1. Reads the plugin's permission definition files
  2. Generates the ACL manifest describing available permissions and default capabilities
  3. Autogenerates Rust code for permissions constants
  4. 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 (like fs: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 methods
  • sendChannelData — 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.rs using tauri-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.