一次启动
主线位置:启动主线从头到尾。本页不深入任何一层,只把「一个窗口加载出页面」经过的函数串起来:先跑一个打满日志的实验,看每一步发生在哪个线程、相隔多久;再按时间顺序逐步走一遍,每一段末尾链到详细讲它的那一章。
sequenceDiagram participant M as main 线程 participant T as tao 事件循环 participant TA as tauri participant W as wry participant K as WebKit participant R as tokio 线程 M->>TA: generate_context 取 Context M->>TA: Builder::build TA->>T: Wry::new 建 EventLoop M->>T: App::run T->>TA: StartCause Init 即 Ready TA->>TA: setup 按配置建窗口 TA->>W: create_webview 带协议和脚本 W->>K: 建 WKWebView 并 loadRequest TA->>TA: 用户 setup 钩子 和 Ready 回调 K->>W: 请求 tauri://localhost W->>R: tauri 协议 spawn 取资源 R-->>K: index.html K->>K: 先跑初始化脚本 再跑页面脚本
| 步骤 | 文件 · 函数 | 关键的一两行 | 详见 |
|---|---|---|---|
1. 编译期生成 Context | tauri-macros/src/context.rs · generate_context | dev: cfg!(not(feature = "custom-protocol")) | generate_context! |
2. 建 Builder,生成 invoke key | app.rs · Builder::new | let invoke_key = crate::generate_invoke_key().unwrap(); | Builder 与 App |
3. build:管理器、事件循环、核心插件 | app.rs · Builder::build;tauri-runtime-wry · Wry::new | let mut runtime = R::new(runtime_args)?; | Builder 与 App、tauri-runtime-wry |
4. 进事件循环,第一个事件是 Ready | tauri-runtime-wry · handle_event_loop;tao · AppState::launched | Event::NewEvents(StartCause::Init) => { callback(RunEvent::Ready); } | tao |
5. Ready 时建配置里的窗口,再跑用户的 setup | app.rs · setup | WebviewWindowBuilder::from_config(app.handle(), window_config)?.build()?; | Builder 与 App |
| 6. 补协议和初始化脚本 | manager/webview.rs · prepare_pending_webview | pending.register_uri_scheme_protocol("tauri", move |webview_id, request, responder| { | 初始化脚本 |
| 7. 交给 wry 建 WKWebView | tauri-runtime-wry · create_webview;wry · new_ns_view | config.setURLSchemeHandler_forURLScheme( | tauri-runtime-wry、wry 在 macOS 上 |
8. 请求 tauri://localhost,在 tokio 线程上取资源 | protocol/tauri.rs · get;manager/mod.rs · get_asset | crate::async_runtime::spawn(async move { | tauri:// 协议 |
9. 页面脚本执行时 __TAURI_INTERNALS__ 已经在了 | wry · init | WKUserScriptInjectionTime::AtDocumentStart | 初始化脚本 |
实验:给每一步打上时间和线程
模板是 tauri 卷首页说的那个隐藏窗口应用,打开 tauri 的 tracing feature。tauri、tauri-runtime-wry 和 wry 在启动、建窗口、自定义协议、IPC 上都有 span;为了让输出短,实验自己写了一个很小的 tracing layer,每个 span 进入时打一行 >、退出时打一行 <、事件打一行 *,行首是距进程启动的毫秒数和线程名,字段只留 uri、cmd、request 这几个(layer 的代码只是格式化,从略)。main.rs 在关键位置自己也打一行(exp:):
mod trace;
use tauri::{Manager, RunEvent};
use tracing_subscriber::prelude::*;
fn step(what: &str) {
tracing::info!(target: "exp", "{what}");
}
#[tauri::command]
fn report(app: tauri::AppHandle, msg: String) {
step("command report");
println!("REPORT {msg}");
app.exit(0);
}
fn main() {
trace::init();
tracing_subscriber::registry().with(trace::Line).init();
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(15));
eprintln!("TIMEOUT");
std::process::exit(2);
});
step("main");
let context = tauri::generate_context!();
let keys: Vec<String> = context.assets().iter().map(|(k, v)| format!("{k} ({} B)", v.len())).collect();
step(&format!("generate_context! done, assets = {keys:?}, csp = {:?}", context.config().app.security.csp.as_ref().map(|c| c.to_string())));
let builder = tauri::Builder::default()
.invoke_handler(tauri::generate_handler![report])
.setup(|app| {
step(&format!("setup hook, windows = {:?}", app.webview_windows().keys().collect::<Vec<_>>()));
Ok(())
})
.on_page_load(|w, p| step(&format!("on_page_load {:?} {} ({})", p.event(), p.url(), w.label())));
step("Builder ready, calling build");
let app = builder.build(context).expect("build");
step(&format!("build returned, windows = {:?}", app.webview_windows().keys().collect::<Vec<_>>()));
let mut cleared = 0u32;
app.run(move |_h, e| match e {
RunEvent::MainEventsCleared => cleared += 1,
RunEvent::Ready => step("RunEvent::Ready"),
RunEvent::ExitRequested { .. } => step("RunEvent::ExitRequested"),
RunEvent::Exit => step(&format!("RunEvent::Exit (MainEventsCleared x{cleared})")),
RunEvent::WindowEvent { label, event, .. } => step(&format!("WindowEvent {label} {event:?}")),
RunEvent::WebviewEvent { label, event, .. } => step(&format!("WebviewEvent {label} {event:?}")),
other => step(&format!("{other:?}")),
});
}
report 里还有一段只在设了 LSOF 环境变量时才执行的检查,tauri 解决什么问题用到它,这次运行没设,上面从略。
dist/index.html 里的脚本在第一行就记下当时的状态,等 load 之后再 fetch 一次自己的地址看响应头,最后通过 report 交回 Rust:
<!doctype html><html><head><meta charset="utf-8"></head><body><p id="out">loading</p>
<script>
const t0 = performance.now().toFixed(1);
const ti = window.__TAURI_INTERNALS__;
const info = {
t_script_ms: t0,
readyState: document.readyState,
href: location.href,
isTauri: window.isTauri,
typeof_ipc: typeof window.ipc,
typeof___TAURI__: typeof window.__TAURI__,
internals: Object.getOwnPropertyNames(ti),
plugins: Object.keys(ti.plugins),
metadata: ti.metadata,
pattern: ti.__TAURI_PATTERN__,
};
window.addEventListener('load', async () => {
info.t_load_ms = performance.now().toFixed(1);
const r = await fetch(location.href);
info.index_headers = Object.fromEntries(r.headers.entries());
await ti.invoke('report', { msg: JSON.stringify(info, null, 1) });
});
</script></body></html>
tauri.conf.json 只有一个隐藏窗口,没有 CSP。debug 构建、--features tauri/custom-protocol,M2 Max、macOS 27、rustc 1.99.0。输出(去掉了 tao 打出来的 validAttributesForMarkedText、viewDidMoveToWindow 之类的 trace 行和 emit 内部的两层 span,用 ... 标出;时间每次运行略有不同):
0.160ms [main] * exp: main
0.689ms [main] * exp: generate_context! done, assets = ["/index.html (507 B)"], csp = None
0.788ms [main] * exp: Builder ready, calling build
0.821ms [main] > app::build
70.447ms [main] > app::core_plugins::register
70.682ms [main] < app::core_plugins::register
77.872ms [main] < app::build
77.896ms [main] * exp: build returned, windows = []
169.241ms [main] * log: Triggered `applicationDidFinishLaunching`
170.801ms [main] > app::setup
170.852ms [main] > webview::create
171.063ms [main] > wry::webview::create
171.075ms [main] > wry::window::draw
171.083ms [main] > wry::window::create
171.100ms [main] * log: Creating new window
...
211.401ms [main] < wry::window::create
211.451ms [main] * log: web content process terminated
317.325ms [main] < wry::webview::create
317.545ms [main] > app::emit
317.601ms [main] < app::emit
317.615ms [main] > app::emit
317.659ms [main] < app::emit
317.675ms [main] < webview::create
317.713ms [main] * exp: setup hook, windows = ["main"]
317.719ms [main] < app::setup
317.726ms [main] * exp: RunEvent::Ready
317.736ms [main] * log: Completed `applicationDidFinishLaunching`
348.967ms [main] < wry::window::draw
376.334ms [main] > wry::custom_protocol::handle
376.400ms [main] = wry::custom_protocol::handle uri="tauri://localhost"
376.472ms [main] > wry::custom_protocol::call_handler
376.744ms [main] < wry::custom_protocol::call_handler
376.766ms [main] < wry::custom_protocol::handle
376.943ms [tokio-rt-worker] > wry::custom_protocol::call_handler
377.140ms [tokio-rt-worker] < wry::custom_protocol::call_handler
381.019ms [main] * exp: on_page_load Started tauri://localhost (main)
381.130ms [main] > wry::custom_protocol::handle
381.189ms [main] = wry::custom_protocol::handle uri="tauri://localhost"
...
381.332ms [main] * exp: on_page_load Finished tauri://localhost (main)
381.412ms [tokio-rt-worker] > wry::custom_protocol::call_handler
381.839ms [tokio-rt-worker] < wry::custom_protocol::call_handler
442.369ms [main] > wry::custom_protocol::handle
442.442ms [main] = wry::custom_protocol::handle uri="ipc://localhost/report"
...
442.768ms [main] * exp: command report
REPORT {
"t_script_ms": "2.0",
"readyState": "loading",
"href": "tauri://localhost",
"isTauri": true,
"typeof_ipc": "object",
"typeof___TAURI__": "undefined",
"internals": [
"plugins",
"postMessage",
"metadata",
"__TAURI_PATTERN__",
"ipc",
"convertFileSrc",
"transformCallback",
"unregisterCallback",
"runCallback",
"callbacks",
"invoke"
],
"plugins": [],
"metadata": {
"currentWindow": {
"label": "main"
},
"currentWebview": {
"label": "main"
}
},
"pattern": {
"pattern": "brownfield"
},
"t_load_ms": "3.0",
"index_headers": {
"access-control-allow-origin": "tauri://localhost",
"content-length": "810",
"content-type": "text/html"
}
}
...
442.962ms [main] * exp: RunEvent::ExitRequested
...
449.015ms [main] * exp: RunEvent::Exit (MainEventsCleared x456)
wry::window::draw 这个 span 在建窗口时进入,窗口第一次绘制时才退出,所以它跨过了 setup。下面按时间顺序把这份输出拆开。同一个程序 release 构建跑三次,从 main 到 report 到达分别是 678、598、531 毫秒,步骤和线程完全一样。
1. 编译期:Context 已经备好
generate_context! 在编译期把 tauri.conf.json 解析成 Config 的字面量,把 frontendDist 目录下的文件读进来压缩,连同 CSP 哈希、图标、解析好的 ACL 一起生成一个 Context 表达式。运行时第一行打印的 /index.html (507 B) 是压缩后的大小:磁盘上的 index.html 是 810 字节,默认的 compression feature 用 brotli 压过;同一份文件 release 构建压成 414 字节(debug 用最快的压缩级别 2,release 用 9)。后面 tauri 协议取资源时再解压,fetch 拿到的 content-length: 810 就是解压后的。
这一步在运行时几乎不花时间(0.16 到 0.69 毫秒之间包括了建 Context 和打印),工作都在编译期做完了。展开出来的结构见 generate_context!。
2. Builder::new:invoke key 在这里生成
tauri::Builder::default() 调 Builder::new。它做的事里和后面两条主线有关的只有一件:生成 invoke key(16 个随机字节的 Z85 编码),并且立刻把它渲染进 ipc-protocol.js,存成 invoke_initialization_script 字符串。这段脚本后面会作为第二段初始化脚本注入每个 webview,key 只出现在它的闭包变量里。调用主线上这个 key 怎么被检查,见一次 invoke。
3. build:建事件循环,不建窗口
app::build 这个 span 从 0.8 毫秒到 77.9 毫秒。Builder::build 依次:
AppManager::with_handlers收下Context、插件、invoke_handler、setup之外的各种回调和托管状态,见 AppManager;R::new,在Wry上就是Wry::new:用EventLoopBuilder::with_user_event()建 tao 的EventLoop,记下当前线程是主线程,见 tauri-runtime-wry;- 设菜单、托盘的事件转发,建
App和AppHandle; register_core_plugins注册path、event、window、webview、app、resources、image、menu、tray这些核心插件(输出里 0.2 毫秒),manage几个内部状态,最后initialize_plugins。
app::core_plugins::register 在 70 毫秒才开始,也就是说前面将近 70 毫秒花在管理器和事件循环的创建上(debug 构建)。build 返回之后实验打印 windows = []:这时一个窗口都没有。
4. App::run:第一个事件是 Ready
App::run 把运行时取出来调 Wry::run,后者 self.event_loop.run(event_handler) 把主线程交给 tao,从此不再返回。macOS 上 tao 的事件循环就是 NSApp 的 run loop,NSApplication 启动完成时调 delegate 的 applicationDidFinishLaunching:,tao 在里面发出第一个事件:
pub fn launched(app_delegate: &Object) {
// ...
HANDLER.set_ready();
HANDLER.waker().start();
HANDLER.set_in_callback(true);
HANDLER.handle_nonuser_event(EventWrapper::StaticEvent(Event::NewEvents(
StartCause::Init,
)));
HANDLER.set_in_callback(false);
}
tauri-runtime-wry 的 handle_event_loop 把它翻译成 RunEvent::Ready:
match event {
Event::NewEvents(StartCause::Init) => {
callback(RunEvent::Ready);
}
输出里 Triggered applicationDidFinishLaunching 在 169 毫秒,和 build 返回隔了 90 多毫秒,那是 NSApp 自己启动的时间。从这一行到 317.7 毫秒的 Completed,后面第 5 到第 7 步全部发生在这一个 delegate 回调里面。tao 怎么把 Cocoa 的回调接成 winit 风格的事件循环,见 tao。
5. Ready 的处理:先建窗口,再跑用户的 setup
App 把用户回调包了一层(make_run_event_loop_callback),Ready 来时先调内部的 setup,再把 RunEvent::Ready 交给用户:
move |event| match event {
RuntimeRunEvent::Ready => {
if let Err(e) = setup(&mut self) {
panic!("Failed to setup app: {e}");
}
let event = on_event_loop_event(self.handle(), RuntimeRunEvent::Ready, self.manager());
callback(self.handle(), event);
}
setup 本身是三步,顺序就是输出里看到的顺序:
fn setup<R: Runtime>(app: &mut App<R>) -> crate::Result<()> {
app.ran_setup = true;
for window_config in app.config().app.windows.iter().filter(|w| w.create) {
WebviewWindowBuilder::from_config(app.handle(), window_config)?.build()?;
}
app.manager.assets.setup(app);
if let Some(setup) = app.setup.take() {
(setup)(app).map_err(|e| crate::Error::Setup(e.into()))?;
}
Ok(())
}
webview::create 这个 span(170.9 到 317.7 毫秒)就是 tauri.conf.json 里那个窗口的创建;它结束之后才是 setup hook, windows = ["main"],再之后才是 RunEvent::Ready。所以用户的 setup 钩子里 get_webview_window("main") 一定拿得到,而 build 和 run 之间拿不到。窗口创建前发出的两个 app::emit 是 tauri://webview-created 和 tauri://window-created 两个内部事件。
build 不建窗口、要等 Ready,结果是窗口创建一定发生在事件循环之内:在 macOS 上就是 NSApplication 完成启动之后的那个 delegate 回调里,用户代码不必关心这个时机。
windows = ...,一行在 build 返回之后、一行在用户的 setup 钩子里,各打印几个窗口标签?如果把 tauri.conf.json 里窗口的 create 设成 false 呢?平台 M2 Max、macOS 27,tauri 2.12.5,debug 构建。6. prepare_pending_webview:补上协议和脚本
WebviewWindowBuilder::build 先把 webview 的描述变成 PendingWebview(into_pending_webview),这一步里 WebviewManager::prepare_webview 先算 URL:配置里的 WebviewUrl::App("index.html") 经 get_app_url 变成 tauri://localhost(文件名就是 index.html 时不拼进路径),所以输出里的 URL 没有结尾的 /index.html。然后调 prepare_pending_webview,往 PendingWebview 上补两样东西。
初始化脚本,按这个顺序排在用户自己的脚本前面:
all_initialization_scripts.push(main_frame_script(
r"
Object.defineProperty(window, 'isTauri', {
value: true,
});
if (!window.__TAURI_INTERNALS__) {
Object.defineProperty(window, '__TAURI_INTERNALS__', {
value: {
plugins: {}
}
})
}
"
.to_owned(),
));
all_initialization_scripts.push(main_frame_script(self.invoke_initialization_script.clone()));
all_initialization_scripts.push(main_frame_script(format!(
r#"
Object.defineProperty(window.__TAURI_INTERNALS__, 'metadata', {{
// ...
all_initialization_scripts.push(main_frame_script(self.initialization_script(
app_manager,
&ipc_init.into_string(),
&pattern_init.into_string(),
use_https_scheme,
)?));
all_initialization_scripts.extend(plugin_init_scripts);
页面脚本报回来的 internals 正好按这个顺序排:plugins(第一段)、postMessage(第二段,ipc-protocol.js)、metadata(第三段)、__TAURI_PATTERN__、ipc、convertFileSrc、transformCallback、unregisterCallback、runCallback、callbacks、invoke(第四段:init.js 模板依次拼进 pattern.js、ipc.js、core.js 和事件分发函数)。Object.defineProperty 不写 writable、configurable 时两者默认都是 false,这些属性页面脚本改不掉也删不掉。__TAURI_PATTERN__ 是 brownfield,表示没开隔离模式。window.__TAURI__ 是 undefined,因为没配 withGlobalTauri。typeof window.ipc 是 object:那是 wry 自己注入的 postMessage 入口,调用主线的退路。每一段从哪来、freezePrototype 和插件脚本怎么插进来,见初始化脚本。
URI scheme 处理函数:用户用 register_uri_scheme_protocol 注册的先挂上,然后在用户没占用同名 scheme 的前提下挂 tauri(protocol::tauri::get)和 ipc(ipc::protocol::get);开了 protocol-asset 再挂 asset,开了隔离模式再挂隔离 iframe 用的 scheme。prepare_webview 最后还给它装上 postMessage 那条路的处理函数 ipc_handler。
7. 交给 wry:WKWebView、scheme handler、user script
build_internal 拿到运行时句柄,调 create_window。在 Wry 上这是 send_user_message:当前线程是主线程就直接处理,否则经 EventLoopProxy 发回主线程。这里已经在主线程上,所以是同步执行的,输出里 wry::window::create 和 wry::webview::create 都在 webview::create 里面。
tauri-runtime-wry 的 create_webview 把 PendingWebview 逐项翻译成 wry 的 WebViewBuilder 调用:每个 scheme 一个 with_asynchronous_custom_protocol,每段脚本一个 with_initialization_script_for_main_only,ipc_handler 变成 with_ipc_handler,debug 构建里再加 with_devtools(true)。wry 的 new_ns_view 在 WKWebViewConfiguration 上给每个 scheme 调一次 setURLSchemeHandler_forURLScheme,把每段脚本变成 AtDocumentStart 的 WKUserScript,最后 loadRequest(tauri://localhost)。这一步在 debug 构建里约 106 毫秒(wry::window::create 结束到 wry::webview::create 结束)。逐个调用的细节见 tauri-runtime-wry 和 wry 在 macOS 上。
输出里夹着一行 web content process terminated,出现在刚建 webview 的时候,进程并没有终止。这行日志在 create_webview 里的位置是:
if let Some(on_web_content_process_terminate_handler) =
pending.on_web_content_process_terminate_handler
{
webview_builder = webview_builder
.with_on_web_content_process_terminate_handler(on_web_content_process_terminate_handler);
} else {
log::debug!("web content process terminated");
let context_ = context.clone();
let window_id_ = window_id.clone();
webview_builder = webview_builder.with_on_web_content_process_terminate_handler(move || {
// ...
match webview.reload() {
用户没有设置处理函数时走 else 分支,log::debug! 写在闭包外面,于是在注册默认处理函数时就打印了;真正的处理(webview.reload())在闭包里,WebContent 进程终止时才执行。
WKWebView 的页面跑在单独的 WebContent 进程里,这个进程被系统杀掉时页面会变成空白。#14523(首次发布于 tauri 2.11.0、tauri-runtime-wry 2.11.0)之前,tauri-runtime-wry 不给 wry 注册这个回调;这个 PR 在 create_webview 里加了上面那段代码,并在 Builder 上加了 on_web_content_process_terminate,让应用自己决定怎么处理。CHANGELOG 的原话是「Add handler for web content process termination on macOS and iOS.」。日志放在闭包外,是同一个 PR 带进来的,到 2.12.5 还是这样。
8. tauri://localhost:主线程收请求,tokio 线程取资源
窗口建好、Ready 交给用户回调之后,主线程回到事件循环。约 59 毫秒后,WebKit 对 tauri://localhost 发起请求,wry 的 start_task 在主线程上收到它(wry::custom_protocol::handle uri="tauri://localhost"),把 WKURLSchemeTask 里的方法、URL、头和正文抄成一个 http::Request<Vec<u8>>,连同一个应答闭包交给 tauri 协议。tauri 协议立刻把活儿交出去:
Box::new(move |_, request, responder| {
let context = context.clone();
crate::async_runtime::spawn(async move {
match get_response(&context, request).await {
Ok(response) => responder.respond(response),
所以主线程上的 call_handler 只用了 0.27 毫秒;紧接着在 tokio-rt-worker 线程上出现的 call_handler 是 wry 应答闭包里的 span:get_asset 把路径空串映射成 index.html、解压、按扩展名和内容定 MIME 类型,配了 CSP 时改写 HTML 并生成 Content-Security-Policy 头;应答闭包在 tokio 线程上直接调 didReceiveResponse / didReceiveData / didFinish 把结果交还 WebKit。
页面 fetch(location.href) 拿到的响应头就是这一步的产物:access-control-allow-origin: tauri://localhost(窗口自己的源)、content-type: text/html、content-length: 810。没有 content-security-policy,因为配置里没有 CSP;配了之后会多一个这个头,内联脚本的哈希会被自动加进 script-src,详见 tauri:// 协议和 CSP、原型冻结与隔离模式。
9. 页面脚本执行:初始化脚本已经跑完
页面脚本第一行记下的状态说明了初始化脚本的时机:readyState 是 loading(文档还在解析),performance.now() 只有 2 毫秒,而 window.__TAURI_INTERNALS__ 上该有的 11 个属性都已经在了,invoke 可以直接调用。这就是 WKUserScriptInjectionTime::AtDocumentStart 的含义:在文档元素创建之后、页面任何脚本之前执行。
on_page_load 的两次回调和页面脚本的先后也值得看一眼:Started 在 381.0 毫秒,第二个 tauri://localhost 请求在 381.1 毫秒,Finished 在 381.3 毫秒。第二个请求是页面在 load 事件里发的 fetch,也就是说 Finished 到达 Rust 时,页面脚本早已执行完、load 事件已经触发,它发出的请求反而先到。wry 在 didCommitNavigation 时发 Started、在 didFinishNavigation 时发 Finished,两者都由 WebKit 的导航 delegate 回调触发,wry 和 tauri 都没有让它们和页面 JS 的执行同步。想在页面脚本之前做事,要用初始化脚本,不能指望 on_page_load。
最后,页面的 invoke('report', ...) 在 442.4 毫秒到达,走的是 ipc://localhost/report:调用主线开始了,见一次 invoke。report 里的 app.exit(0) 引出 ExitRequested 和 Exit,进程退出。
动脑筋答案
上面那次运行:
77.896ms [main] * exp: build returned, windows = []
...
317.713ms [main] * exp: setup hook, windows = ["main"]
把窗口配置改成 "create": false,setup 钩子末尾加一行 app.handle().exit(0)(没有窗口就没有页面来调 report),其余不变:
4.998ms [main] * exp: main
11.718ms [main] * exp: generate_context! done, assets = ["/index.html (507 B)"], csp = None
13.516ms [main] * exp: Builder ready, calling build
98.366ms [main] * exp: build returned, windows = []
198.177ms [main] * exp: setup hook, windows = []
198.251ms [main] * exp: RunEvent::Ready
202.914ms [main] * exp: RunEvent::ExitRequested
203.006ms [main] * exp: Resumed
203.128ms [main] * exp: RunEvent::Exit (MainEventsCleared x1)
build返回时 0 个:Builder::build只建管理器、事件循环和核心插件,配置里的窗口要等Ready。setup钩子里 1 个(main):内部的setup先按配置建完所有create为真的窗口,再调用户的钩子。create设成false时两处都是 0 个:内部setup用.filter(|w| w.create)跳过了它。这时Ready和用户钩子来得更早(198 毫秒,没有建窗口的那 150 毫秒),窗口要按WindowConfig::create文档说的,从app.config().app.windows取出配置,在钩子里自己用WebviewWindowBuilder::from_config去建。
小结
generate_context!在编译期备好Context,前端资源以 brotli 压缩形式内嵌(实验里 810 字节压成 507 / 414 字节)。Builder::new生成 invoke key 并渲染ipc-protocol.js;Builder::build建AppManager、tao 事件循环和核心插件,不建窗口。App::run把主线程交给 tao;macOS 上applicationDidFinishLaunching里发出StartCause::Init,变成Ready。Ready时先按配置建窗口,再跑用户的setup钩子,最后把RunEvent::Ready交给用户回调,三者都在同一个 delegate 回调里。prepare_pending_webview加四段初始化脚本和tauri、ipc两个 scheme;wry 把它们变成WKUserScript(AtDocumentStart)和WKURLSchemeHandler。tauri://localhost的请求在主线程收到,取资源在 tokio 线程上做;页面脚本执行时__TAURI_INTERNALS__已经就绪;on_page_load的Finished到达时,页面的load事件早已触发。