Rust in Everything
基于 tauri 2.12.5

怎么读这一卷

主线位置:三条主线的总览。本页把启动主线、调用主线和事件主线各画一遍,标出每一步落在哪个函数上、哪一页讲它;再讲这三条线为什么在开发和发布两种构建里走不同的代码: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 动手"]

锁定版本

项本卷说明
tauri2.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
wry0.57.1,tag wry-v0.57.1,commit 2446fea(2026-10-23)tauri-runtime-wry 2.12.3 依赖它
tao0.37.2,tag tao-v0.37.2,commit 2d9b2ba1(2026-10-23)同上
实验依赖里的 tokio / serde / serde_json / http1.54.1 / 1.0.229 / 1.0.154 / 1.5.0锁文件里实际解析到的版本
rust-version / editiontauri 工作区 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.rsgenerate_context!
2. Builder::new 用 generate_invoke_key 生成 16 字节随机数的 Z85 编码,渲染进 ipc-protocol.jsapp.rs、lib.rsBuilder 与 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.rsBuilder 与 App、AppManager、tauri-runtime-wry
4. App::run 调 Wry::run,进入 tao 的事件循环;macOS 上 applicationDidFinishLaunching 回调里 tao 发出 NewEvents(StartCause::Init),handle_event_loop 把它变成 RunEvent::Readyapp.rs、tauri-runtime-wry/src/lib.rs、tao platform_impl/macos/app_state.rstao
5. Ready 的处理在 make_run_event_loop_callback 里:先调 setup,它按配置逐个 WebviewWindowBuilder::from_config(..).build(),再跑用户的 setup 钩子;然后才把 RunEvent::Ready 交给用户的回调app.rsBuilder 与 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_handlertauri-runtime-wry/src/lib.rstauri-runtime-wry
8. wry 的 new_ns_view 给每个 scheme 调 setURLSchemeHandler_forURLScheme,每段脚本变成一个 AtDocumentStart 的 WKUserScript,最后 navigate_to_url 发出 loadRequestwry src/wkwebview/mod.rswry 在 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.rstauri:// 协议

第 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__.postMessagescripts/core.js、scripts/ipc.jsJS 侧的 invoke
2. sendIpcMessage 把参数编成 JSON(或原始字节),fetch 发 POST ipc://localhost/greet,头里带 Tauri-Callback、Tauri-Error、Tauri-Invoke-Key;fetch 失败时改用 window.ipc.postMessage,此后都走这条路scripts/ipc-protocol.jsJS 侧的 invoke
3. wry 的 start_task 在主线程上读出方法、头和正文,交给 ipc 协议的 get 返回的闭包;parse_invoke_request 从路径取命令名、从头取回调 id 和 invoke key、从 Origin 头取来源 URLipc/protocol.rsipc 协议
4. Webview::on_message:invoke key 不对就直接返回;按来源、窗口和 webview 标签 resolve_access;插件命令、应用有 ACL 清单、来源是远程,三者之一成立才要求 ACL 放行;plugin: 前缀交给插件,否则 run_invoke_handlerwebview/mod.rs、manager/mod.rs分发、运行时检查
5. generate_handler! 生成的闭包按命令名 match;#[command] 的包装函数用 CommandArg 逐个取参数(JSON 键默认 camelCase);同步命令就地执行,async 命令经 respond_async_serialized spawn 到 async_runtimetauri-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-Responseipc/mod.rs、ipc/protocol.rs参数与 InvokeResolver
7. JS 侧按 Tauri-Response 选回调 id、按 content-type 解析正文,runCallback 兑现 Promise,两个回调 id 一起注销scripts/ipc-protocol.js、scripts/core.jsJS 侧的 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 里:listenpackages/api/src/event.ts、event/plugin.rsJS 侧的 invoke
2. Webview::listen_js 在 Rust 侧的表里记下「哪个 webview 听哪个事件」,同时 eval 一段 listen_js_script,把 { target, handlerId } 存进页面里的一个全局对象webview/mod.rs、event/mod.rs、event/listener.rsAppManager
3. AppManager::emit 把 payload 序列化成 EmitArgs,先调 emit_js,再分发给 Rust 侧 listen 的监听器manager/mod.rs、event/listener.rsAppManager
4. emit_js_filter 只对登记过这个事件的 webview 动手,给每个这样的 webview eval 一段 emit_js_script,调页面里的分发函数event/listener.rs、event/mod.rsAppManager
5. 分发函数由 event_initialization_script 生成、随 init.js 模板注入:按事件名和监听器 id 找到 handlerId,runCallbackevent/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() 的运行时就是 WryRuntime 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_responseRuntime 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"]。它的效果是分三处落下去的:

  1. 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 的构建脚本。
  2. 应用的构建脚本。tauri_build::build() 里的 try_build 读 DEP_TAURI_DEV(is_dev),给应用 crate 发 cfg(dev),desktop / mobile 也是在这里发的。
  3. 过程宏。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 的文档就是这么推荐的。

📝NOTE
没有蠢问题

问: 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;两者行为不同时两种都跑。

阅读路线

前置知识

  • Rust 的 trait、泛型和关联类型:整个 tauri crate 对运行时泛型(R: Runtime),见 Runtime trait。
  • async/await 和 tokio:async 命令和 tauri 协议的处理都跑在一个全局的 tokio 多线程运行时上,见 tokio 卷。
  • http crate 的 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 先给有监听器的 webview eval,再通知 Rust 监听器。
  • custom-protocol 经过 tauri 的构建脚本(cargo:dev)、应用的构建脚本(cfg(dev))和过程宏三处生效,切换的是开发 / 发布模式,不是「注不注册 tauri://」。
  • debug 构建多了 ACL 的详细拒绝信息、devtools、动态分发和低压缩级别;本卷两种行为不同时都跑。

💬 关联讨论

发起讨论
加载中...