Rust in Everything
基于 tauri 2.12.5

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 要解决的是三件事:

  1. 界面:不自带浏览器引擎,用操作系统已经有的 webview。代价是各平台的渲染引擎不同,同一份前端在 WKWebView、WebView2、WebKitGTK 上的行为可能有差别。
  2. 页面从哪来:不起本地 HTTP 服务。前端产物在编译期压缩进二进制,运行时 webview 请求 tauri://localhost/...,由注册在 webview 上的 URI scheme 处理函数直接从内存里取。
  3. JS 怎么调 Rust:同样不走网络。invoke 用 fetch 请求另一个自定义 scheme ipc://,请求在进程内交给 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 辅助进程。结果放在本页末尾的动脑筋答案里。

❗IMPORTANT
动脑筋:页面已经加载完、invoke 已经到达 Rust 的那一刻,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 在编译期就定下来,做成 Context02 编译期

tauri 本体对 R: Runtime 泛型,Builder::default() 选的是 Wry;test feature 里的 MockRuntime 是第二个实现,不开窗口就能跑 IPC。中间这层抽象的代价和收益放在设计取舍里。

安全检查卡在哪一层

前端被看作不完全可信:页面可能加载第三方脚本,也可能整个是远程页面。所以检查放在 IPC 入口、命令执行之前,在 Webview::on_message 里依次是:

  1. invoke key:Builder::new 时生成的随机串,只存在于注入脚本的闭包里,请求头里带的不对就丢弃请求。它防的是没经过 tauri 初始化的 frame 直接构造请求。
  2. ACL:按命令名、窗口标签、webview 标签和来源(本地 tauri:// 还是远程 URL)查编译期解析好的权限表。是否强制检查有一个条件:插件命令、应用自己声明了 ACL 清单、来源是远程,三者之一成立。

实验模板既没有 capabilities/ 目录,也没有声明应用命令的权限清单,它的 greet、report 照样能从本地页面调用,就是因为三个条件都不成立。这条规则的含义和远程页面的情形见运行时检查。

和 Electron 比:带不带浏览器

Electron 把 Chromium 和 Node.js 一起打进应用,所有平台上是同一个渲染引擎,主进程跑 JS。tauri 反过来:不带渲染引擎,后端是编译好的 Rust。两边的差别都由这一个选择派生出来:

tauriElectron
渲染引擎系统 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 做成 Context02 编译期
窗口和 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-runtime trait → tauri-runtime-wry → wry / tao → WKWebView / NSWindow;配置、资源和 ACL 在编译期定下来。
  • 和 Electron 的分界是带不带渲染引擎;和 dioxus-desktop 的分界是 UI 逻辑在 Rust 还是在 JS;和直接用 wry 的分界是 tauri 补上的 IPC 约定、安全检查、编译期上下文和应用管理。

💬 关联讨论

发起讨论
加载中...