Plugin Structure
A detailed breakdown of every folder and file in a Tauri v2 plugin project, what each part does, and how they fit together.
A Tauri plugin is a Cargo crate combined with an NPM package. When you run npx @tauri-apps/cli plugin new or tauri plugin init, you get a project with a fixed layout. Understanding that layout is the first step to building, maintaining, or debugging a custom plugin. Installing Plugins covers adding an existing plugin; this page is the layout of one you write yourself.
The generated tree (without optional mobile targets) looks like this:
tauri-plugin-{name}/
├── src/ # Rust source code
│ ├── commands.rs # Command functions callable from the frontend
│ ├── desktop.rs # Desktop-specific implementation
│ ├── error.rs # Custom error type for command results
│ ├── lib.rs # Plugin entry point, re-exports, setup
│ ├── mobile.rs # Mobile-specific implementation
│ └── models.rs # Shared data structures
├── permissions/ # Auto-generated permission files
├── guest-js/ # JavaScript/TypeScript API bindings source
├── dist-js/ # Transpiled JavaScript bindings
├── Cargo.toml # Rust crate metadata
└── package.json # NPM package metadata
If you enable Android or iOS support with --android / --ios, you’ll also see android/ and ios/ directories containing Kotlin and Swift code respectively. Every piece in this tree has a specific job.
The Rust Crate (src/)
All native logic lives inside src/. The Cargo crate is named tauri-plugin-{your-plugin-name}, following Tauri’s naming convention so the CLI and community tooling can discover it. The Cargo.toml declares the tauri dependency (with plugin feature enabled) and any other crates your plugin needs.
lib.rs — Plugin entry point
This file ties the entire crate together. It conditionally re‑exports the correct implementation for the target platform (desktop or mobile) and exposes the init() function that consumers call to register the plugin with their app.
// src/lib.rs
mod commands;
mod desktop;
mod error;
mod mobile;
mod models;
pub use desktop::*; // re-export desktop module as the default on desktop targets
pub use mobile::*; // re-export mobile module on mobile targets
pub use error::*;
use tauri::plugin::{Builder, TauriPlugin};
use tauri::Runtime;
// Struct that holds plugin configuration read from tauri.conf.json
#[derive(serde::Deserialize)]
pub struct Config {
pub timeout: usize,
}
impl Default for Config {
fn default() -> Self {
Self { timeout: 30 }
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R, Config> {
Builder::<R, Config>::new("my-plugin")
.setup(|app, api| {
let timeout = api.config().timeout;
// manage state, register mobile plugins, etc.
Ok(())
})
.build()
}
The Config struct is what the plugin reads from the user’s tauri.conf.json under the plugins key. If you don’t need configuration you can use Builder::<R, Option<Config>>::new(...).
commands.rs — Frontend-callable functions
Any Rust function you want the web frontend to invoke goes here. Each function is annotated with #[command] and typically returns a Result that uses the custom error type from error.rs.
// src/commands.rs
use tauri::{command, AppHandle, Runtime, State};
use crate::error::Error;
#[command]
pub async fn greet<R: Runtime>(
app: AppHandle<R>,
name: String,
) -> Result<String, Error> {
// do something with app handle, access managed state, etc.
Ok(format!("Hello, {}!", name))
}
Commands are not automatically available:
Commands only become callable after the plugin registers them via Builder::invoke_handler(tauri::generate_handler![...]). That registration happens in lib.rs (or inside setup()).
desktop.rs and mobile.rs — Platform-specific code
These files contain the same public API but with different implementations for desktop and mobile. When the plugin’s user runs on Windows/macOS/Linux, the desktop module is used; on Android/iOS, mobile is picked instead.
The generated template usually exports a struct (named after the plugin in PascalCase) and an extension trait so that users can call plugin methods from Rust anywhere they have an AppHandle or Window.
// src/desktop.rs (simplified)
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::MyData;
pub struct MyPlugin<R: Runtime> {
inner: PluginApi<R, ()>,
}
pub trait MyPluginExt<R: Runtime> {
fn my_plugin(&self) -> &MyPlugin<R>;
}
impl<R: Runtime, T: tauri::Manager<R>> MyPluginExt<R> for T {
fn my_plugin(&self) -> &MyPlugin<R> {
self.state::<MyPlugin<R>>().inner()
}
}
impl<R: Runtime> MyPlugin<R> {
pub fn do_work(&self, app: &AppHandle<R>, input: MyData) -> Result<String, crate::Error> {
// desktop-only implementation
Ok(format!("processed on desktop: {:?}", input))
}
}
The mobile counterpart (mobile.rs) has the same signature but can call into platform-specific Kotlin or Swift code through Tauri’s mobile bridge. The PluginApi ensures that the correct implementation is compiled and linked.
error.rs — Centralised error handling
Commands should return meaningful errors to the frontend. This file defines an enum that implements std::error::Error and Serialize, so it can be sent across the IPC boundary.
// src/error.rs
use serde::Serialize;
#[derive(Debug, thiserror::Error, Serialize)]
pub enum Error {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("internal error: {0}")]
Internal(String),
}
models.rs — Shared data types
Any struct or enum used in command parameters or responses goes here. This keeps data shapes consistent and prevents circular dependencies between commands.rs, desktop.rs, and mobile.rs.
// src/models.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct MyData {
pub id: u32,
pub content: String,
}
JavaScript API Bindings (guest-js and dist-js)
The guest-js/ folder contains TypeScript source code that wraps the Rust commands and makes them callable from the frontend (React, in this case) using invoke. The build step compiles this code into dist-js/, which is the folder referenced by the package.json main field.
A typical binding for our greet command looks like this:
// guest-js/index.ts
import { invoke } from "@tauri-apps/api/core";
export async function greet(name: string): Promise<string> {
return invoke("plugin:my-plugin|greet", { name });
}
The command name follows the pattern plugin:{plugin-name}|{command-name}. This is how Tauri’s IPC layer routes the call to the correct plugin.
During development you run npm run build inside the plugin project to re‑compile the guest JS and produce the dist-js/ output. The NPM package then exports that compiled code so that end users can simply import { greet } from "@scope/plugin-my-plugin" in their React components.
Outdated JavaScript bindings break the frontend:
If you change a command signature (parameters, return type) but forget to rebuild the guest JS, the frontend will receive shape mismatches or serialisation errors at runtime. Always re‑build the NPM package after altering a command’s contract.
Permissions (permissions/)
Tauri v2 uses a capability‑based security model. Every command your plugin exposes must have a corresponding permission identifier, and the consuming application must explicitly grant that permission in a capability file (e.g., src-tauri/capabilities/default.json).
The permissions/ folder is meant to hold generated or hand‑written permission sets. When you initialise a plugin, you can provide a default permission set. For example, you might define a default.toml that bundles all allow-* permissions for your commands. How those files are granted in an app is covered in Permission Configuration.
Users of your plugin then add an entry in their capability file like:
{
"identifier": "main-capability",
"windows": ["main"],
"permissions": [
"my-plugin:allow-greet"
]
}
Without this step, every command invocation from the frontend will be blocked with a “not allowed” error. The permission system is strict by design — no command is callable unless explicitly allowed.
Permission errors are a common first-run frustration:
A “my-plugin.greet not allowed” message almost always means the capability file is missing the corresponding permission string. This is separate from the Rust command registration; the CLI and build scripts do not connect these two worlds automatically.
The build.rs Manifest Registration
One piece that is not visible in the default tree but is required for a plugin to work in Tauri v2 is the compile‑time manifest registration. The consuming application must tell the Tauri build system about the plugin’s existence so that permissions and capabilities are compiled into the app binary.
This is done inside the application’s src-tauri/build.rs:
fn main() {
tauri_build::try_build(
tauri_build::Attributes::new()
.plugin(
"my-plugin",
tauri_build::InlinedPlugin::new().commands(&["greet"]),
),
)
.expect("failed to run tauri-build");
}
If this registration is missing, the plugin will not be recognised at runtime even if its Cargo crate is listed in Cargo.toml and the init() is called in lib.rs. The error you’ll see is something like “Plugin not found”.
A missing build.rs plugin registration causes silent failures:
The application may compile and start, but all plugin commands will return “plugin not found” or “not allowed”. Always ensure build.rs includes a plugin(...) entry for every plugin you use.
Mobile Extensions (android/ and ios/)
When you add --android or --ios during plugin initialisation, the CLI generates native library projects. These contain Kotlin (Android) and Swift (iOS) source files that can implement the native behaviour called from mobile.rs.
The plugin still exposes a single Rust API; the mobile‑specific modules bridge the gap between Rust and the platform SDKs. The generated template includes a sample command that demonstrates how to call a Kotlin function from Rust, giving you a starting point for integrating camera, sensors, or any other native API.
How Everything Connects at Runtime
- The frontend (React app) imports the JavaScript binding and calls
greet("Tauri"). - The binding calls
invoke("plugin:my-plugin|greet", { name: "Tauri" }). - Tauri’s IPC layer checks the capability file to ensure the permission
my-plugin:allow-greetis granted for the current window. - If allowed, the call is routed to the Rust command in
commands.rs. - The command runs, possibly using managed state from
setup()or calling platform‑specific functions fromdesktop.rs/mobile.rs. - The result is serialised and returned to the frontend, where the JavaScript binding resolves the promise with the string.
You’ve got the full picture:
Once you can trace a frontend call through every layer — JS binding, permission check, Rust command, platform implementation — you understand the plugin structure completely. That mental model is all you need to start building your own plugins.
Common Structural Mistakes
- Forgetting to export the command in
lib.rs: Even ifcommands.rsdefines a#[command], it must be included in thegenerate_handler!call insideinit(). - Not matching the command name in the JavaScript binding: The command name must be exactly
plugin:{name}|{function}. A mismatch causes an “unknown command” error. - Skipping the capability file: The plugin will build and register, but the frontend will never be able to call it.
- Confusing
package.jsonnamewith the Cargo crate name: The NPM package name is typically@scope/plugin-{name}(ortauri-plugin-{name}-api), while the Rust crate istauri-plugin-{name}. They must be consistent enough that users can map the import to the correct plugin.