怎么读这一卷
主线位置:三条主线的总览。本页把启动主线、调用主线和事件主线各画一遍,标出每一步落在哪个函数上、哪一页讲它;再讲这三条线为什么在开发和发布两种构建里走不同的代码:
custom-protocol这个 feature 经过两个构建脚本变成应用里的cfg(dev),debug_assertions又让 debug 和 release 有别。「一次启动」和「一次 invoke」用带时间戳的实验把前两条线各走一遍。
flowchart TB
start[从这里开始] --> q{读这一卷为了什么}
q -->|会用| use1[01 全景]
use1 --> use2["06 的分发与参数"]
use2 --> use3["07 的能力与运行时检查"]
q -->|读懂实现| all[00 到 11 按顺序]
q -->|自己写一个| hands["03 运行时 再到 11 动手"]
锁定版本
| 项 | 本卷 | 说明 |
|---|---|---|
tauri | 2.12.5,tag tauri-v2.12.5,commit 993038c49(2026-10-21) | 同一个 tag 下:tauri-runtime-wry 2.12.3、tauri-runtime 2.12.2、tauri-utils 2.10.2、tauri-build / tauri-macros / tauri-codegen 2.7.4、tauri-plugin 2.7.2 |
| wry | 0.57.1,tag wry-v0.57.1,commit 2446fea(2026-10-23) | tauri-runtime-wry 2.12.3 依赖它 |
| tao | 0.37.2,tag tao-v0.37.2,commit 2d9b2ba1(2026-10-23) | 同上 |
| 实验依赖里的 tokio / serde / serde_json / http | 1.54.1 / 1.0.229 / 1.0.154 / 1.5.0 | 锁文件里实际解析到的版本 |
rust-version / edition | tauri 工作区 1.90 / 2024;wry 1.85 / 2024;tao 1.85 / 2021 | |
| 对照:dioxus 0.7.10 | 工作区写 wry = "0.53.5"、tao = "0.34.0",锁文件里是 wry 0.53.5、tao 0.34.8 | 同一套库,比本卷旧几个版本 |
wry 和 tao 的 tag 比 tauri 的 tag 晚两天,它们是独立发版的仓库。本卷的版本组合不是挑出来的:一个新 crate 写 tauri = "=2.12.5"、tauri-build = "=2.7.4",cargo 解析出来就是这一组,实验项目的 Cargo.lock 里一共 414 个包。源码链接一律指向 tag:https://github.com/tauri-apps/tauri/blob/tauri-v2.12.5/crates/...、https://github.com/tauri-apps/wry/blob/wry-v0.57.1/src/...、https://github.com/tauri-apps/tao/blob/tao-v0.37.2/src/...。仓库里已经有 tauri-v3.0.0-alpha.* 的 tag,本卷不讲 3.x。
三条主线
全卷沿三条线展开,每页开头都会标出本页在哪条主线上。
启动主线:一个窗口加载出页面
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
flowchart TB ctx["generate_context! 编译期"] --> bnew["Builder::new 生成 invoke key"] bnew --> bbuild["Builder::build"] bbuild --> wnew["Wry::new 建事件循环"] wnew --> run["App::run 进事件循环"] run --> ready["StartCause Init 变成 Ready"] ready --> setupfn["setup 按配置建窗口"] setupfn --> prep["prepare_pending_webview"] prep --> cw["create_webview 交给 wry"] cw --> wk["WKWebView 和 scheme handler"] wk --> load["请求 tauri://localhost"] load --> asset["get_asset 取内嵌资源"]
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. 编译期 generate_context! 读 tauri.conf.json,context_codegen 把前端目录压缩进 EmbeddedAssets、算 CSP 哈希、把能力解析成 Resolved,生成一个 Context 表达式 | tauri-macros/src/context.rs、tauri-codegen/src/context.rs、embedded_assets.rs | generate_context! |
2. Builder::new 用 generate_invoke_key 生成 16 字节随机数的 Z85 编码,渲染进 ipc-protocol.js | app.rs、lib.rs | Builder 与 App |
3. Builder::build:AppManager::with_handlers 收下 Context 和所有回调;R::new 即 Wry::new 建 tao 的 EventLoop;register_core_plugins 注册 path、event、window、webview 等核心插件。这一步不建窗口 | app.rs、manager/mod.rs、tauri-runtime-wry/src/lib.rs | Builder 与 App、AppManager、tauri-runtime-wry |
4. App::run 调 Wry::run,进入 tao 的事件循环;macOS 上 applicationDidFinishLaunching 回调里 tao 发出 NewEvents(StartCause::Init),handle_event_loop 把它变成 RunEvent::Ready | app.rs、tauri-runtime-wry/src/lib.rs、tao platform_impl/macos/app_state.rs | tao |
5. Ready 的处理在 make_run_event_loop_callback 里:先调 setup,它按配置逐个 WebviewWindowBuilder::from_config(..).build(),再跑用户的 setup 钩子;然后才把 RunEvent::Ready 交给用户的回调 | app.rs | Builder 与 App |
6. 建 webview 之前,WebviewManager::prepare_webview 算出 URL(发布模式是 tauri://localhost),prepare_pending_webview 补上初始化脚本(__TAURI_INTERNALS__、ipc-protocol.js、metadata、init.js 模板、插件脚本)和 tauri、ipc 两个 URI scheme 处理函数 | manager/webview.rs、webview/mod.rs | 窗口与 webview、初始化脚本 |
7. tauri-runtime-wry 的 create_webview 把它们交给 wry 的 WebViewBuilder:每个 scheme 一个 with_asynchronous_custom_protocol,每段脚本一个 with_initialization_script_for_main_only,再加 with_ipc_handler | tauri-runtime-wry/src/lib.rs | tauri-runtime-wry |
8. wry 的 new_ns_view 给每个 scheme 调 setURLSchemeHandler_forURLScheme,每段脚本变成一个 AtDocumentStart 的 WKUserScript,最后 navigate_to_url 发出 loadRequest | wry src/wkwebview/mod.rs | wry 在 macOS 上 |
9. WebKit 请求 tauri://localhost,wry 的 start_task 在主线程上把它包成 http::Request 交给 tauri 协议 get;后者把活儿 spawn 到 async_runtime,get_asset 解压 index.html、配了 CSP 时加上 Content-Security-Policy 头 | protocol/tauri.rs、manager/mod.rs | tauri:// 协议 |
第 3 步和第 5 步的分界值得记住:build 返回时一个窗口也没有,窗口是在事件循环跑起来之后、在 Ready 的处理里建的,用户的 setup 钩子拿到的是已经建好的窗口。逐步的实验见一次启动。
调用主线:一次 invoke
#[tauri::command]
fn greet(name: &str) -> String {
format!("hello, {name}")
}
const v = await window.__TAURI_INTERNALS__.invoke('greet', { name: 'rie' })
flowchart TB inv["invoke 登记两个回调 id"] --> pm["postMessage 发 fetch"] pm --> st["wry start_task"] st --> ipcget["ipc 协议 get"] ipcget --> parse["parse_invoke_request"] parse --> onmsg["Webview::on_message"] onmsg --> acl["invoke key 和 ACL"] acl --> handler["generate_handler 的分发"] handler --> wrap["command 包装函数"] wrap --> resolver["InvokeResolver"] resolver --> resp["HTTP 响应"] resp --> cbrun["runCallback 兑现 Promise"]
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. invoke 用 crypto.getRandomValues 生成两个 u32 作回调 id(成功一个、失败一个),调 __TAURI_INTERNALS__.ipc;ipc 在默认的 brownfield 模式下直接转给 __TAURI_INTERNALS__.postMessage | scripts/core.js、scripts/ipc.js | JS 侧的 invoke |
2. sendIpcMessage 把参数编成 JSON(或原始字节),fetch 发 POST ipc://localhost/greet,头里带 Tauri-Callback、Tauri-Error、Tauri-Invoke-Key;fetch 失败时改用 window.ipc.postMessage,此后都走这条路 | scripts/ipc-protocol.js | JS 侧的 invoke |
3. wry 的 start_task 在主线程上读出方法、头和正文,交给 ipc 协议的 get 返回的闭包;parse_invoke_request 从路径取命令名、从头取回调 id 和 invoke key、从 Origin 头取来源 URL | ipc/protocol.rs | ipc 协议 |
4. Webview::on_message:invoke key 不对就直接返回;按来源、窗口和 webview 标签 resolve_access;插件命令、应用有 ACL 清单、来源是远程,三者之一成立才要求 ACL 放行;plugin: 前缀交给插件,否则 run_invoke_handler | webview/mod.rs、manager/mod.rs | 分发、运行时检查 |
5. generate_handler! 生成的闭包按命令名 match;#[command] 的包装函数用 CommandArg 逐个取参数(JSON 键默认 camelCase);同步命令就地执行,async 命令经 respond_async_serialized spawn 到 async_runtime | tauri-macros/src/command/、ipc/mod.rs、ipc/command.rs | #[command] 与 generate_handler!、参数与 InvokeResolver |
6. InvokeResolver::return_result 调回 ipc 协议里的应答闭包:成功 Tauri-Response: ok、失败 error,正文是 JSON 或原始字节,另加 Access-Control-Allow-Origin: * 和 Access-Control-Expose-Headers: Tauri-Response | ipc/mod.rs、ipc/protocol.rs | 参数与 InvokeResolver |
7. JS 侧按 Tauri-Response 选回调 id、按 content-type 解析正文,runCallback 兑现 Promise,两个回调 id 一起注销 | scripts/ipc-protocol.js、scripts/core.js | JS 侧的 invoke |
一次 invoke 在 Rust 侧走了哪些线程、JS 侧看到的请求和响应头各是什么,见一次 invoke。
事件主线:一次 emit
flowchart TB lis["JS listen"] --> lisinv["invoke plugin:event 的 listen"] lisinv --> ljs["Webview::listen_js"] ljs --> store["eval 记下 handlerId"] emitn["Rust app.emit"] --> amemit["AppManager::emit"] amemit --> tojs["emit_js 每个有监听的 webview"] tojs --> evaljs["eval 调分发函数"] evaljs --> runcb["runCallback handlerId"] amemit --> torust["再分发给 Rust 监听器"]
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. @tauri-apps/api 的 listen 先用 transformCallback 登记回调,再 invoke('plugin:event|listen', { event, target, handler })。它是一次普通的调用主线,命令在核心插件 event 里:listen | packages/api/src/event.ts、event/plugin.rs | JS 侧的 invoke |
2. Webview::listen_js 在 Rust 侧的表里记下「哪个 webview 听哪个事件」,同时 eval 一段 listen_js_script,把 { target, handlerId } 存进页面里的一个全局对象 | webview/mod.rs、event/mod.rs、event/listener.rs | AppManager |
3. AppManager::emit 把 payload 序列化成 EmitArgs,先调 emit_js,再分发给 Rust 侧 listen 的监听器 | manager/mod.rs、event/listener.rs | AppManager |
4. emit_js_filter 只对登记过这个事件的 webview 动手,给每个这样的 webview eval 一段 emit_js_script,调页面里的分发函数 | event/listener.rs、event/mod.rs | AppManager |
5. 分发函数由 event_initialization_script 生成、随 init.js 模板注入:按事件名和监听器 id 找到 handlerId,runCallback | event/mod.rs、scripts/init.js | 初始化脚本 |
两点和直觉不同:没有监听器的 webview 收不到任何 eval;JS 侧的监听器比 Rust 侧的先被通知(指送出 eval 的先后,JS 什么时候执行由 webview 决定)。大块数据不要走事件,改用 Channel:它沿用 invoke 的回调 id,Rust 侧按 id 推送。
feature 与 cfg
tauri 的 feature
crates/tauri/Cargo.toml 的 [features] 里,和本卷有关的:
| feature | 默认 | 打开后多了什么 | 讲它的页 |
|---|---|---|---|
wry | 是 | 依赖 tauri-runtime-wry,Builder::default() 的运行时就是 Wry | Runtime trait |
compression | 是 | generate_context! 用 brotli 压缩内嵌资源,取资源时解压 | generate_context! |
dynamic-acl | 是 | Manager::add_capability,运行时追加能力 | 编译期解析 |
custom-protocol | 否 | 发布模式,见下一节。tauri-cli 在 tauri build 时打开它 | 本页 |
isolation | 否 | 隔离模式(AES-GCM 加密 IPC 消息的沙箱 iframe),要和 tauri.conf.json 的配置一致 | CSP、原型冻结与隔离模式 |
protocol-asset | 否 | 注册 asset scheme,convertFileSrc 生成的 URL 才能用 | asset:// 协议 |
test | 否 | tauri::test 模块:MockRuntime、mock_builder、get_ipc_response | Runtime trait |
tracing | 否 | 启动、插件、IPC、事件、自定义协议上的 span,也打开 wry/tracing | 一次 invoke |
devtools | 否 | release 构建里也能开 Web Inspector;debug 构建不需要它 | 本页 |
unstable | 否 | 多 webview:没有 webview 的 Window、Window::add_child | 窗口与 webview |
custom-protocol 怎么变成 cfg(dev)
custom-protocol 在 tauri 的 Cargo.toml 里只有一行:custom-protocol = ["tauri-macros/custom-protocol"]。它的效果是分三处落下去的:
- tauri 自己的构建脚本。
crates/tauri/build.rs检查这个 feature,用alias给 tauri crate 自己发cfg(custom_protocol)或cfg(dev),另外打印一行cargo:dev=<bool>。tauri 的Cargo.toml写着links = "Tauri",所以这一行会以环境变量DEP_TAURI_DEV的形式传给直接依赖 tauri 的 crate 的构建脚本。 - 应用的构建脚本。
tauri_build::build()里的try_build读DEP_TAURI_DEV(is_dev),给应用 crate 发cfg(dev),desktop/mobile也是在这里发的。 - 过程宏。
tauri-macros的generate_context用cfg!(not(feature = "custom-protocol"))决定生成开发还是发布的Context:开发模式配了devUrl就不内嵌资源,CSP 优先用devCsp。
实验 crate 分别带和不带 --features tauri/custom-protocol 编一次,两个构建脚本的输出(target/debug/build/*/output,只留 cfg 相关的行):
# 带 custom-protocol
tauri 的构建脚本 cargo:rustc-cfg=custom_protocol
cargo:dev=false
cargo:rustc-cfg=desktop
应用的构建脚本 cargo:rustc-cfg=desktop
# 不带
tauri 的构建脚本 cargo:rustc-cfg=dev
cargo:dev=true
cargo:rustc-cfg=desktop
应用的构建脚本 cargo:rustc-cfg=desktop
cargo:rustc-cfg=dev
运行时的分叉看 cfg(dev):get_app_url 在开发模式下返回 devUrl,发布模式下 frontendDist 是 URL 就用它,否则是 tauri://localhost;csp() 在开发模式下优先取 devCsp;mobile 的开发模式下 tauri 协议还会把请求代理到开发服务器。应用代码里也可以写 #[cfg(dev)],tauri_build::is_dev 的文档就是这么推荐的。
问: feature 叫 custom-protocol,是不是不打开它,tauri:// 这个自定义协议就不注册?
答: 不是。prepare_pending_webview 注册 tauri 和 ipc 两个 scheme 只看用户有没有自己注册同名的,和这个 feature 无关。feature 决定的是窗口打开哪个 URL:开发模式打开 devUrl(前端开发服务器),这时页面不从 tauri:// 来;发布模式打开 tauri://localhost,资源从二进制里来。名字说的是「发布版用自定义协议提供页面」这件事,开关的却是整个开发 / 发布模式。
本卷的模板没有配 devUrl。本卷的实验都带 custom-protocol 编译。写作时不带它的开发模式构建曾在 setup 之后无声退出(退出码 138),原因是实验机器的 DYLD_LIBRARY_PATH 让系统的 ImageIO 加载了另一份 libpng,和 tauri 无关,见常见坑 1。
debug 构建多出来的东西
debug_assertions 在 debug profile 下打开、release 下关闭。tauri 里和本卷有关的:
| 位置 | debug 下多做什么 | 讲它的页 |
|---|---|---|
Webview::on_message 的 ACL 拒绝分支 | 拒绝信息由 resolve_access_message 生成,列出这个命令关联的权限;release 只有 Command <名字> not allowed by ACL | 运行时检查 |
ResolvedCommand 的 referenced_by | 记下这条授权来自哪个能力、哪个权限,供上面那条信息用;release 里这个字段不存在 | 编译期解析 |
create_webview 的 with_devtools | #[cfg(any(debug_assertions, feature = "devtools"))]:debug 构建默认能开 Web Inspector,open_devtools 这些方法也存在 | tauri-runtime-wry |
respond_async_serialized | 把 future 装箱后走动态分发,注释说是为了开发时编译快;release 直接泛型展开 | 参数与 InvokeResolver |
| 内嵌资源的 brotli 级别 | 压缩级别 2,release 是 9;实验模板的 810 字节 index.html 压成 507 字节(debug)和 414 字节(release) | generate_context! |
format_callback | 断言要 eval 的 JSON 字符串短于 2³⁰−2 字节 | 参数与 InvokeResolver |
tauri-runtime-wry 的 dialog 模块 | 反过来:只在 release 里编译,Windows 上找不到 WebView2 运行时时弹一个原生对话框,debug 只返回错误 | 只读源码(Windows) |
本卷展示输出时都会说明是 debug 还是 release;两者行为不同时两种都跑。
阅读路线
- 只想会用:读 01 全景;06 里的分发和参数与 InvokeResolver;07 里的权限、能力、作用域和运行时检查;遇到报错再查 09 常见坑。
- 想读懂实现:按顺序读。02 讲清楚编译期生成了什么之后,04、05 的运行时代码才好读;06 是全卷的重点。
- 只关心 webview 那一层:03 运行时,配合 dioxus 卷:dioxus-desktop 用的是同一套 wry 和 tao。
- 想自己写一个:读完 03 和 06,直接去 11 动手。
前置知识
- Rust 的 trait、泛型和关联类型:整个
tauricrate 对运行时泛型(R: Runtime),见 Runtime trait。 - async/await 和 tokio:
async命令和 tauri 协议的处理都跑在一个全局的 tokio 多线程运行时上,见 tokio 卷。 httpcrate 的Request/Response:自定义协议的请求和响应就是它们,见 hyper 卷。- serde:命令参数从 JSON 反序列化、返回值序列化回 JSON,见 serde 卷。
- 前端:
fetch、Promise、CORS 和 CSP 的基本概念。
小结
- 全卷对着 tauri 2.12.5、wry 0.57.1、tao 0.37.2 写,这是新项目写
tauri = "=2.12.5"时解析出来的组合。 - 启动主线:
generate_context!→Builder::new(invoke key)→Builder::build(AppManager、Wry::new、核心插件,不建窗口)→App::run→Ready→setup建窗口 →prepare_pending_webview→create_webview→ WKWebView →tauri://localhost→get_asset。 - 调用主线:
invoke登记回调 id →fetch POST ipc://localhost/<cmd>→start_task→parse_invoke_request→on_message(invoke key、ACL)→ 分发 → 包装函数 →InvokeResolver→ HTTP 响应 →runCallback。 - 事件主线:
listen是一次plugin:event|listen调用;emit先给有监听器的 webvieweval,再通知 Rust 监听器。 custom-protocol经过 tauri 的构建脚本(cargo:dev)、应用的构建脚本(cfg(dev))和过程宏三处生效,切换的是开发 / 发布模式,不是「注不注册tauri://」。- debug 构建多了 ACL 的详细拒绝信息、devtools、动态分发和低压缩级别;本卷两种行为不同时都跑。