developing-tauri-plugins
Developing Tauri Plugins
Tauri plugins extend application functionality through modular Rust crates with optional JavaScript bindings and native mobile implementations.
Plugin Architecture
A complete plugin includes:
- 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 # Basic
npx @tauri-apps/cli plugin new my-plugin --android --ios # With mobile
npx @tauri-apps/cli plugin android add # Add to existing
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>;
Lifecycle Events
Builder::new("my-plugin")
.setup(|app, api| { Ok(()) }) // Plugin init
.on_navigation(|window, url| url.scheme() != "dangerous") // Block nav
.on_webview_ready(|window| {}) // Window created
.on_event(|app, event| { match event { tauri::RunEvent::Exit => {} _ => {} }})
.on_drop(|app| {}) // Cleanup
.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); }
// Read file...
}
Android Plugin (Kotlin)
package com.example.myplugin
import android.app.Activity
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@InvokeArg
class DoSomethingArgs {
lateinit var value: String // Required
var optional: String? = null // Optional
var withDefault: Int = 42 // Default value
}
@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 { // Use IO for blocking ops
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
// Emit event
trigger("dataReceived", JSObject().apply { put("data", "value") })
// Lifecycle
override fun onNewIntent(intent: Intent) {
trigger("newIntent", JSObject().apply { put("action", intent.action) })
}
// Call Rust via JNI
companion object { init { System.loadLibrary("my_plugin") } }
external fun processData(input: String): String // Java_com_example_myplugin_MyPlugin_processData
iOS Plugin (Swift)
import SwiftRs
import Tauri
import UIKit
class DoSomethingArgs: Decodable {
let value: String // Required
var optional: String? // Optional
}
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
// Emit event
trigger("dataReceived", data: ["data": "value"])
// Call Rust via FFI
@_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 (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 (tauri.conf.json)
{ "plugins": { "my-plugin": { "timeout": 60, "enabled": true } } }
Permissions (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}%`));
Best Practices
- Separate platform code in
desktop.rsandmobile.rs - Use
thiserrorfor 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()
Android 16KB Page Size
For NDK < 28, add to .cargo/config.toml:
[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
More from beshkenadze/claude-code-tauri-skills
distributing-tauri-for-ios
Guides users through distributing Tauri applications to the iOS App Store, including Apple Developer enrollment, Xcode configuration, provisioning profiles, code signing, TestFlight beta testing, and App Store submission processes.
5setting-up-tauri-projects
Helps users create and initialize new Tauri v2 projects for building cross-platform desktop and mobile applications. Covers system prerequisites and setup requirements for macOS, Windows, and Linux. Guides through project creation using create-tauri-app or manual Tauri CLI initialization. Explains project directory structure and configuration files. Supports vanilla JavaScript, TypeScript, React, Vue, Svelte, Angular, SolidJS, and Rust-based frontends.
3understanding-tauri-ecosystem-security
Guides developers through Tauri ecosystem security practices including security auditing, dependency management, vulnerability reporting, and organizational security measures for building secure desktop applications.
3packaging-tauri-for-linux
Guides users through packaging Tauri v2 applications for Linux distributions including AppImage, Debian (.deb), RPM, Flatpak, Snap, and AUR submission.
3distributing-tauri-for-android
Guides the user through distributing Tauri applications for Android, including Google Play Store submission, APK and AAB generation, build configuration, signing setup, and version management.
3migrating-tauri-apps
Assists users with migrating Tauri applications from v1 to v2 stable, and from v2 beta to v2 stable, covering breaking changes, configuration updates, API migrations, and plugin system changes.
2