插件
主线位置:启动主线上「
Builder::build… 注册插件」和「prepare_pending_webview补上…插件脚本」两步;调用主线上「plugin:前缀走插件的处理函数」那个分叉。分流之前的 invoke key 和 ACL 检查在 on_message 与分发,插件脚本在全部初始化脚本里的位置在 初始化脚本。
sequenceDiagram participant B as Builder participant S as PluginStore participant P as 插件 participant E as 事件循环 participant W as webview B->>S: register 同名替换 Note over S: build 末尾 initialize_all S->>P: initialize 即 setup E->>S: Ready 时建窗口 S->>P: on_window_ready, on_webview_ready W->>S: 导航 S->>P: on_navigation 返回 bool W->>S: 页面加载 S->>P: on_page_load W->>S: invoke plugin:probe|ping S->>P: extend_api 即 invoke_handler E->>S: 每个 RunEvent S->>P: on_event
源码在 plugin.rs(1062 行,其中移动端的 plugin/mobile.rs 本卷不讲)。实验程序和平台同 Builder、App 与 AppHandle:M2 Max、macOS 27、rustc 1.99.0,tauri 2.12.5,真实 Wry 运行时,隐藏窗口;ACL 的拒绝信息分 debug 和 release 两种,下文分别给出。
Plugin trait 和 plugin::Builder
Plugin 是一个 Send 的 trait,除了 name 每个方法都有空的默认实现:
| 方法 | 默认 | 什么时候被调 |
|---|---|---|
name(&self) -> &'static str | 必须实现 | 当键用:配置 plugins.<name>、命令前缀 plugin:<name>|、同名替换 |
initialize(&mut self, app, config: JsonValue) | Ok(()) | 注册时(见下文的顺序) |
initialization_script / initialization_script_2 | None | 每建一个 webview,prepare_pending_webview 收集一次 |
window_created(Window)、webview_created(Webview) | 空 | 窗口、webview 加入管理器之后 |
on_navigation(&Webview, &Url) -> bool | true | webview 每次导航之前,返回 false 取消 |
on_page_load(&Webview, &PageLoadPayload) | 空 | 页面开始加载、加载完成 |
on_event(&AppHandle, &RunEvent) | 空 | 事件循环的每个 RunEvent,在 run 的回调之前 |
extend_api(Invoke) -> bool | false | 收到 plugin:<name>|.. 的 invoke |
extend_api 和 initialization_script_2 名字别扭,源码里各有一条「TODO: Change … in v3」。
几乎没有人直接实现这个 trait,都用 plugin::Builder:它把每个钩子存成一个 boxed 闭包,build 出一个 TauriPlugin,后者实现 Plugin 时逐个转调这些闭包。名字和 trait 方法的对应:setup → initialize,js_init_script → initialization_script,on_window_ready → window_created,on_webview_ready → webview_created,invoke_handler → extend_api,其余同名。多出来的只有三个:
on_drop:TauriPlugin的Drop里调,参数是初始化时记下的AppHandle;从没初始化过的插件被 drop 时不调。register_uri_scheme_protocol/register_asynchronous_uri_scheme_protocol:在initialize里登记到AppManager.webview.uri_scheme_protocols。文档写明了限制:协议在建 webview 时注册,插件晚于 webview 加进来,这个协议在已有的 webview 里就不存在。- 类型参数
C:插件配置的类型。initialize拿到的是tauri.conf.json里plugins.<name>那一段 JSON(没有就是null),TauriPlugin用serde_json::from_value转成C,失败时报「Error deserializing 'plugins.{name}' within your Tauri configuration: {err}」,被build包成Error::PluginInitialization。
try_build 只检查一件事:名字不能是 RESERVED_PLUGIN_NAMES = &["core", "tauri"] 之一,否则返回 BuilderError::ReservedName(build 会 expect("valid plugin"))。core 是内置插件权限标识符的前缀(core:window:allow-close),被占了就分不清。
实验里的插件 probe 把能挂的钩子都挂上了:
fn probe<R: Runtime>() -> TauriPlugin<R> {
PluginBuilder::new("probe")
.setup(|_app, _api| {
log("plugin setup");
// ... NESTED 时注册另一个插件,见下文
Ok(())
})
.js_init_script("window.__PROBE_INIT__ = location.href;")
.invoke_handler(tauri::generate_handler![ping, ping_async, unload])
.on_window_ready(|w| log(format!("plugin on_window_ready {}", w.label())))
.on_webview_ready(|w| log(format!("plugin on_webview_ready {}", w.label())))
.on_navigation(|_w, url| {
let allow = url.host_str() != Some("127.0.0.1");
log(format!("plugin on_navigation {url} -> {allow}"));
allow
})
.on_page_load(|_w, p| log(format!("plugin on_page_load {:?} {}", p.event(), p.url())))
.on_event(|_app, e| match e {
RunEvent::MainEventsCleared => {}
e => log(format!("plugin on_event {}", short(e))),
})
.on_drop(|_app| log("plugin on_drop"))
.build()
}
PluginStore:一个 Vec,同名替换
pub(crate) struct PluginStore<R: Runtime> {
store: Vec<Box<dyn Plugin<R>>>,
}
pub fn register(&mut self, plugin: Box<dyn Plugin<R>>) -> bool {
let len = self.store.len();
self.store.retain(|p| p.name() != plugin.name());
let result = len != self.store.len();
self.store.push(plugin);
result
}
PluginStore 是按注册顺序排列的 Vec,所有钩子都按这个顺序依次调用。register 先删掉同名的旧插件再追加,返回值表示「是否替换了」,但 Builder::plugin_boxed 和 AppHandle::plugin_boxed 都没看它。所以同一个插件注册两次不报错,后一个生效。实验设 DOUBLE 时 Builder 上连着 .plugin(probe()) 两次,输出里 plugin setup 只有一行:
0ms [main] main
96ms [main] plugin setup
96ms [main] Builder::build returned
被替换掉的那个从没初始化,on_drop 也不会被调。Builder::build 的文档说插件初始化失败时 build 返回错误,「which is also the case when a plugin is registered twice」,在 tauri 本体里看不到对应的检查;只有插件自己在 setup 里发现重复并返回 Err 时才会这样。
整个 PluginStore 在 AppManager 里包在一个 Mutex 里,下文所有钩子都在持有这把锁时被调用。
注册和初始化的顺序
用户插件和内置插件走两条不同的路:
Builder::plugin只register,不初始化。build里的register_core_plugins和ipc::channel::plugin()用的是AppHandle::plugin_boxed:锁住插件表,先initialize,再register。build最后initialize_plugins锁住插件表,对表里每一个插件调initialize。
打开 tauri 的 tracing feature,用 tracing-subscriber 打印 app::* 和 plugin::hooks::* 这些 span 的创建(span 的字段只留 name),build 期间是这样的:
INFO main app::build: new
INFO main app::build:app::core_plugins::register{}: new
INFO main app::build:app::core_plugins::register{}:app::plugin::register{name="path"}: new
INFO main app::build:app::core_plugins::register{}:app::plugin::register{name="path"}:plugin::hooks::initialize{name="path"}: new
INFO main app::build:app::core_plugins::register{}:app::plugin::register{name="event"}: new
INFO main app::build:app::core_plugins::register{}:app::plugin::register{name="event"}:plugin::hooks::initialize{name="event"}: new
...
INFO main app::build:app::core_plugins::register{}:app::plugin::register{name="menu"}:plugin::hooks::initialize{name="menu"}: new
INFO main app::build:app::plugin::register{name="__TAURI_CHANNEL__"}: new
INFO main app::build:app::plugin::register{name="__TAURI_CHANNEL__"}:plugin::hooks::initialize{name="__TAURI_CHANNEL__"}: new
INFO main app::build:plugin::hooks::initialize{name="probe"}: new
105ms [main] plugin setup
INFO main app::build:plugin::hooks::initialize{name="path"}: new
INFO main app::build:plugin::hooks::initialize{name="event"}: new
INFO main app::build:plugin::hooks::initialize{name="window"}: new
INFO main app::build:plugin::hooks::initialize{name="webview"}: new
INFO main app::build:plugin::hooks::initialize{name="app"}: new
INFO main app::build:plugin::hooks::initialize{name="resources"}: new
INFO main app::build:plugin::hooks::initialize{name="image"}: new
INFO main app::build:plugin::hooks::initialize{name="menu"}: new
INFO main app::build:plugin::hooks::initialize{name="__TAURI_CHANNEL__"}: new
105ms [main] Builder::build returned
- 表里的顺序是
probe、path、event、window、webview、app、resources、image、menu、__TAURI_CHANNEL__:用户插件在Builder里就进了表,排在最前面;内置插件后来追加。之后每个钩子的 span 也都是这个顺序。这次构建没开tray-iconfeature,所以没有tray。 - 内置插件被
initialize了两次:注册时一次,initialize_all又一次。对TauriPlugin来说第二次基本无害:setup闭包在第一次就被take()走了,第二次只是重新记一遍AppHandle、重新登记一遍 URI scheme 协议。直接实现Plugintrait、initialize不幂等的插件如果走同样的路(在build里经AppHandle::plugin注册),就会出问题;目前只有内置插件这样注册。 - 插件
setup在build里、AppManager已经建好之后、窗口建出来之前跑,所以setup里可以manage状态、listen事件,拿不到窗口。
js_init_script:每个 webview 都注入一遍
js_init_script 存成 InitializationScript { script, for_main_frame_only: true }(js_init_script_on_all_frames 则是 false)。每建一个 webview,prepare_pending_webview 锁住插件表,调 PluginStore::initialization_script 收集所有插件的脚本:
.map(
|InitializationScript {
script,
for_main_frame_only,
}| InitializationScript {
script: format!("(function () {{ {script} }})();"),
for_main_frame_only,
},
)
每段脚本被包进一个立即执行的函数,所以插件脚本里的顶层 const、function 不会漏到全局,要暴露东西只能显式写 window.xxx = ..。这些脚本排在 tauri 自己的 __TAURI_INTERNALS__、ipc-protocol.js、事件脚本之后,在 webview 自己的初始化脚本之前,见 初始化脚本。实验页面在 <script> 一开头读 typeof window.__PROBE_INIT__,送回来是 js_init_script seen at parse: string:插件脚本在页面任何脚本之前就跑完了。
插件晚于某个 webview 注册时(比如在 setup 之后用 AppHandle::plugin 加进来),已经存在的 webview 不会补上它的脚本,这和 URI scheme 协议的限制一样。
插件命令:plugin:<name>|<cmd>
JS 调插件命令时,命令名写成 plugin:<插件名>|<命令名>:
await t('plugin:probe|ping', () => I.invoke('plugin:probe|ping'));
await t('plugin:probe|ping_async', () => I.invoke('plugin:probe|ping_async'));
Webview::on_message 剥掉 plugin: 前缀、按 | 切成插件名和命令名,把 invoke.message.command 改成不带前缀的 ping,交给 AppManager::run_plugin_invoke_handler,后者锁住插件表,调 PluginStore::run_invoke_handler:
pub(crate) fn run_invoke_handler(&mut self, plugin: &str, invoke: Invoke<R>) -> bool {
for p in self.store.iter_mut() {
if p.name() == plugin {
#[cfg(feature = "tracing")]
let _span = tracing::trace_span!("plugin::hooks::ipc", name = plugin).entered();
return p.extend_api(invoke);
}
}
invoke.resolver.reject(format!("plugin {plugin} not found"));
true
}
插件存在,就把 Invoke 交给它的 extend_api,也就是 generate_handler! 生成的那个按命令名 match 的闭包;返回 false(插件在、命令不在)时,on_message 拒绝成 Command ping not found。插件不存在则拒绝成 plugin xxx not found。插件命令和应用命令用同一个 #[tauri::command]、同一个 generate_handler!,参数和返回值的处理完全一样,见 #[command] 与 generate_handler!。
插件命令一律要过 ACL
在走到 run_invoke_handler 之前,on_message 有一个条件:命令是插件命令、或者应用有自己的 ACL 清单、或者来源是远程页面,三者有其一就必须在 ACL 里查到允许才放行(细节见 运行时检查)。插件命令总是满足第一条,所以没有能力文件授权的插件命令一定被拒绝,哪怕页面是本地的。
实验应用一开始没有 capabilities/ 目录。debug 构建的输出:
plugin:probe|ping err: probe.ping not allowed. Permissions associated with this command: probe:allow-ping
plugin:probe|ping_async err: probe.ping_async not allowed. Permissions associated with this command: probe:allow-ping-async
plugin:event|emit err: event.emit not allowed. Permissions associated with this command: core:event:allow-emit, core:event:default
同一份代码 --release 构建:
plugin:probe|ping err: Command plugin:probe|ping not allowed by ACL
plugin:probe|ping_async err: Command plugin:probe|ping_async not allowed by ACL
plugin:event|emit err: Command plugin:event|emit not allowed by ACL
debug 版列出了「哪些权限能放行这个命令」,release 版只有一句话。同一次运行里应用自己的命令 sync_cmd、async_cmd 都成功了:应用没有 ACL 清单、页面是本地的,三个条件都不满足。
probe:allow-ping 这个权限从哪来?probe 是写在应用里的插件,没有自己的 crate,权限由应用的 build.rs 声明:
fn main() {
let attributes = tauri_build::Attributes::new().plugin(
"probe",
tauri_build::InlinedPlugin::new().commands(&["ping", "ping_async", "unload"]),
);
tauri_build::try_build(attributes).expect("failed to run tauri-build");
}
tauri-build 为每个命令生成一对 allow-<cmd> / deny-<cmd>,命令名里的下划线变成连字符:gen/schemas/acl-manifests.json 里 probe 下面是 allow-ping、allow-ping-async、allow-unload 和对应的三个 deny-*,default_permission 为 null(没有 default 权限集,所以写 probe:default 不行)。再加一个能力文件:
{
"identifier": "default",
"windows": ["main"],
"permissions": ["probe:allow-ping", "probe:allow-ping-async", "probe:allow-unload", "core:event:default", "core:resources:allow-close"]
}
重新构建后:
plugin:probe|ping ok: "pong"
plugin:probe|ping_async ok: "pong"
plugin:event|emit ok: null
独立 crate 的插件走另一条路:插件自己的 build.rs 调 tauri_plugin::Builder::new(COMMANDS).build(),它要求 Cargo.toml 里有 links(CARGO_MANIFEST_LINKS,缺了报 LinksMissing),在 permissions/autogenerated/ 下生成命令权限,再用 cargo:PERMISSION_FILES_PATH=.. 把权限文件清单的路径发给依赖它的 crate;应用的 tauri-build 从环境变量 DEP_<LINKS>_PERMISSION_FILES_PATH 读到它们,汇进应用的 ACL 清单。这一段在 tauri-build,权限、能力的数据结构在 权限、能力、作用域。
on_navigation:第一个 false 就取消
prepare_pending_webview 把 webview 的导航回调包了一层:
pending.navigation_handler = Some(Box::new(move |url| {
// always allow navigation events for the isolation iframe and do not emit them for consumers
#[cfg(feature = "isolation")]
if let crate::Pattern::Isolation { schema, .. } = &*pattern {
if url.scheme() == schema
&& url.domain() == Some(crate::pattern::ISOLATION_IFRAME_SRC_DOMAIN)
{
return true;
}
}
if let Some(handler) = &navigation_handler {
if !handler(url) {
return false;
}
}
let webview = app_manager.webview.webviews_lock().get(&label).cloned();
if let Some(w) = webview {
app_manager
.plugins
.lock()
.expect("poisoned plugin store")
.on_navigation(&w, url)
} else {
true
}
}));
顺序是:隔离模式的 iframe 一律放行、不通知任何人;然后是 WebviewBuilder::on_navigation 设的回调;最后按注册顺序问每个插件,PluginStore::on_navigation 遇到第一个 false 就返回 false,后面的插件不再被问。
实验页面最后执行 location.href = 'http://127.0.0.1:18930/blocked'(这个端口上没有服务),probe 对 127.0.0.1 返回 false:
439ms [main] plugin on_navigation tauri://localhost -> true
...
1028ms [main] plugin on_navigation http://127.0.0.1:18930/blocked -> false
...
after navigation attempt: tauri://localhost
第一行是 webview 建好后加载应用本身;第二行的导航被取消,300 ms 后 location.href 还是 tauri://localhost,页面上的 JS 继续执行,最后的 report 照常送达。导航在发出请求之前就被拦下,这个地址从没被访问。
其他钩子
on_window_ready/on_webview_ready:WindowManager::attach_window、WebviewManager::attach_webview把窗口、webview 放进表之后,用run_on_main_thread派发window_created/webview_created。on_page_load:先调Builder::on_page_load设的应用级回调,再按顺序调插件;Started、Finished各一次(见 Builder、App 与 AppHandle 的日志)。on_event:on_event_loop_event在把事件交给run的回调之前调。ExitRequested的api在插件里也能用,插件可以替应用拦下退出。on_drop:AppHandle::remove_plugin从表里删掉插件,插件随即被 drop。实验MODE=drop在report里remove_plugin("probe"):
893ms [main] remove_plugin(probe)
893ms [main] plugin on_drop
893ms [main] remove_plugin returned true
893ms [main] app run callback ExitRequested { code: Some(0) }
on_drop 在 remove_plugin 返回之前同步执行;之后的 ExitRequested 只剩 app run callback,没有 plugin on_event 了。正常退出时(run 结束进程)插件不会被 drop,on_drop 不会被调,前面几次运行的输出里都没有这一行。
window_created 为什么要绕一圈 run_on_main_thread1.0.0 的 WindowManager 在把窗口放进表之后,直接在当前线程锁住插件表、调 created(window)。PR #4462(「fix(core): deadlock on plugin webview ready hook」)把它改成 window.run_on_main_thread(move || { manager.plugins.lock()...created(window_) }),并加了注释「run on main thread so the plugin store doesn't dead lock with the event loop handler in App」。CHANGELOG 的说法是「Fixes deadlock when a plugin window ready event needs to block the thread waiting on the event loop.」:窗口在别的线程上建出来时,钩子在那个线程上持着插件表锁、又同步等主线程回话;主线程这时正好在处理一个事件,要锁插件表去调各插件的 on_event,两边互相等。挪到主线程上执行后,钩子和 on_event 在同一个线程上先后执行,钩子里的同步调用也直接在主线程上完成,不再需要等。2.12.5 的 attach_window、attach_webview 里还是这一段,连注释都一样。第一个包含它的 tag 是 tauri-v1.0.1。
持锁调用的代价
上面每个钩子、每次插件命令分发,都在 AppManager.plugins 这把 std::sync::Mutex 里执行。Mutex 不可重入,钩子或同步插件命令里任何再次锁插件表的调用都会卡死当前线程。会锁插件表的公开 API 有 AppHandle::plugin、AppHandle::remove_plugin,以及间接触发钩子的操作。
实验设 NESTED 时,probe 的 setup 里注册另一个插件:
if std::env::var("NESTED").is_ok() {
log("plugin setup registers another plugin");
_app.plugin(PluginBuilder::<R>::new("inner").build())?;
log("nested plugin registered");
}
0ms [main] main
98ms [main] plugin setup
98ms [main] plugin setup registers another plugin
TIMEOUT
exit=2
setup 是 build 末尾 initialize_all 在持锁时调的,AppHandle::plugin 又去锁同一把锁,主线程永远等下去,15 秒后看门狗结束进程。Builder::setup(应用的那个)不在锁里,在那里注册插件没有问题。
async 插件命令不受影响:extend_api 只是把 future 交给 async_runtime::spawn 就返回,锁随即释放,命令体在 tokio 线程上执行时插件表是空闲的。同步插件命令就不一样了。
probe 有一个同步命令:
#[tauri::command]
fn unload<R: Runtime>(app: tauri::AppHandle<R>) {
log("plugin command unload -> remove_plugin(probe)");
app.remove_plugin("probe");
log("unreachable?");
}
能力里有 probe:allow-unload。MODE=deadlock 时,页面一加载就 invoke('plugin:probe|unload')。unreachable? 会打印出来吗?进程怎么结束?
内置插件
register_core_plugins 注册的就是 JS API 背后的那些命令:
| 插件名 | ACL 前缀 | 源码 |
|---|---|---|
path | core:path | path/plugin.rs |
event | core:event | event/plugin.rs(listen、unlisten、emit、emit_to) |
window | core:window | window/plugin.rs |
webview | core:webview | webview/plugin.rs |
app | core:app | app/plugin.rs |
resources | core:resources | resources/plugin.rs(close) |
image | core:image | image/plugin.rs |
menu(桌面) | core:menu | menu/plugin.rs |
tray(tray-icon feature) | core:tray | tray/plugin.rs |
__TAURI_CHANNEL__ | 不查 ACL | ipc/channel.rs,只有一个 fetch 命令 |
它们和用户插件是同一种 TauriPlugin,JS 侧调用时也是 plugin:window|..、plugin:event|listen。ACL 前缀多一个 core:,是因为 tauri crate 自己的 build.rs 在定义这些插件的权限时就把它们写成 core:path、core:event 这样的名字(它的 PLUGINS 表)。__TAURI_CHANNEL__ 的 fetch 被 on_message 特判跳过 ACL(源码注释「TODO: Remove this special check in v3」),用来取 Channel 的大消息,见 Channel。core:default 一次性给出这些插件的默认权限。
动脑筋答案
...
382ms [main] plugin on_navigation tauri://localhost -> true
389ms [main] app on_page_load Started main
389ms [main] plugin on_page_load Started tauri://localhost
390ms [main] app on_page_load Finished main
390ms [main] plugin on_page_load Finished tauri://localhost
452ms [main] plugin command unload -> remove_plugin(probe)
TIMEOUT
exit=2
不会打印。unload 是同步命令,在主线程上、在 run_plugin_invoke_handler 持有插件表锁的时候执行;remove_plugin 再锁同一把 Mutex,主线程卡死。事件循环停了,页面、窗口都不再响应,report 永远不会被调用,进程只能被看门狗线程在 15 秒后 exit(2) 结束。把 unload 改成 async fn 就没有这个问题:那时 remove_plugin 在 tokio 线程上执行,插件表锁早已释放。
小结
Plugintrait 除name外都有默认实现;plugin::Builder把每个钩子存成闭包,build出TauriPlugin。名字不能是core或tauri;配置来自plugins.<name>,按类型参数C反序列化。PluginStore是按注册顺序的Vec,同名注册静默替换。用户插件在build末尾统一初始化;内置插件注册时初始化一次、initialize_all又一次。插件setup早于窗口。js_init_script被包成 IIFE,在每个新 webview 的页面脚本之前执行;晚注册的插件不会补到已有 webview。on_navigation第一个false即取消。plugin:<name>|<cmd>由on_message剥前缀后交给对应插件的extend_api;插件命令必须由能力授权,否则 debug 下报「x.y not allowed. Permissions associated with this command: ..」,release 下报Command plugin:x|y not allowed by ACL。应用内的插件用tauri_build::InlinedPlugin声明命令,权限名里下划线变连字符。- 所有钩子和插件命令分发都持有插件表的
Mutex:插件setup里注册插件、同步插件命令里remove_plugin,都会让主线程死锁。