Rust in Everything
基于 tauri 2.12.5

初始化脚本

主线位置:启动主线上「prepare_pending_webview 补协议和初始化脚本 → wry 把脚本变成 WKUserScript」这一步。一次启动已经从页面一侧看到了 __TAURI_INTERNALS__ 的 11 个属性按什么顺序出现;本页从 WKWebView 一侧把实际注入的每一段脚本取出来,逐段对上源码,再看 withGlobalTauri、freezePrototype、插件脚本和用户脚本各插在哪里、对页面有什么可测的效果。

flowchart TB
  wryipc["wry: window.ipc"] --> s1["1 isTauri 与空的 INTERNALS"]
  s1 --> s2["2 ipc-protocol.js 带 invoke key"]
  s2 --> s3["3 metadata 窗口与 webview 标签"]
  s3 --> s4["4 init.js 模板"]
  s4 --> plug["插件脚本 用户插件在前"]
  plug --> iso["isolation 脚本 可选"]
  iso --> glob["bundle.global.js 可选"]
  glob --> user["用户的 initialization_script"]
  user --> zoom["zoom 热键脚本 可选"]
  zoom --> page["页面自己的脚本"]

实验:把 user script 倒出来

页面里看不到初始化脚本的源码,但它们最后都是 WKUserContentController 上的 WKUserScript。实验 crate exp-tauri/wv-init 在 report 命令里用 Webview::with_webview 拿到 WKWebView 指针,借 objc2-web-kit 0.3.2(wry 自己用的那个版本)读出 configuration.userContentController.userScripts,每段写成一个文件,打印注入时机、是否只进主 frame、字节数和第一行:

#[tauri::command]
fn report(webview: Webview, msg: String) {
  println!("REPORT {msg}");
  let out = if webview.config().app.with_global_tauri { "scripts-full" } else { "scripts-default" };
  dump_scripts(&webview, out);
  webview.app_handle().exit(0);
}

fn dump_scripts(webview: &Webview, out_dir: &'static str) {
  let (tx, rx) = std::sync::mpsc::channel();
  webview
    .with_webview(move |pw| {
      // SAFETY: on macOS `inner()` is the WKWebView pointer owned by wry; we only read from it
      // on the main thread, inside `with_webview`.
      let wk: &WKWebView = unsafe { &*(pw.inner() as *const WKWebView) };
      let ucc = unsafe { wk.configuration().userContentController() };
      let scripts = unsafe { ucc.userScripts() };
      // ... 每段写进 out_dir/NN.js,打印一行摘要,经 tx 送回
    })
    .unwrap();
  // ...
}

应用注册了一个只带 js_init_script 的插件 probe,并且不用配置里的窗口,而是在 setup 里自己建一个,加两段用户脚本,一段只进主 frame、一段进所有 frame。三段脚本都往 window.__order 里追加自己的名字:

  let probe = tauri::plugin::Builder::<tauri::Wry>::new("probe")
    .js_init_script("(window.__order = window.__order || []).push('plugin probe');")
    .build();
  tauri::Builder::default()
    .plugin(probe)
    .invoke_handler(tauri::generate_handler![report])
    .setup(|app| {
      WebviewWindowBuilder::new(app, "probe-win", WebviewUrl::App("index.html".into()))
        .visible(false)
        .initialization_script("(window.__order = window.__order || []).push('user main-frame');")
        .initialization_script_for_all_frames(
          "(window.__order = window.__order || []).push('user all-frames ' + (window === top ? 'top' : 'iframe'));",
        )
        .build()?;
      Ok(())
    })

页面里有一个同源 iframe(frame.html),它用 postMessage 把自己看到的东西交给父页面,父页面再连同自己的检查一起 report。同一份代码编成两个二进制:init 用默认配置,init-full 用一份打开了 withGlobalTauri 和 freezePrototype 的配置。平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建,--features tauri/custom-protocol。

init 的输出(REPORT 部分见下文):

#00 start main_only=true     146 B  Object.defineProperty(window, 'ipc', { value: Object.freeze({ postMess
#01 start main_only=true     286 B  Object.defineProperty(window, 'isTauri', {
#02 start main_only=true    4568 B  // Copyright 2019-2024 Tauri Programme within The Commons Conservancy
#03 start main_only=true     237 B  Object.defineProperty(window.__TAURI_INTERNALS__, 'metadata', {
#04 start main_only=true   10080 B  // Copyright 2019-2024 Tauri Programme within The Commons Conservancy
#05 start main_only=true      82 B  (function () { (window.__order = window.__order || []).push('plugin pr
#06 start main_only=true     310 B  (function () { // Copyright 2019-2024 Tauri Programme within The Commo
#07 start main_only=true     642 B  (function () { // Copyright 2019-2024 Tauri Programme within The Commo
#08 start main_only=true    4389 B  (function () { // Copyright 2019-2024 Tauri Programme within The Commo
#09 start main_only=true    1126 B  (function () { // Copyright 2019-2024 Tauri Programme within The Commo
#10 start main_only=true      64 B  (window.__order = window.__order || []).push('user main-frame');
#11 start main_only=false    103 B  (window.__order = window.__order || []).push('user all-frames ' + (win

init-full 的输出只有两处不同:#04 从 10080 字节变成 10254 字节,#09 之后多了一段:

#10 start main_only=true   41101 B  var __TAURI_IIFE__=function(e){"use strict";function n(e,n,t,i){if("a"
#11 start main_only=true      64 B  (window.__order = window.__order || []).push('user main-frame');
#12 start main_only=false    103 B  (window.__order = window.__order || []).push('user all-frames ' + (win

全部是 AtDocumentStart。下面逐段对上源码。

#来源内容
00wry wkwebview/mod.rswindow.ipc.postMessage,调用主线的退路
01prepare_pending_webview 内联window.isTauri,空的 __TAURI_INTERNALS__ = { plugins: {} }
02WebviewManager::invoke_initialization_scriptipc-protocol.js,invoke key 在闭包里
03prepare_pending_webview 内联metadata.currentWindow / currentWebview
04initialization_script(init.js 模板)可选的 freeze_prototype.js、pattern.js、ipc.js、core.js、事件分发函数
05插件 probe用户注册的插件
06–09内置插件 path、event、window、webviewplugins.path、unregisterListener、拖动区域、window.print 和 devtools 热键
10(full)bundle.global.jswindow.__TAURI__
末两段WebviewWindowBuilder用户脚本

wry 的那一段

#00 不是 tauri 加的。wry 在 macOS 上建好 WKWebView 后,只要设了 IPC 处理函数,就先插一段自己的脚本,再按顺序插调用方给的脚本(wkwebview/mod.rs 第 692 行起的这一段):

      if w.ipc_handler_delegate.is_some() {
        // Initialize scripts
        w.init(
          r#"Object.defineProperty(window, 'ipc', { value: Object.freeze({ postMessage: function(s) { window.webkit.messageHandlers.ipc.postMessage(s) } }) });"#,
          true,
        );
      }
      if !shares_controller {
        for init_script in attributes.initialization_scripts {
          w.init(&init_script.script, init_script.for_main_frame_only);
        }
      }

init 把每段包成 WKUserScript,注入时机固定为 AtDocumentStart,for_main_only 原样传下去。tauri 总是设 IPC 处理函数(prepare_webview 末尾的 ipc_handler),所以 window.ipc 总在第一个。wry 这一层的细节见 wry 在 macOS 上。

tauri 的四段

prepare_pending_webview 按 #01–#04 的顺序推进一个 Vec<InitializationScript>,每段都经过一个本地小函数 main_frame_script,for_main_frame_only 固定为 true。前三段的源码和作用 一次启动 已经逐段列过,这里只补两点:

  • #01 用 if (!window.__TAURI_INTERNALS__) 包住,所以页面不论是不是第一次导航,这个对象都由它创建,后面几段只往上加属性。
  • #03 里的两个标签是 serde_json::to_string 出来的字面量,建 webview 时写死。脚本加进 WKUserContentController 之后就一直留在那里,此后每次顶层导航都原样再执行一遍(initialization_script 的文档也提醒了这一点:导航到别的源也会执行,要自己检查 window.location)。所以 reparent 把 webview 挪到别的窗口之后,页面里的 currentWindow.label 不会变,重新加载也还是旧值。

#04 是 initialization_script 渲染 init.js 模板的结果:

;(function () {
  __RAW_freeze_prototype__

  __RAW_pattern_script__

  __RAW_ipc_script__

  __RAW_core_script__

  __RAW_event_initialization_script__
})()

五个 __RAW_*__ 位置原样拼进别的模板的渲染结果:freezePrototype 为真时是 freeze_prototype.js,否则空串;pattern.js 定义 __TAURI_PATTERN__;ipc.js 定义 __TAURI_INTERNALS__.ipc(brownfield 时转给 postMessage,isolation 时发给隔离 iframe);core.js 定义 convertFileSrc、回调表和 invoke;最后是 event_initialization_script 生成的事件分发函数,函数名是 __internal_unstable_listeners_function_id__。core.js 自己还是一个模板,三个参数 os_name、protocol_scheme、invoke_key 在这里填进去(invoke_key 在 core.js 里其实没被用到;invoke key 真正用在 #02,见 JS 侧:invoke 与回调)。

init-full 的 #04 多出的 174 字节就是第一个位置:

<   
---
>   // Copyright 2019-2024 Tauri Programme within The Commons Conservancy
> // SPDX-License-Identifier: Apache-2.0
> // SPDX-License-Identifier: MIT
...
> Object.freeze(Object.prototype)

(diff scripts-default/04.js scripts-full/04.js 的输出,许可证注释也一起拼进来了。)

#04 之后,同一个函数依次追加插件脚本、isolation 模式的脚本、withGlobalTauri 的全局脚本,最后把用户在 builder 上设的脚本接在后面:

    all_initialization_scripts.extend(plugin_init_scripts);

    #[cfg(feature = "isolation")]
    if let crate::Pattern::Isolation { schema, .. } = &*app_manager.pattern {
      all_initialization_scripts.push(main_frame_script(
        // ...
      ));
    }

    if let Some(plugin_global_api_scripts) = &*app_manager.plugin_global_api_scripts {
      for &script in plugin_global_api_scripts.iter() {
        all_initialization_scripts.push(main_frame_script(script.to_owned()));
      }
    }

    // Prepend `all_initialization_scripts` to `webview_attributes.initialization_scripts`
    all_initialization_scripts.extend(webview_attributes.initialization_scripts);
    webview_attributes.initialization_scripts = all_initialization_scripts;

isolation 脚本做什么见 CSP、原型冻结与隔离模式。

插件脚本

插件的 js_init_script 由 PluginStore::initialization_script 收集,每段包进一个函数:

      .map(
        |InitializationScript {
           script,
           for_main_frame_only,
         }| InitializationScript {
          script: format!("(function () {{ {script} }})();"),
          for_main_frame_only,
        },
      )

这就是 #05 开头那个 (function () { 的来历:插件脚本里的 var、function 声明不会漏到 window 上。插件表是一个 Vec,按注册顺序遍历;Builder::plugin 注册的插件在 Builder::build 时就进了表,内置插件是 build 后面 register_core_plugins 才按 path、event、window、webview、app、resources、image、menu 的顺序加的,所以用户插件的脚本反而排在内置插件前面。九个内置插件里有四个带脚本:#06 往 __TAURI_INTERNALS__.plugins.path 上放路径分隔符,#07 定义 __TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener,#08 实现 data-tauri-drag-region 拖动窗口,#09 把 window.print 改成调 plugin:webview|print,再加 devtools 快捷键。

plugin::Builder::js_init_script 固定 for_main_frame_only: true;要进所有 frame 得用 js_init_script_on_all_frames。插件钩子本身见插件。

withGlobalTauri

init-full 的页面报回来的 window.__TAURI__:

 "typeof___TAURI__": "object",
 "__TAURI__keys": [
  "app",
  "core",
  "dpi",
  "event",
  "image",
  "menu",
  "mocks",
  "path",
  "tray",
  "webview",
  "webviewWindow",
  "window"
 ],

这是 @tauri-apps/api 打包成的一个 41 101 字节的 IIFE(crates/tauri/scripts/bundle.global.js),挂到 window.__TAURI__ 上。它不在 init.js 里,而是作为一段独立脚本排在插件脚本之后、用户脚本之前。它的路径怎么经构建脚本链传到 generate_context!,见源码地图和 tauri-build;generate_context! 只在 withGlobalTauri 为真时把文件内容读进 Context.plugin_global_api_scripts,否则是 None,二进制里也就没有这 41 KB。

📝NOTE
冷知识:2.4.0 之前这 41 KB 每个应用都带着

早先 bundle.global.js 是 init.js 模板里的一个位置 __RAW_bundle_script__,initialization_script 用 include_str!("../../scripts/bundle.global.js") 把它编进 tauri crate,withGlobalTauri 为假时才填空串。也就是说脚本只在需要时注入,但字节总在二进制里。PR #13033(「fix: don't ship global api bundle if withGlobalTauri is false」,首次发布于 tauri 2.4.0)删掉了这个位置,改由 tauri 的 build.rs 把路径交给构建脚本链、generate_context! 按配置决定要不要读进来,也就是上面那段 plugin_global_api_scripts 循环。CHANGELOG:「Don't ship global bundle.global.js if app > withGlobalTauri is set to false」,变更类型标的是 patch:perf。

freezePrototype

#04 里冻结原型这一行在 IIFE 的第一句,所以它之后的一切(pattern.js 起的 tauri 代码、插件脚本、全局 API、用户脚本、页面脚本)都在冻结后的 Object.prototype 上跑;#00–#03 在它之前。两个二进制的页面做了同样几件事:

const info = {
  // ...
  protoFrozen: Object.isFrozen(Object.prototype),
};
try { (function () { 'use strict'; Object.prototype.polluted = 1; })(); info.strictProtoWrite = 'ok'; }
catch (e) { info.strictProtoWrite = `${e.name}: ${e.message}`; }
Object.prototype.sloppy = 1;
info.sloppyProtoWrite = String(({}).sloppy);
const inv = ti.invoke;
ti.invoke = null;
info.invokeReassigned = ti.invoke !== inv ? 'changed' : 'unchanged';
info.deleteInvoke = delete ti.invoke;
try { (function () { 'use strict'; ti.invoke = null; })(); info.strictInvokeWrite = 'ok'; }
catch (e) { info.strictInvokeWrite = `${e.name}: ${e.message}`; }
init:
 "protoFrozen": false,
 "strictProtoWrite": "ok",
 "sloppyProtoWrite": "1",
 "invokeReassigned": "unchanged",
 "deleteInvoke": false,
 "strictInvokeWrite": "TypeError: Attempted to assign to readonly property.",
init-full:
 "protoFrozen": true,
 "strictProtoWrite": "TypeError: Attempting to define property on object that is not extensible.",
 "sloppyProtoWrite": "undefined",
 "invokeReassigned": "unchanged",
 "deleteInvoke": false,
 "strictInvokeWrite": "TypeError: Attempted to assign to readonly property.",

(两段 REPORT 里摘出来的行,前面的 init: / init-full: 是标注。)

  • 不开 freezePrototype 时原型照常可写。开了之后,严格模式下写原型抛 TypeError,非严格模式下静默失败(({}).sloppy 是 undefined)。
  • __TAURI_INTERNALS__.invoke 两种配置下都改不掉、删不掉:它是 Object.defineProperty 只给 value 定义的,writable、configurable 默认都是 false。这和 freezePrototype 无关;freezePrototype 防的是另一件事,即往所有对象共享的原型上加属性(原型污染),影响 tauri 自己代码里的对象字面量。
  • 全局 API 打包脚本在冻结之后执行,__TAURI__ 照样建出来了;配置文档提醒的是另一类代码:往内置原型上加方法的 polyfill 会在开了之后失效。

它防的具体是什么、和 CSP、isolation 怎么配合,见 CSP、原型冻结与隔离模式。

用户脚本与 iframe

init 的页面报回来的执行顺序和 iframe 里看到的东西:

 "order": [
  "plugin probe",
  "user main-frame",
  "user all-frames top"
 ],
...
 "iframe": {
  "href": "tauri://localhost/frame.html",
  "typeof___TAURI_INTERNALS__": "undefined",
  "typeof_ipc": "undefined",
  "order": [
   "user all-frames iframe"
  ]
 }
  • 执行顺序就是 user script 的注册顺序:插件 → 用户主 frame → 用户所有 frame,都在页面第一个 <script> 之前。
  • iframe 是同源的(tauri://localhost/frame.html),但里面既没有 __TAURI_INTERNALS__,也没有 wry 的 window.ipc:tauri 和 wry 的脚本全都 for_main_frame_only: true。只有用 initialization_script_for_all_frames 加的那段进了 iframe。想在 iframe 里调命令,iframe 自己没有任何入口;IPC 请求从哪个 frame 发、Rust 侧怎么看 Origin,见 ipc 协议。

WebviewBuilder::initialization_script 的文档说明它在「global object 创建之后、HTML 解析之前」执行;在 macOS 上这就是 WKUserScriptInjectionTime::AtDocumentStart。

还有一段实验里没出现的:prepare_webview 在 prepare_pending_webview 返回之后,如果 zoom_hotkeys_enabled 为真(默认假),再往最后追加一段 zoom-hotkey.js,按 Cmd/Ctrl 加减号时调 plugin:webview|set_webview_zoom。这段只在桌面、非 Windows 编译。

❗IMPORTANT
动脑筋:同一个页面里,主 frame 和同源 iframe 各能看到几段初始化脚本的效果?iframe 里 typeof window.ipc 是什么?

用上面的 init 二进制:一个插件脚本、一段主 frame 用户脚本、一段所有 frame 用户脚本。平台 M2 Max、macOS 27,tauri 2.12.5,wry 0.57.1。

在 Windows 和 Linux 上

以下只读了源码和文档,没有实测。tauri 这一层(prepare_pending_webview)三个平台完全一样;差别在 wry 怎么落实 for_main_frame_only。wry 0.57.1 的 with_initialization_script_for_main_only 文档写着:「Windows: scripts are always added to subframes regardless of the for_main_frame_only option.」也就是说在 WebView2 上,上面那张表里的每一段,包括带 invoke key 的 #02,都会进 iframe。Linux 的 WebKitGTK 有对应的 UserContentInjectedFrames 设置,wry 按 for_main_frame_only 选 TopFrame 或 AllFrames。

动脑筋答案

init 的 REPORT 里对应的几行就是上面「用户脚本与 iframe」一节引用的输出:

  • 主 frame:__order 有三项(插件 probe、两段用户脚本),__TAURI_INTERNALS__ 和 window.ipc 都在,页面正是靠它们 report 的。
  • iframe:只执行了 #11 一段,__order 是 ["user all-frames iframe"];typeof window.ipc 是 "undefined",typeof window.__TAURI_INTERNALS__ 也是 "undefined"。wry 的 window.ipc 脚本调 init(.., true),tauri 的四段和插件脚本都是 main_frame_script,只有显式要求所有 frame 的那段例外。

小结

  • 一个 tauri webview 上的 user script 依次是:wry 的 window.ipc、tauri 的四段(isTauri 和空对象、ipc-protocol.js、metadata、init.js)、插件脚本(用户插件在内置插件前)、可选的 isolation 脚本和 bundle.global.js、用户脚本,最后是可选的 zoom 热键脚本;全部 AtDocumentStart。
  • init.js 把 freeze_prototype.js、pattern.js、ipc.js、core.js、事件分发函数拼进一个 IIFE;冻结原型是第一句,所以它管得到之后的所有代码。
  • 插件脚本各自包在 (function () { ... })(); 里;四个内置插件带脚本。
  • withGlobalTauri 加一段 41 101 字节的独立脚本;2.4.0 起不开就不进二进制。
  • 在 macOS 上 tauri 和 wry 的脚本都只进主 frame,同源 iframe 里没有 __TAURI_INTERNALS__;wry 文档说 Windows 上一律进子 frame。

💬 关联讨论

发起讨论
加载中...