关于 developing-tauri-plugins
面向 Tauri 插件开发者的实践指南,涵盖 Rust 核心、JavaScript 绑定及可选的 Android 和 iOS 实现,并说明权限、生命周期与注册方式。
主要功能
- 01创建插件项目结构
- 02实现 Rust 命令与配置
- 03编写 JavaScript 绑定
- 04定义权限与作用域
- 05添加 Android 和 iOS 实现
- 06处理插件生命周期事件
适用场景
- 01为 Tauri 应用创建插件
- 02为插件添加移动端实现
- 03配置命令权限并注册插件
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ dchuk/ claude-code-tauri-skills --skill developing-tauri-plugins安装后在 Claude Code 中输入/developing-tauri-plugins确认已加载。
详细介绍
开发 Tauri 插件
Tauri 插件通过模块化 Rust crate 扩展应用功能,并可选择提供 JavaScript 绑定和原生移动端实现。
插件架构
完整的插件包括:
- Rust crate(
tauri-plugin-{name})- 核心逻辑 - JavaScript 绑定(
@scope/plugin-{name})- NPM 包 - Android 库(Kotlin)- 可选
- iOS 包(Swift)- 可选
创建插件
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
项目结构
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
插件实现
主插件文件(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()
}
带配置的插件
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.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.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.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.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>;
生命周期事件
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 绑定(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 });
}
插件权限
权限文件(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.rs)
const COMMANDS: &[&str] = &["do_something", "upload"];
fn main() { tauri_plugin::Builder::new(COMMANDS).build(); }
作用域权限
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 插件(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 权限
@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 事件和 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 插件(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 权限
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 事件和 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()
}
使用插件
注册(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");
}
配置(tauri.conf.json)
{ "plugins": { "my-plugin": { "timeout": 60, "enabled": true } } }
权限(capabilities/default.json)
{ "identifier": "default", "windows": ["main"], "permissions": ["my-plugin:default"] }
前端用法
import { doSomething, upload } from '@myorg/plugin-my-plugin';
const result = await doSomething('hello');
await upload('/path/to/file', (p) => console.log(`${p}%`));
最佳实践
- 将平台代码分别放在
desktop.rs和mobile.rs中 - 使用
thiserror进行结构化错误处理 - I/O 操作使用异步;仅请求必要的权限
- Android:命令在主线程上运行,阻塞任务请使用协程
- iOS:正确清理 FFI 资源;使用
invoke.reject()/invoke.resolve()
Android 16KB 页面大小
对于 NDK < 28,在 .cargo/config.toml 中添加:
[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
评论
支持 Markdown