Rust in Everything
基于 tauri 2.12.5

插件

主线位置:启动主线上「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_2None每建一个 webview,prepare_pending_webview 收集一次
window_created(Window)、webview_created(Webview)空窗口、webview 加入管理器之后
on_navigation(&Webview, &Url) -> booltruewebview 每次导航之前,返回 false 取消
on_page_load(&Webview, &PageLoadPayload)空页面开始加载、加载完成
on_event(&AppHandle, &RunEvent)空事件循环的每个 RunEvent,在 run 的回调之前
extend_api(Invoke) -> boolfalse收到 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-icon feature,所以没有 tray。
  • 内置插件被 initialize 了两次:注册时一次,initialize_all 又一次。对 TauriPlugin 来说第二次基本无害:setup 闭包在第一次就被 take() 走了,第二次只是重新记一遍 AppHandle、重新登记一遍 URI scheme 协议。直接实现 Plugin trait、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 不会被调,前面几次运行的输出里都没有这一行。

📝NOTE
冷知识:window_created 为什么要绕一圈 run_on_main_thread

1.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 线程上执行时插件表是空闲的。同步插件命令就不一样了。

❗IMPORTANT
动脑筋:同步插件命令里移除自己,会怎样?

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 前缀源码
pathcore:pathpath/plugin.rs
eventcore:eventevent/plugin.rs(listen、unlisten、emit、emit_to)
windowcore:windowwindow/plugin.rs
webviewcore:webviewwebview/plugin.rs
appcore:appapp/plugin.rs
resourcescore:resourcesresources/plugin.rs(close)
imagecore:imageimage/plugin.rs
menu(桌面)core:menumenu/plugin.rs
tray(tray-icon feature)core:traytray/plugin.rs
__TAURI_CHANNEL__不查 ACLipc/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 线程上执行,插件表锁早已释放。

小结

  • Plugin trait 除 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,都会让主线程死锁。

💬 关联讨论

发起讨论
加载中...