IPC 桥接:JavaScript 的 invoke() 如何抵达 Rust 命令
前置知识
- ›第 1 篇:架构与 Crate 全景图
- ›第 2 篇:应用生命周期与 Builder 模式
- ›理解 HTTP 自定义协议与 URI Scheme
- ›Rust 过程宏基本概念
- ›TypeScript/JavaScript 基础知识
IPC 桥接:JavaScript 的 invoke() 如何抵达 Rust 命令
JavaScript 与 Rust 之间的桥接,是每一个 Tauri 应用的核心所在。当前端调用 invoke("greet", { name: "World" }) 时,这条消息会经历一段完整的旅程:穿越自定义 URI 协议、通过安全校验、路由到正确的命令处理器、完成参数反序列化,最后将响应原路返回。本文将逐跳拆解这一过程。
JS 侧:invoke() 与 TAURI_INTERNALS
面向开发者的 API 位于 packages/api/src/core.ts。invoke() 函数本身极为精简:
async function invoke<T>(
cmd: string,
args: InvokeArgs = {},
options?: InvokeOptions
): Promise<T> {
return window.__TAURI_INTERNALS__.invoke(cmd, args, options)
}
真正的逻辑都在 window.__TAURI_INTERNALS__ 中。这个对象由 InvokeInitializationScript 负责初始化——这是一段在编译期渲染的 JavaScript 模板,其中已内嵌 invoke key、操作系统名称以及 IPC 处理函数。
初始化脚本(模板来自 scripts/ipc-protocol.js)创建的 __TAURI_INTERNALS__.invoke 会依次完成以下操作:
- 对参数值执行
SERIALIZE_TO_IPC_FN转换 - 通过
transformCallback()生成回调 ID 和错误 ID - 向
ipc://localhost自定义协议发送 HTTP POST 请求,并在请求头中携带回调 ID、错误 ID 以及 invoke key - 返回一个
Promise,待回调触发时 resolve
sequenceDiagram
participant App as Frontend Code
participant TI as __TAURI_INTERNALS__
participant IPC as ipc:// Protocol Handler
participant WV as Webview::on_message()
App->>TI: invoke("greet", {name: "World"})
TI->>TI: serialize args, create callback
TI->>IPC: POST ipc://localhost/greet<br/>Headers: Tauri-Callback, Tauri-Error, Tauri-Invoke-Key
IPC->>IPC: parse_invoke_request()
IPC->>WV: on_message(InvokeRequest)
WV->>WV: verify invoke key
WV->>WV: ACL check
WV->>WV: dispatch to command handler
WV-->>IPC: InvokeResponse
IPC-->>TI: HTTP Response with Tauri-Response header
TI-->>App: Promise resolves/rejects
传输层:自定义协议与请求头
在 Rust 侧,IPC 协议处理器注册于 crates/tauri/src/ipc/protocol.rs。IPC 元数据通过三个自定义请求头传递:
const TAURI_CALLBACK_HEADER_NAME: &str = "Tauri-Callback";
const TAURI_ERROR_HEADER_NAME: &str = "Tauri-Error";
const TAURI_INVOKE_KEY_HEADER_NAME: &str = "Tauri-Invoke-Key";
get() 函数将自身注册为 URI scheme 协议处理器。当收到 POST 请求时,它会依次执行以下步骤:
- 调用
parse_invoke_request(),从 URL 路径中提取命令名,从 HTTP 请求中提取请求头,并解析请求体(JSON 或原始字节) - 根据 label 查找对应的 webview
- 将解析好的
InvokeRequest传递给webview.on_message() - 通过回调闭包接收响应,并将其映射为 HTTP 响应——响应头
Tauri-Response的值为"ok"或"error"
每个响应都会附加 CORS 头(Access-Control-Allow-Origin: *),OPTIONS 预检请求则只返回 CORS 头。这是因为 webview 的 origin 与 ipc:// scheme 不同,必须显式允许跨域访问。
flowchart TD
REQ["HTTP POST to ipc://localhost/{cmd}"] --> PARSE["parse_invoke_request()"]
PARSE --> LOOKUP["Find webview by label"]
LOOKUP -->|"Found"| ONMSG["webview.on_message(request)"]
LOOKUP -->|"Not found"| ERR404["404 Response"]
ONMSG --> RESPOND["Invoke responder callback"]
RESPOND --> OK["Tauri-Response: ok<br/>200 + body"]
RESPOND --> FAIL["Tauri-Response: error<br/>400 + error body"]
Invoke Key 校验与 ACL 检查
Webview 上的 on_message() 方法实现了两阶段安全门控。
第一阶段:Invoke Key 校验(第 1723–1738 行)。每个 IPC 请求都携带着在 webview 初始化时注入的 invoke key。处理器会将其与 AppManager 中存储的 key 进行比对,若不匹配则静默丢弃请求。这一机制可防止恶意脚本伪造 IPC 调用——只有在正确初始化的 Tauri webview 中运行的代码才拥有这把密钥。
第二阶段:ACL 解析(第 1771–1826 行)。RuntimeAuthority 负责判断当前 webview 是否有权调用该命令:
let (resolved_acl, has_app_acl_manifest) = {
let runtime_authority = manager.runtime_authority.lock().unwrap();
let acl = runtime_authority.resolve_access(
&request.cmd,
message.webview.window_ref().label(),
message.webview.label(),
&acl_origin,
);
(acl, runtime_authority.has_app_manifest())
};
resolve_access 方法对命令名执行 BTree 查找,然后检查发起请求的 webview label 和所在 window 的 label 是否匹配已解析权限中的任意模式。如果命令受 ACL 保护且没有匹配的权限,请求将被拒绝——在 debug 构建中会返回详细的错误信息,在 release 构建中则统一返回"不允许"的通用提示。
提示: 对于没有定义任何权限的命令,ACL 检查会被跳过,除非应用本身定义了 ACL manifest。这意味着没有 capability 文件的简单应用开箱即用——安全系统是渐进增强的,对应用自定义命令并不会默认拦截。
命令路由:核心命令与插件命令
安全检查通过后,命令将根据名称进行路由。插件命令遵循固定的命名约定:
plugin:{plugin_name}|{command_name}
第 1788–1794 行的解析逻辑如下:
let plugin_command = request.cmd.strip_prefix("plugin:").map(|raw_command| {
let mut tokens = raw_command.split('|');
let plugin = tokens.next().unwrap();
let command = tokens.next().map(|c| c.to_string()).unwrap_or_default();
(plugin, command)
});
flowchart TD
CMD["Incoming Command"] --> CHECK{"Starts with 'plugin:'?"}
CHECK -->|"Yes"| SPLIT["Split on '|'"]
SPLIT --> PLUGIN["plugin_name = 'fs'<br/>command = 'read_file'"]
PLUGIN --> DISPATCH_P["plugins.extend_api(invoke)"]
CHECK -->|"No"| CORE["Regular command"]
CORE --> DISPATCH_C["invoke_handler(invoke)"]
插件命令会被分发到该插件的 extend_api() 方法;普通命令则直接交给 invoke_handler——也就是由 generate_handler! 生成的那个函数。
#[command] 宏与 CommandArg Trait
#[command] 过程宏将一个普通的 Rust 函数转换为兼容 IPC 的命令。以下面这个函数为例:
#[tauri::command]
fn greet(name: String, state: State<'_, AppState>) -> String {
format!("Hello, {}!", name)
}
该宏会生成一个包装函数,依次完成以下操作:
- 通过
CommandArgtrait 提取每个参数 - 调用原始函数
- 将返回值包装为
InvokeResponse
CommandArg trait 是这里的核心抽象:
pub trait CommandArg<'de, R: Runtime>: Sized {
fn from_command(command: CommandItem<'de, R>) -> Result<Self, InvokeError>;
}
对于所有实现了 Deserialize 的类型,框架提供了一个 blanket 实现(第 62 行),直接从 JSON 请求体中反序列化参数值。而 State<T>、Window、Webview、AppHandle 等特殊类型则有各自的自定义实现,它们从调用上下文中提取值,完全不触碰 JSON payload。正因如此,你才可以在命令签名中同时混用 JSON 反序列化的参数和框架内置类型。
generate_handler! 宏接收一组命令函数,生成一个根据命令名进行匹配并调用对应包装函数的闭包。
Channel API:向前端推送流式数据
IPC 并非只有请求-响应这一种模式。Channel API 支持从 Rust 向 JavaScript 推送流式数据,适用于进度事件、文件系统监听器或任何长生命周期的数据流场景。
在 Rust 侧,Channel<TSend> 封装了一个带有 on_message 回调的 Arc<ChannelInner>。其中一个关键优化是基于阈值的传输策略(第 35–39 行):
const MAX_JSON_DIRECT_EXECUTE_THRESHOLD: usize = 8192;
const MAX_RAW_DIRECT_EXECUTE_THRESHOLD: usize = 1024;
小体积的 payload(JSON 低于 8KB,原始数据低于 1KB)会通过直接在 webview 中执行 JavaScript 的方式发送,本质上是调用 window.__TAURI_INTERNALS__.callback(id, data)。较大的 payload 则会存入 ChannelDataIpcQueue,由 JS 侧通过特殊的 plugin:__TAURI_CHANNEL__|fetch 命令主动拉取。这样可以避免将大体积二进制数据 base64 编码后塞入 eval 字符串所带来的额外开销。
在 JavaScript 侧,Channel 类通过基于索引的机制来保证消息的有序处理:
sequenceDiagram
participant Rust as Rust Channel
participant WV as WebView
participant JS as JS Channel
Rust->>WV: send(index=0, message=A)
Rust->>WV: send(index=1, message=B)
Note over WV: index=1 arrives first (out of order)
WV->>JS: callback({index: 1, message: B})
JS->>JS: Queue B at index 1
WV->>JS: callback({index: 0, message: A})
JS->>JS: Process A (index matches)
JS->>JS: Drain queue: process B
Rust->>WV: send({end: true, index: 2})
JS->>JS: All messages received, cleanup
每条消息都携带一个索引。JS 侧的 Channel 通过 #nextMessageIndex 追踪当前期望的索引,只有索引匹配时才处理消息。乱序到达的消息会暂存于 #pendingMessages,直到期望的索引到来。当 Rust 侧释放 channel 时,会发送一个 {end: true, index: N} 信号,通知 JS 侧进行清理。
提示:
SERIALIZE_TO_IPC_FNsymbol(导出名为'__TAURI_TO_IPC_KEY__')允许你为复杂的 TypeScript 类型定义自定义序列化逻辑。如果你的类实现了[SERIALIZE_TO_IPC_FN](),该方法会在值通过 IPC 发送前被调用——对于那些无法自然映射到 JS 对象的 Rust enum 表示形式来说,这非常实用。
下一篇文章将深入探讨守护这一切 IPC 流量的安全模型——基于 capability 的 ACL 系统、scope 约束,以及应对 Web 侧攻击的多层防线。