Agent skill
developing-tauri-plugins
Guides the user through Tauri plugin development, including creating plugin extensions, configuring permissions, and building mobile plugins for iOS and Android platforms.
Install this agent skill to your Project
npx add-skill https://github.com/dchuk/claude-code-tauri-skills/tree/main/tauri/tauri-plugins
SKILL.md
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"]
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
understanding-tauri-ipc
Teaches the assistant about Tauri IPC (Inter-Process Communication) patterns including brownfield and isolation approaches for secure message passing between frontend and Rust backend.
testing-tauri-apps
Guides developers through testing Tauri applications including unit testing with mock runtime, mocking Tauri APIs, WebDriver end-to-end testing with Selenium and WebdriverIO, and CI integration with GitHub Actions.
migrating-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.
managing-tauri-plugin-permissions
Guides users through configuring Tauri plugin permissions, capabilities, and security. Covers platform-specific capabilities, window-targeted permissions, using official and community plugin permissions, and writing custom plugin permissions with scopes.
optimizing-tauri-binary-size
Guides users through Tauri binary size optimization techniques to produce small, efficient desktop application bundles using Cargo profile settings and build configurations.
debugging-tauri-apps
Helps users debug Tauri v2 applications across VS Code, RustRover, IntelliJ, and Neovim. Covers console debugging, WebView DevTools, Rust backtrace, CrabNebula DevTools integration, and IDE-specific launch configurations.
Didn't find tool you were looking for?