home / skills / dchuk / claude-code-tauri-skills / tauri-plugins

tauri-plugins skill

/tauri/tauri-plugins

This skill helps you create and configure Tauri plugins across desktop and mobile, including Rust cores, bindings, and platform specifics.

npx playbooks add skill dchuk/claude-code-tauri-skills --skill tauri-plugins

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.2 KB
---
name: developing-tauri-plugins
description: Guides the user through Tauri plugin development, including creating plugin extensions, configuring permissions, and building mobile plugins for iOS and Android platforms.
---

# 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

```bash
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)

```rust
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

```rust
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)

```rust
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)

```rust
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)

```rust
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)

```rust
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

```rust
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)

```typescript
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)

```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)

```rust
const COMMANDS: &[&str] = &["do_something", "upload"];
fn main() { tauri_plugin::Builder::new(COMMANDS).build(); }
```

### Scoped Permissions

```rust
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)

```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

```kotlin
@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

```kotlin
// 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)

```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

```swift
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

```swift
// 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)

```rust
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_my_plugin::init())
        .run(tauri::generate_context!())
        .expect("error running application");
}
```

### Configure (tauri.conf.json)

```json
{ "plugins": { "my-plugin": { "timeout": 60, "enabled": true } } }
```

### Permissions (capabilities/default.json)

```json
{ "identifier": "default", "windows": ["main"], "permissions": ["my-plugin:default"] }
```

### Frontend Usage

```typescript
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.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()`

## Android 16KB Page Size

For NDK < 28, add to `.cargo/config.toml`:

```toml
[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
```

Overview

This skill guides developers through creating, configuring, and distributing Tauri v2 plugins across desktop and mobile. It covers plugin architecture, Rust crate and JS bindings, mobile implementations for Android and iOS, and permission scoping for safe command execution. Expect practical patterns for lifecycle events, error handling, and platform-specific best practices.

How this skill works

The skill explains how to scaffold a plugin, implement core Rust logic, expose commands as async functions, and publish TypeScript bindings for the webview. It shows how to add optional Android (Kotlin) and iOS (Swift) native modules, wire lifecycle events, and declare permissions and command scopes. Build scripts and configuration examples demonstrate registering and configuring plugins in tauri.conf.json and app code.

When to use it

  • You need reusable native features shared across multiple Tauri apps.
  • You want to expose safe, permissioned commands from Rust to the frontend.
  • You need platform-specific behavior for Android or iOS in the same plugin package.
  • You plan to ship a plugin with both Rust logic and JS/TypeScript bindings.
  • You must restrict plugin capabilities with scoped permissions for security.

Best practices

  • Keep desktop and mobile code separate (desktop.rs, mobile.rs) to avoid platform leakage.
  • Use async for all I/O operations and coroutines on Android for blocking work.
  • Define structured errors with thiserror and implement Serialize to send readable errors to the frontend.
  • Declare explicit permissions and command scopes; validate paths or inputs against scope allows/denies.
  • Clean up FFI resources on iOS and use proper deallocation when returning pointers.

Example use cases

  • Create a filesystem helper plugin that reads files with path-scoped permissions and emits progress events.
  • Implement a camera/photo plugin with Android/iOS permission checks and lifecycle handling.
  • Add a cross-platform encryption plugin: Rust core for crypto, JS bindings for frontend, native helpers for mobile accelerators.
  • Build a background upload plugin that streams progress through Channels and supports mobile-specific networking constraints.

FAQ

How do I expose a command that reports progress to the frontend?

Use a Channel parameter in the command signature, send numeric progress values from Rust, and wire a Channel listener in the TypeScript bindings to handle progress updates.

How do I restrict plugin commands to specific paths or actions?

Declare permission TOML files and use CommandScope in command handlers to check scope.allows()/scope.denies() and validate inputs before performing sensitive operations.