初始化脚本
主线位置:启动主线上「
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。下面逐段对上源码。
| # | 来源 | 内容 |
|---|---|---|
| 00 | wry wkwebview/mod.rs | window.ipc.postMessage,调用主线的退路 |
| 01 | prepare_pending_webview 内联 | window.isTauri,空的 __TAURI_INTERNALS__ = { plugins: {} } |
| 02 | WebviewManager::invoke_initialization_script | ipc-protocol.js,invoke key 在闭包里 |
| 03 | prepare_pending_webview 内联 | metadata.currentWindow / currentWebview |
| 04 | initialization_script(init.js 模板) | 可选的 freeze_prototype.js、pattern.js、ipc.js、core.js、事件分发函数 |
| 05 | 插件 probe | 用户注册的插件 |
| 06–09 | 内置插件 path、event、window、webview | plugins.path、unregisterListener、拖动区域、window.print 和 devtools 热键 |
| 10(full) | bundle.global.js | window.__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。
早先 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 编译。
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。