| name | developing-tauri-plugins |
| description | Scaffolds Tauri v2 plugins with `tauri plugin new`, implements Rust commands with desktop/mobile split, configures plugin permissions in TOML, writes Kotlin and Swift native bindings for Android and iOS. Use when creating or extending Tauri plugins, configuring plugin permissions, writing platform-specific native code, or building Tauri plugins for mobile platforms. |
Developing Tauri Plugins
Workflow
- Scaffold plugin —
tauri plugin new
- Implement Rust commands — desktop/mobile split in
commands.rs, desktop.rs, mobile.rs
- Add permissions — define TOML permission files and
build.rs
- Write JS bindings — TypeScript API in
guest-js/index.ts
- Add mobile code (optional) — Kotlin for Android, Swift for iOS
- Register and test — wire into
tauri::Builder, verify from frontend
Plugin Architecture
- Rust crate (
tauri-plugin-{name}) — core logic
- JavaScript bindings (
@scope/plugin-{name}) — NPM package
- Android library (Kotlin) — optional
- iOS package (Swift) — optional
Creating a Plugin
npx @tauri-apps/cli plugin new my-plugin
npx @tauri-apps/cli plugin new my-plugin --android --ios
npx @tauri-apps/cli plugin android add
npx @tauri-apps/cli plugin ios add
Project Structure
tauri-plugin-my-plugin/
├── src/
│ ├── lib.rs, commands.rs, desktop.rs, mobile.rs, error.rs
├── permissions/ # Permission TOML files
├── guest-js/index.ts # TypeScript API
├── android/, ios/ # Native mobile code
├── build.rs, Cargo.toml
Plugin Implementation
Main Plugin File (lib.rs)
use tauri::{plugin::{Builder, TauriPlugin}, Manager, Runtime};
mod commands;
mod error;
pub use error::{Error, Result};
#[cfg(desktop)] mod desktop;
#[cfg(mobile)] mod mobile;
#[cfg(desktop)] use desktop::MyPlugin;
#[cfg(mobile)] use mobile::MyPlugin;
pub struct MyPluginState<R: Runtime>(pub MyPlugin<R>);
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("my-plugin")
.invoke_handler(tauri::generate_handler![commands::do_something])
.setup(|app, api| {
app.manage(MyPluginState(MyPlugin::new(app, api)?));
Ok(())
})
.build()
}
Plugin with Configuration
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config { pub timeout: Option<u64>, pub enabled: bool }
pub fn init<R: Runtime>() -> TauriPlugin<R, Config> {
Builder::<R, Config>::new("my-plugin")
.setup(|app, api| { let config = api.config(); Ok(()) })
.build()
}
Commands (commands.rs)
use tauri::{command, ipc::Channel, Runtime, State};
use crate::{MyPluginState, Result};
#[command]
pub async fn do_something<R: Runtime>(
state: State<'_, MyPluginState<R>>, input: String,
) -> Result<String> {
state.0.do_something(input).await
}
#[command]
pub async fn upload<R: Runtime>(path: String, on_progress: Channel<u32>) -> Result<()> {
for i in 0..=100 { on_progress.send(i)?; }
Ok(())
}
Desktop Implementation (desktop.rs)
use tauri::{AppHandle, Runtime};
use crate::Result;
pub struct MyPlugin<R: Runtime> { app: AppHandle<R> }
impl<R: Runtime> MyPlugin<R> {
pub fn new(app: &AppHandle<R>, _api: tauri::plugin::PluginApi<R, ()>) -> Result<Self> {
Ok(Self { app: app.clone() })
}
pub async fn do_something(&self, input: String) -> Result<String> {
Ok(format!("Desktop: {}", input))
}
}
Mobile Implementation (mobile.rs)
use tauri::{AppHandle, Runtime};
use serde::{Deserialize, Serialize};
use crate::Result;
#[derive(Serialize)] struct MobileRequest { value: String }
#[derive(Deserialize)] struct MobileResponse { result: String }
pub struct MyPlugin<R: Runtime> { app: AppHandle<R> }
impl<R: Runtime> MyPlugin<R> {
pub fn new(app: &AppHandle<R>, _api: tauri::plugin::PluginApi<R, ()>) -> Result<Self> {
Ok(Self { app: app.clone() })
}
pub async fn do_something(&self, input: String) -> Result<String> {
let response: MobileResponse = self.app
.run_mobile_plugin("doSomething", MobileRequest { value: input })
.map_err(|e| crate::Error::Mobile(e.to_string()))?;
Ok(response.result)
}
}
Error Handling (error.rs)
use serde::{Serialize, Serializer};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("IO error: {0}")] Io(#[from] std::io::Error),
#[error("Mobile error: {0}")] Mobile(String),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where S: Serializer { serializer.serialize_str(self.to_string().as_str()) }
}
pub type Result<T> = std::result::Result<T, Error>;
Verify: Run cargo build in the plugin crate directory. The Rust plugin code should compile without errors before proceeding to permissions.
Lifecycle Events
Builder::new("my-plugin")
.setup(|app, api| { Ok(()) })
.on_navigation(|window, url| url.scheme() != "dangerous")
.on_webview_ready(|window| {})
.on_event(|app, event| { match event { tauri::RunEvent::Exit => {} _ => {} } })
.on_drop(|app| {})
.build()
JavaScript Bindings (guest-js/index.ts)
import { invoke, Channel } from '@tauri-apps/api/core';
export async function doSomething(input: string): Promise<string> {
return invoke('plugin:my-plugin|do_something', { input });
}
export async function upload(path: string, onProgress: (p: number) => void): Promise<void> {
const channel = new Channel<number>();
channel.onmessage = onProgress;
return invoke('plugin:my-plugin|upload', { path, onProgress: channel });
}
Plugin Permissions
Permission File (permissions/default.toml)
[default]
description = "Default permissions"
permissions = ["allow-do-something"]
[[permission]]
identifier = "allow-do-something"
description = "Allows do_something command"
commands.allow = ["do_something"]
[[permission]]
identifier = "allow-upload"
description = "Allows upload command"
commands.allow = ["upload"]
[[set]]
identifier = "full-access"
description = "Full plugin access"
permissions = ["allow-do-something", "allow-upload"]
Build Script (build.rs)
const COMMANDS: &[&str] = &["do_something", "upload"];
fn main() { tauri_plugin::Builder::new(COMMANDS).build(); }
Scoped Permissions
use tauri::ipc::CommandScope;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct PathScope { pub path: String }
#[command]
pub async fn read_file(path: String, scope: CommandScope<'_, PathScope>) -> Result<String> {
let allowed = scope.allows().iter().any(|s| path.starts_with(&s.path));
let denied = scope.denies().iter().any(|s| path.starts_with(&s.path));
if denied || !allowed { return Err(Error::PermissionDenied); }
}
Verify: Run cargo tauri build in the host application. Permissions should auto-generate without errors. Check that build.rs lists all command names.
Android Plugin (Kotlin)
package com.example.myplugin
import android.app.Activity
import app.tauri.annotation.*
import app.tauri.plugin.*
import kotlinx.coroutines.*
@InvokeArg
class DoSomethingArgs {
lateinit var value: String
var optional: String? = null
var withDefault: Int = 42
}
@TauriPlugin
class MyPlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun doSomething(invoke: Invoke) {
val args = invoke.parseArgs(DoSomethingArgs::class.java)
CoroutineScope(Dispatchers.IO).launch {
try {
invoke.resolve(JSObject().apply { put("result", "Android: ${args.value}") })
} catch (e: Exception) { invoke.reject(e.message) }
}
}
}
Android Permissions
@TauriPlugin(permissions = [
Permission(strings = [android.Manifest.permission.CAMERA], alias = "camera")
])
class MyPlugin(private val activity: Activity) : Plugin(activity) {
@Command override fun checkPermissions(invoke: Invoke) { super.checkPermissions(invoke) }
@Command override fun requestPermissions(invoke: Invoke) { super.requestPermissions(invoke) }
}
Android Events, JNI & Page Size
trigger("dataReceived", JSObject().apply { put("data", "value") })
override fun onNewIntent(intent: Intent) {
trigger("newIntent", JSObject().apply { put("action", intent.action) })
}
companion object { init { System.loadLibrary("my_plugin") } }
external fun processData(input: String): String
16KB page size (NDK < 28): Add to .cargo/config.toml:
[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
iOS Plugin (Swift)
import SwiftRs
import Tauri
import UIKit
class DoSomethingArgs: Decodable {
let value: String
var optional: String?
}
class MyPlugin: Plugin {
@objc public func doSomething(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(DoSomethingArgs.self)
invoke.resolve(["result": "iOS: \(args.value)"])
}
}
@_cdecl("init_plugin_my_plugin")
func initPlugin() -> Plugin { return MyPlugin() }
iOS Permissions
import AVFoundation
class MyPlugin: Plugin {
@objc override func checkPermissions(_ invoke: Invoke) {
var result: [String: String] = [:]
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: result["camera"] = "granted"
case .denied, .restricted: result["camera"] = "denied"
default: result["camera"] = "prompt"
}
invoke.resolve(result)
}
@objc override func requestPermissions(_ invoke: Invoke) {
AVCaptureDevice.requestAccess(for: .video) { _ in self.checkPermissions(invoke) }
}
}
iOS Events & FFI
trigger("dataReceived", data: ["data": "value"])
@_silgen_name("process_data_ffi")
private static func processDataFFI(_ input: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>?
@objc public func hybrid(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(DoSomethingArgs.self)
guard let ptr = MyPlugin.processDataFFI(args.value) else { invoke.reject("FFI failed"); return }
invoke.resolve(["result": String(cString: ptr)])
ptr.deallocate()
}
Using the Plugin
Register in src-tauri/src/lib.rs:
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_my_plugin::init())
.run(tauri::generate_context!())
.expect("error running application");
}
Configure in tauri.conf.json:
{ "plugins": { "my-plugin": { "timeout": 60, "enabled": true } } }
Permissions in capabilities/default.json:
{ "identifier": "default", "windows": ["main"], "permissions": ["my-plugin:default"] }
Frontend usage:
import { doSomething, upload } from '@myorg/plugin-my-plugin';
const result = await doSomething('hello');
await upload('/path/to/file', (p) => console.log(`${p}%`));
Testing and Verification
- Rust compilation:
cargo build in the plugin crate — confirms commands, types, and error handling compile
- Permission generation:
cargo tauri build in the host app — confirms build.rs generates permission files
- Frontend invocation: Call an exported function from JavaScript and confirm the response:
const result = await doSomething('test');
console.assert(result !== undefined, 'Plugin command returned a result');
- Mobile round-trip (if applicable): Run on an Android emulator or iOS simulator and invoke a command that hits native code via
run_mobile_plugin
Best Practices
- Separate platform code in
desktop.rs and mobile.rs
- Use
thiserror for structured error handling
- Use async for I/O operations; request only necessary permissions
- Android: Commands run on main thread — use coroutines for blocking work
- iOS: Clean up FFI resources properly; use
invoke.reject()/invoke.resolve()