tauri 解决什么问题
主线位置:三条主线的起点。本页不跟任何一条主线走,先回答「tauri 在 webview 和 Rust 之间加了哪些层」:启动主线要穿过的层次、调用主线入口处的那道 ACL 检查,都在这里先摆出位置。
flowchart TB
fe["前端 HTML JS CSS"] -->|"fetch ipc://"| ipcl["IPC 入口 on_message"]
ipcl --> acl{"ACL 检查"}
acl -->|放行| cmds["命令 插件 事件"]
cmds --> core["tauri 本体 App Manager"]
core --> rtt["tauri-runtime trait"]
rtt --> rtw["tauri-runtime-wry"]
rtw --> wry["wry"]
rtw --> tao["tao"]
wry --> wk["WKWebView"]
tao --> nsw["NSWindow 和 NSApp"]
fe -.->|"tauri:// 取页面"| wk
tauri 怎么描述自己
仓库 README 的第一段:
Tauri is a framework for building tiny, blazingly fast binaries for all major operating systems. Developers can integrate any front-end framework that compiles to HTML, JS and CSS for building their user interface. The backend of the application is a rust-sourced binary with an API that the front-end can interact with.
第二段交代了下面两层:窗口用 tao,渲染用 wry,wry 在 macOS 和 iOS 上是 WKWebView,在 Windows 上是 WebView2,在 Linux 上是 WebKitGTK。README 的特性列表里还有一条和本卷关系最大:
Native WebView Protocol (tauri doesn't create a localhost http(s) server to serve the WebView contents)
ARCHITECTURE.md 补了一句为什么小:「They are very small, because they use the OS's webview. They do not ship a runtime, since the final binary is compiled from Rust.」
拆开看,tauri 要解决的是三件事:
- 界面:不自带浏览器引擎,用操作系统已经有的 webview。代价是各平台的渲染引擎不同,同一份前端在 WKWebView、WebView2、WebKitGTK 上的行为可能有差别。
- 页面从哪来:不起本地 HTTP 服务。前端产物在编译期压缩进二进制,运行时 webview 请求
tauri://localhost/...,由注册在 webview 上的 URI scheme 处理函数直接从内存里取。 - JS 怎么调 Rust:同样不走网络。
invoke用fetch请求另一个自定义 schemeipc://,请求在进程内交给 Rust;每个请求先过 invoke key 和 ACL 两道检查,才落到用户写的#[command]函数上。
第 2、3 条可以直接验证:看这个进程有没有网络套接字。实验模板在 report 命令里用 lsof 看自己(debug 构建,窗口隐藏,页面已经从 tauri://localhost 加载并发来了这次 invoke):
#[tauri::command]
fn report(app: tauri::AppHandle, msg: String) {
if std::env::var_os("LSOF").is_some() {
let pid = std::process::id().to_string();
let out = std::process::Command::new("lsof").args(["-a", "-n", "-P", "-p", &pid, "-i"]).output().unwrap();
println!("LSOF exit={:?} stdout_lines={}", out.status.code(), String::from_utf8_lossy(&out.stdout).lines().count());
print!("{}", String::from_utf8_lossy(&out.stdout));
let all = std::process::Command::new("lsof").args(["-a", "-n", "-P", "-p", &pid]).output().unwrap();
println!("LSOF all fds of this pid: {} lines", String::from_utf8_lossy(&all.stdout).lines().count());
let ps = std::process::Command::new("ps").args(["-axo", "pid=,ppid=,etime=,comm="]).output().unwrap();
for line in String::from_utf8_lossy(&ps.stdout).lines() {
let f: Vec<&str> = line.split_whitespace().collect();
// WebKit helper processes started within the last 9 seconds
if f.len() >= 4 && f[3].contains("com.apple.WebKit") && f[2].len() == 5 && f[2] < "00:10" {
println!("PS pid={} ppid={} etime={} {}", f[0], f[1], f[2], f[3].rsplit('/').next().unwrap());
}
}
println!("PS self pid={}", pid);
}
println!("REPORT {msg}");
app.exit(0);
}
后半段顺便列出刚启动的 WebKit 辅助进程。结果放在本页末尾的动脑筋答案里。
lsof -i 列出这个进程的几个网络套接字(含监听和已连接)?平台 M2 Max、macOS 27,tauri 2.12.5,--features tauri/custom-protocol 的 debug 构建。
分层:每层回答一个问题
| 层 | crate | 回答的问题 | 讲它的部分 |
|---|---|---|---|
| 前端 | 用户的 HTML / JS / CSS,@tauri-apps/api | 界面长什么样;invoke 只是转给 window.__TAURI_INTERNALS__ | 06 IPC |
| IPC 入口 | tauri 的 ipc/protocol.rs、webview/mod.rs | 这个请求是谁发的、该不该放行、交给哪个处理函数 | 06 IPC、07 安全 |
| 本体 | tauri 的 app.rs、manager/、plugin.rs、event/ | 应用里有哪些窗口、状态、插件、监听器 | 04 应用与管理器 |
| 运行时抽象 | tauri-runtime | 「建一个窗口」「在 webview 里执行一段 JS」这些操作的接口 | 03 运行时 |
| 运行时实现 | tauri-runtime-wry | 用 wry 和 tao 实现上面的接口,把别的线程发来的操作送回主线程 | 03 运行时 |
| 平台 | wry、tao | 在 macOS 上怎么建 WKWebView、怎么注册 scheme handler、怎么跑 NSApp 的事件循环 | 03 运行时 |
| 编译期 | tauri-build、tauri-macros、tauri-codegen | 配置、前端资源、ACL 在编译期就定下来,做成 Context | 02 编译期 |
tauri 本体对 R: Runtime 泛型,Builder::default() 选的是 Wry;test feature 里的 MockRuntime 是第二个实现,不开窗口就能跑 IPC。中间这层抽象的代价和收益放在设计取舍里。
安全检查卡在哪一层
前端被看作不完全可信:页面可能加载第三方脚本,也可能整个是远程页面。所以检查放在 IPC 入口、命令执行之前,在 Webview::on_message 里依次是:
- invoke key:
Builder::new时生成的随机串,只存在于注入脚本的闭包里,请求头里带的不对就丢弃请求。它防的是没经过 tauri 初始化的 frame 直接构造请求。 - ACL:按命令名、窗口标签、webview 标签和来源(本地
tauri://还是远程 URL)查编译期解析好的权限表。是否强制检查有一个条件:插件命令、应用自己声明了 ACL 清单、来源是远程,三者之一成立。
实验模板既没有 capabilities/ 目录,也没有声明应用命令的权限清单,它的 greet、report 照样能从本地页面调用,就是因为三个条件都不成立。这条规则的含义和远程页面的情形见运行时检查。
和 Electron 比:带不带浏览器
Electron 把 Chromium 和 Node.js 一起打进应用,所有平台上是同一个渲染引擎,主进程跑 JS。tauri 反过来:不带渲染引擎,后端是编译好的 Rust。两边的差别都由这一个选择派生出来:
| tauri | Electron | |
|---|---|---|
| 渲染引擎 | 系统 webview,随系统更新,各平台不同 | 自带 Chromium,各平台相同 |
| 后端语言 | Rust,编进同一个二进制 | Node.js |
| 前端调后端 | fetch 一个自定义 scheme,进程内处理 | 进程间消息 |
体积和启动时间的实测放在设计取舍里。本卷只在 tauri 这边做实验,表里 Electron 一列是它公开的架构,不是本卷测出来的。
和 dioxus-desktop 比:UI 写在哪边
dioxus-desktop 和 tauri 用的是同一套 wry 和 tao(dioxus 0.7.10 锁的是 wry 0.53.5、tao 0.34.8,比本卷旧)。分界在 UI 逻辑在哪边:
- dioxus-desktop 把组件、
VirtualDom和 signal 都放在 Rust 进程里,webview 只是一个渲染器:页面本身从它注册的dioxus自定义协议加载,Rust 算出的 DOM 指令经一个绑在127.0.0.1随机端口上的 WebSocket 推进页面(start_server;文件开头的注释说,最早是在自定义协议上长轮询,因为 Android 上 wry 的问题才改成 WebSocket),页面里的事件经同一个协议的__events路径和with_ipc_handler回到 Rust。用户不写 JS,也就没有「命令」和「权限」的概念。dioxus 的更新怎么从事件变成指令,见 dioxus 卷的一次点击。 - tauri 把 UI 整个交给前端,Rust 只提供命令。JS 是一等公民,于是需要 invoke key、ACL、CSP、隔离模式这一整套,来回答「这段 JS 能调哪些 Rust」。
两者对同一个 webview 的用法几乎相反:dioxus 往页面里推指令,tauri 等页面来调。
和直接用 wry 比:tauri 多了什么
wry 已经提供了建 webview、注册自定义协议(with_asynchronous_custom_protocol)、注入脚本(with_initialization_script)、接 window.ipc.postMessage(with_ipc_handler)这些能力,直接用它也能写出「JS 调 Rust」。tauri 在这之上加的:
| tauri 加的 | 在哪 |
|---|---|
编译期把配置、资源、CSP 哈希、ACL 做成 Context | 02 编译期 |
| 窗口和 webview 的管理、多窗口、事件循环之外的线程安全调用 | 04 应用与管理器、03 运行时 |
一套 IPC 约定:回调 id、invoke key、JSON 和原始字节两种正文、Tauri-Response 头、postMessage 退路 | 06 IPC |
#[command] 把普通函数变成处理函数,参数可以是 State、Window、AppHandle | #[command] 与 generate_handler! |
| 权限、能力、作用域 | 07 安全 |
事件、Channel、插件、托管状态 | 04 应用与管理器、Channel |
11 动手用三百行左右的 wry + tao 代码实现一个最小的 invoke,再逐项对照少了上表里的哪些。
动脑筋答案
LSOF exit=Some(1) stdout_lines=0
LSOF all fds of this pid: 64 lines
PS pid=3368 ppid=1 etime=00:00 com.apple.WebKit.GPU
PS pid=3369 ppid=1 etime=00:00 com.apple.WebKit.WebContent
PS pid=3370 ppid=1 etime=00:00 com.apple.WebKit.Networking
PS self pid=3367
REPORT {
...
"href": "tauri://localhost",
...
- 答案是 0 个。
lsof -a -p <pid> -i列的是这个进程的网络套接字(TCP、UDP,监听的和已连接的都算),输出 0 行,退出码 1 表示没有匹配项。 - 去掉
-i再看同一个进程,有 64 个打开的文件描述符,说明lsof能看到这个进程,只是其中没有网络套接字。 - 同一时刻页面的地址是
tauri://localhost,这次report调用本身也已经到了 Rust。页面加载和 IPC 都没有经过网络栈:WebKit 把对这两个 scheme 的请求交给 wry 注册的WKURLSchemeHandler,在应用进程内回调 Rust。 PS几行是另一面:和应用同时启动了三个 WebKit 辅助进程(GPU、WebContent、Networking),pid 紧跟在应用之后,父进程都是 1(由系统的 XPC 机制拉起,不是应用的子进程)。页面的 JS 实际跑在 WebContent 进程里;「系统 webview」意味着这些进程由系统提供,不打进应用。
小结
- tauri 用系统 webview 跑前端、用 Rust 二进制做后端,页面和 IPC 都走注册在 webview 上的自定义 scheme,不起本地服务器,进程里没有网络套接字。
- 分层:前端 → IPC 入口(invoke key、ACL)→ 本体 →
tauri-runtimetrait →tauri-runtime-wry→ wry / tao → WKWebView / NSWindow;配置、资源和 ACL 在编译期定下来。 - 和 Electron 的分界是带不带渲染引擎;和 dioxus-desktop 的分界是 UI 逻辑在 Rust 还是在 JS;和直接用 wry 的分界是 tauri 补上的 IPC 约定、安全检查、编译期上下文和应用管理。