JS 侧:invoke 与回调
主线位置:调用主线的第一段和最后一段。
invoke登记一对回调、把参数编码、发出请求;Rust 应答之后,同一套代码按 id 找回调、兑现 Promise。中间 Rust 侧怎么解析请求见 ipc 协议。
flowchart TB
inv["invoke cmd payload"] --> reg["registerCallback 两次"]
reg --> ipcfn["__TAURI_INTERNALS__.ipc"]
ipcfn --> send["sendIpcMessage"]
send --> flag{"customProtocolIpcFailed"}
flag -->|false| proc["processIpcMessage payload"]
proc --> fetchp["fetch POST ipc://localhost/cmd"]
fetchp -->|成功| runcb["runCallback id data"]
fetchp -->|任一步失败| setflag["置 flag 并重发"]
setflag --> send
flag -->|true| procall["processIpcMessage 整条消息"]
procall --> pm["window.ipc.postMessage"]
pm --> evalcb["Rust eval runCallback"]
一次 invoke 已经看到:两个随机 u32 作回调 id,fetch 带四个头发到 ipc://localhost/<cmd>,CSP 拦下 fetch 时退回 postMessage。本页往下挖三件事:回调存在哪、什么时候删、晚到的应答怎么办;参数是什么类型时发 JSON、什么时候发字节,Rust 看到的是什么;退回 postMessage 的条件到底有多宽,退回之后发生什么。
实验应用 ipc1-lab(tauri =2.12.5,tracing feature,隐藏窗口,--features tauri/custom-protocol)有几个专门的命令:inspect(request: tauri::ipc::Request) 把收到的 Content-Type 和正文(InvokeBody)描述成字符串返回,not_json 返回一个声明为 JSON、内容却不是 JSON 的响应,slow 是一个先睡 300 ms 的 async 命令。dist/ 里每个实验一张页面,环境变量 PAGE 选择加载哪一张。页面共用的 lab.js 把 window.fetch 换成记录版本(ipc-protocol.js 调用时才查全局 fetch,见一次 invoke),并提供一个 forcePostMessage():临时让 fetch 直接拒绝,调一次 invoke,让页面切到 postMessage。平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建。
回调表:闭包里的一个 Map
core.js 的回调表是这样的:
const callbacks = new Map()
function registerCallback(callback, once) {
const identifier = uid()
callbacks.set(identifier, (data) => {
if (once) {
unregisterCallback(identifier)
}
return callback && callback(data)
})
return identifier
}
function unregisterCallback(id) {
callbacks.delete(id)
}
function runCallback(id, data) {
const callback = callbacks.get(id)
if (callback) {
callback(data)
} else {
console.warn(
`[TAURI] Couldn't find callback id ${id}. This might happen when the app is reloaded while Rust is running an asynchronous operation.`
)
}
}
callbacks 是这个立即执行函数里的局部变量。对外只暴露四个不可写、不可删的属性:transformCallback(就是 registerCallback,名字是历史遗留,源码注释自己写着「Maybe let's rename it to registerCallback?」)、unregisterCallback、runCallback,以及只读引用 callbacks(注释说「just for the debugging purposes」)。页面脚本可以读这张表、也能调 callbacks.delete,但换不掉 runCallback 本身。
once 为真时,包装函数先把自己从表里删掉再调用户回调。invoke 用的两个回调都是 once,而且互相清理:
const callback = registerCallback((r) => {
resolve(r)
unregisterCallback(error)
}, true)
const error = registerCallback((e) => {
reject(e)
unregisterCallback(callback)
}, true)
所以一次调用无论成败,兑现之后表里这一对都不在了(一次 invoke 的实验里 callbacksAfter 回到 0)。反过来,没兑现的调用会一直占着两项:Rust 侧不回答(比如 invoke key 不对,见分发),这两项就留到页面卸载为止。
id 是 crypto.getRandomValues 生成的 u32,不是计数器。好处是页面猜不到别人的 id,坏处是理论上会撞:registerCallback 不检查 Map 里是否已经有这个 id,撞上就覆盖。按 2³² 的空间,同时挂着的回调要到数万个才有可观的概率,实际不用担心。
invoke 本身不发请求,而是交给 __TAURI_INTERNALS__.ipc。core.js 里还有一个 ipcQueue:调用时 ipc 还不存在,就把发送动作排队,每 50 ms 查一次。默认配置下用不上:一次启动测出 ipc.js 和 core.js 拼在同一段初始化脚本里,ipc 先于 invoke 定义。没开隔离模式时,ipc 原样转给 ipc-protocol.js 定义的 postMessage,也就是下面的 sendIpcMessage。
window 上#13325(「refactor: rework transformCallback」,首次发布于 tauri 2.6.0)之前,transformCallback 用 Object.defineProperty(window, '_' + id, ...) 把每个回调挂成 window 的一个属性,Rust 送回来的 JS 是 window["_<id>"](...),前面再包一层 if 判断属性在不在。CHANGELOG 的原话是:「transformCallback now registers the callbacks inside window.__TAURI_INTERNALS__.callbacks instead of directly on window['_{id}']」。改完之后查找和「找不到就警告」都收进了 runCallback,Rust 侧的 format_raw_js 只需生成 window.__TAURI_INTERNALS__.runCallback(<id>, <js>) 一行。
晚到的应答会怎样?runCallback 找不到 id 时只打那条警告,不抛错。什么时候会找不到,本页最后一节用页面刷新来造。
processIpcMessage:JSON 还是字节
sendIpcMessage 先用 processIpcMessage 处理参数。它不是独立的脚本,而是一个函数表达式,由 Builder::new 渲染 InvokeInitializationScript 时作为 #[raw] 字段原样嵌进 ipc-protocol.js 的 __RAW_process_ipc_message_fn__ 位置:
(function (message) {
if (
message instanceof ArrayBuffer
|| ArrayBuffer.isView(message)
|| Array.isArray(message)
) {
return {
contentType: 'application/octet-stream',
data: message
}
} else {
const data = JSON.stringify(message, (_k, val) => {
// if this value changes, make sure to update it in:
// 1. ipc.js
// 2. core.ts
const SERIALIZE_TO_IPC_FN = '__TAURI_TO_IPC_KEY__'
if (val instanceof Map) {
return Object.fromEntries(val.entries())
} else if (val instanceof Uint8Array) {
return Array.from(val)
} else if (val instanceof ArrayBuffer) {
return Array.from(new Uint8Array(val))
} else if (
typeof val === 'object'
&& val !== null
&& SERIALIZE_TO_IPC_FN in val
) {
return val[SERIALIZE_TO_IPC_FN]()
} else {
return val
}
})
// ...
判断只看顶层:参数本身是 ArrayBuffer、类型化数组(ArrayBuffer.isView)或数组,就把它原样交给 fetch 当正文,Content-Type: application/octet-stream;其余一律 JSON.stringify,替换函数把嵌套的 Map 变对象、Uint8Array 和 ArrayBuffer 变数字数组,有 __TAURI_TO_IPC_KEY__ 方法的对象(@tauri-apps/api 的 PhysicalSize 这类)调它。
encode.html 把十种参数各用 invoke('inspect', p) 发一次,记下 JS 发出的 Content-Type 和正文,以及 inspect 报回来的 Rust 侧所见;再 forcePostMessage(),把同样十种走 postMessage 再发一次:
const bytes = new Uint8Array([1, 2, 3]);
const cases = [
['{a:1}', () => ({ a: 1 })],
["'hi'", () => 'hi'],
['null', () => null],
['undefined', () => undefined],
['Uint8Array[1,2,3]', () => bytes],
['ArrayBuffer[1,2,3]', () => bytes.buffer],
['Float32Array[1.5]', () => new Float32Array([1.5])],
['[1,2,3]', () => [1, 2, 3]],
['{bytes:Uint8Array[1,2]}', () => ({ bytes: new Uint8Array([1, 2]) })],
['{m:Map{k:1}}', () => ({ m: new Map([['k', 1]]) })],
];
REPORT {
"fetch": [
"{a:1} -> sent application/json {\"a\":1} | rust application/json Json({\"a\":1})",
"'hi' -> sent application/json \"hi\" | rust application/json Json(\"hi\")",
"null -> sent application/json null | rust application/json Json(null)",
"undefined -> sent application/json {} | rust application/json Json({})",
"Uint8Array[1,2,3] -> sent application/octet-stream Uint8Array | rust application/octet-stream Raw(3 bytes, head [1, 2, 3])",
"ArrayBuffer[1,2,3] -> sent application/octet-stream ArrayBuffer | rust application/octet-stream Raw(3 bytes, head [1, 2, 3])",
"Float32Array[1.5] -> sent application/octet-stream Float32Array | rust application/octet-stream Raw(4 bytes, head [0, 0, 192, 63])",
"[1,2,3] -> sent application/octet-stream Array | rust application/octet-stream Raw(5 bytes, head [49, 44, 50, 44, 51])",
"{bytes:Uint8Array[1,2]} -> sent application/json {\"bytes\":[1,2]} | rust application/json Json({\"bytes\":[1,2]})",
"{m:Map{k:1}} -> sent application/json {\"m\":{\"k\":1}} | rust application/json Json({\"m\":{\"k\":1}})"
],
"postMessage": [
"{a:1} -> rust null Json({\"a\":1})",
"'hi' -> rust null Json(\"hi\")",
"null -> rust null Json(null)",
"undefined -> rust null Json({})",
"Uint8Array[1,2,3] -> rust null Json([1,2,3])",
"ArrayBuffer[1,2,3] -> rust null Json([1,2,3])",
"Float32Array[1.5] -> rust null Json({\"0\":1.5})",
"[1,2,3] -> rust null Json([1,2,3])",
"{bytes:Uint8Array[1,2]} -> rust null Json({\"bytes\":[1,2]})",
"{m:Map{k:1}} -> rust null Json({\"m\":{\"k\":1}})"
],
...
}
逐行看:
| 参数 | fetch 路:Rust 收到 | postMessage 路:Rust 收到 |
|---|---|---|
对象、字符串、null | application/json,InvokeBody::Json | Json,同样的值 |
undefined | Json({}):invoke 的默认参数 payload = {} 先生效 | 同左 |
Uint8Array、ArrayBuffer | application/octet-stream,InvokeBody::Raw,3 个字节 | Json([1,2,3]),数字数组 |
Float32Array | Raw,4 个字节(1.5 的小端 IEEE 754) | Json({"0":1.5}):替换函数只认 Uint8Array,别的类型化数组被 JSON.stringify 当成普通对象 |
普通数组 [1,2,3] | Raw,5 个字节 49 44 50 44 51,即字符串 "1,2,3" | Json([1,2,3]) |
嵌套的 Uint8Array、Map | Json,数组 / 对象 | 同左 |
有两处值得记住。
第一,顶层的普通数组。Array.isArray 让它走字节分支,可 fetch 的 body 不接受 JS 数组,WebKit 把它转成字符串 "1,2,3" 再发出去。Rust 收到 5 个 ASCII 字节,而不是 3 个数,也不是 JSON 数组。这个分支从 #7170 引入 process-ipc-message-fn.js 起就在;正常用法里 invoke 的参数是一个按参数名组织的对象,碰不到它,但要直接把一个数组当正文发时(比如命令只收 tauri::ipc::Request),得先包成 Uint8Array。
第二,同一个调用在两条路上 Rust 看到的类型不同。fetch 路上 Uint8Array 是 Raw,postMessage 路上是 Json 数字数组:因为 postMessage 只能传字符串,sendIpcMessage 把命令名、两个 id、选项、参数和 invoke key 打成一个对象整体 JSON.stringify,顶层不再是字节,嵌在里面的字节全变成数组。命令参数声明成 Vec<u8> 时两种都能反序列化;只收 tauri::ipc::Request、自己 match InvokeBody::Raw 的命令,在退回 postMessage 之后就会拿到意料之外的 Json。Rust 侧怎么从 InvokeBody 取参数见参数、返回值与 InvokeResolver。
options.headers
invoke 的第三个参数 options 里可以带 headers。fetch 路上它们先被放进 new Headers(...),然后 Content-Type、Tauri-Callback、Tauri-Error、Tauri-Invoke-Key 四个用 set 覆盖,所以页面改不了这四个。实验里 invoke('inspect', {}, { headers: { 'X-Demo': 'yes' } }) 在两条路上 Rust 都看到了 x-demo:
"fetchHeaders": {
"body": "Json({})",
"contentType": "application/json",
"origin": "tauri://localhost",
"xDemo": "yes"
},
...
"pmHeaders": {
"body": "Json({})",
"contentType": null,
"origin": null,
"xDemo": "yes"
},
postMessage 路上头表只有页面自己给的那些:options.headers 随消息一起序列化,Rust 侧还原成 HeaderMap,没有 Content-Type,也没有 WebKit 加的 Origin。来源 URL 在这条路上另有出处,见 ipc 协议。
退回 postMessage:条件比「CSP 拦截」宽得多
sendIpcMessage 的 fetch 分支后面是这样一条 Promise 链:
fetch(window.__TAURI_INTERNALS__.convertFileSrc(cmd, 'ipc'), {
method: 'POST',
body: data,
headers
})
.then((response) => {
const callbackId =
response.headers.get('Tauri-Response') === 'ok' ? callback : error
// we need to split here because on Android the content-type gets duplicated
switch ((response.headers.get('content-type') || '').split(',')[0]) {
case 'application/json':
return response.json().then((r) => [callbackId, r])
case 'text/plain':
return response.text().then((r) => [callbackId, r])
default:
return response.arrayBuffer().then((r) => [callbackId, r])
}
})
.then(
([callbackId, data]) => {
window.__TAURI_INTERNALS__.runCallback(callbackId, data)
},
(e) => {
console.warn(
'IPC custom protocol failed, Tauri will now use the postMessage interface instead',
e
)
// failed to use the custom protocol IPC (either the webview blocked a custom protocol or it was a CSP error)
// so we need to fallback to the postMessage interface
customProtocolIpcFailed = true
sendIpcMessage(message)
}
)
第二个 .then 的拒绝处理函数接住的是它前面整条链上的任何拒绝:fetch 本身被拒(CSP、请求被取消),以及第一个 .then 里 response.json() / text() / arrayBuffer() 解析失败。注释只提到前一类,代码对两类一视同仁:置 customProtocolIpcFailed = true,再用同一个 message(同样的两个回调 id)调一次 sendIpcMessage。这个变量是 ipc-protocol.js 闭包里的 let,一旦为真,这个页面以后的每次调用都走 postMessage,直到页面重新加载。
注意「重发」的含义:如果第一次请求已经到达 Rust、命令已经执行,那么它会再执行一次。
#14377(「fix: throw on custom protocol IPC fails」,首次发布于 tauri 2.9.2)之前,这段链写成 .then(解析).catch(退回).then(([callbackId, data]) => runCallback(...)):catch 里重发之后返回 undefined,最后那个 .then 去解构 undefined,又抛一个错。CHANGELOG 的原话是「Fix undefined is not an object (evaluating '[callbackId, data]') error on custom protocol IPC fails」。改成 .then(成功, 失败) 两参数形式之后,失败分支和成功分支互斥。顺带的变化是:runCallback 里(也就是用户的 resolve 链上)抛出的错误不再被当成「协议失败」去触发退回。
not_json 命令返回 tauri::ipc::Response::new("hello".to_string())。Response 收 String 时走 From<String> for InvokeResponseBody,得到 InvokeResponseBody::Json,协议应答于是带着 Content-Type: application/json,正文却是 hello:
#[tauri::command]
fn not_json() -> Response {
let n = NOT_JSON_CALLS.fetch_add(1, Ordering::SeqCst) + 1;
println!("RUST not_json called, count = {n}");
Response::new("hello".to_string())
}
notjson.html 调它一次,再调一次 greet,看后者有没有经过 fetch:
RUST not_json called, count = 1
RUST not_json called, count = 2
REPORT {
"callbacksBefore": 0,
"notJsonRejected": "JSON error: expected value at line 1 column 1",
"fetchesSoFar": [
"ipc://localhost/not_json"
],
"greet": "hello, after",
"greetUsedFetch": false,
"callbacksAfter": 0,
"warns": [
"IPC custom protocol failed, Tauri will now use the postMessage interface instead SyntaxError: The string did not match the expected pattern."
]
}
页面只发出了一个 fetch,命令却执行了两次。顺序是:fetch 成功、响应头 Tauri-Response: ok,response.json() 解析 hello 失败(WebKit 的 SyntaxError),被当成协议失败;ipc-protocol.js 打警告、切换、经 postMessage 重发;Rust 第二次执行 not_json,这次走 eval 应答,Rust 侧把 hello 当原始 JSON 嵌进脚本之前先校验,失败,于是改调失败回调,Promise 以 JSON error: expected value at line 1 column 1 拒绝(这段在 ipc 协议)。之后的 greet 已经不走 fetch 了。
正确的写法是让 Response 收 Vec<u8>(得到 application/octet-stream,JS 侧拿到 ArrayBuffer),或者返回 String 让 serde 序列化成带引号的 JSON 字符串。
另一种「请求已到达、fetch 却被拒」的情形是页面导航:页面在 invoke 还没应答时刷新或跳走,WebKit 取消这个 WKURLSchemeTask,fetch 以拒绝结束。reload.html 造的就是这个:第一阶段调 slow 但不等它,立刻 location.reload();第二阶段等 800 ms 收集警告,然后 forcePostMessage(),再调 slow、再刷新;第三阶段等 800 ms,再手动调一次 runCallback(12345, 'late'),然后报告:
const h = location.hash;
if (h === '') {
inv('slow', { ms: 300 });
location.hash = '#2';
location.reload();
} else if (h === '#2') {
await wait(800);
const phase2 = { afterFetchReload: warns.slice(), callbacks: ti.callbacks.size };
await forcePostMessage();
inv('slow', { ms: 300 });
location.hash = '#3:' + encodeURIComponent(JSON.stringify(phase2));
location.reload();
} else {
await wait(800);
const phase2 = JSON.parse(decodeURIComponent(h.slice(3)));
ti.runCallback(12345, 'late');
await done({ phase2, afterPostMessageReload: warns.slice(), callbacks: ti.callbacks.size });
}
slow 在开头和应答前各打一行 RUST slow(300) start / RUST slow(300) responding。
slow 被调用了两次(两个阶段各一次)。Rust 一共会打几行 RUST slow(300) start?第二阶段刷新前收集到的警告是空的吗?平台 M2 Max、macOS 27,tauri 2.12.5,debug 构建。两条路上「晚到」的含义不同。fetch 路上应答属于一个已经取消的 WKURLSchemeTask,wry 回答前比对 task 的 UUID,对不上就静默丢弃(见 wry 在 macOS 上),新页面什么也收不到。postMessage 路上应答是一段 eval,它不知道页面换过了,照样在新页面里执行 runCallback(<旧 id>, ...),于是新页面打出那条「Couldn't find callback id」警告。
postMessage 路上发的是什么
切换之后,sendIpcMessage 走 else 分支:
const { data } = processIpcMessage({
cmd,
callback,
error,
options: {
...options,
customProtocolIpcBlocked: customProtocolIpcFailed
},
payload,
__TAURI_INVOKE_KEY__
})
// `window.ipc.postMessage` came from `tauri-runtime-wry` > `wry` [`with_ipc_handler`](https://github.com/tauri-apps/wry/blob/a0403b9e2f1ff9d73be7dce1184f058afcaa1d82/src/lib.rs#L1130)
window.ipc.postMessage(data)
processIpcMessage 这次处理的是整条消息,参数只是其中的 payload 字段,所以永远走 JSON 分支,data 是一个字符串,形如(实验里 reload.html 第二阶段那次的前 120 个字符,由 tracing 记下):
{\"cmd\":\"slow\",\"callback\":57647707,\"error\":704807906,\"options\":{\"customProtocolIpcBlocked\":true},\"payload\"...
options.customProtocolIpcBlocked 告诉 Rust「这个页面的 fetch 已经坏了」,Rust 据此决定应答时用不用 Channel;invoke key 从头里挪进了正文的 __TAURI_INVOKE_KEY__ 字段。window.ipc 是 wry 注入的冻结对象(Object.freeze({ postMessage: ... })),页面换不掉它的 postMessage。
ipc-protocol.js 里还有一个和 customProtocolIpcFailed 并列的条件:canUseCustomProtocol = osName !== 'android'。Android 的 webview 读不到自定义协议请求的正文,所以那里一开始就走 postMessage,只有 Channel 取数据的命令 plugin:__TAURI_CHANNEL__|fetch 例外。桌面上(本页实测的 macOS,以及只读了源码的 Windows、Linux)默认都走 fetch。
Rust 侧怎么把这个字符串拼回 InvokeRequest、应答怎么变成 eval、两条路搬 5 MiB 各要多久,见 ipc 协议。
动脑筋答案
RUST slow(300) start
RUST slow(300) start
RUST slow(300) responding
RUST slow(300) responding
RUST slow(300) start
RUST slow(300) responding
REPORT {
"phase2": {
"afterFetchReload": [
"[TAURI] Couldn't find callback id 2053258919. This might happen when the app is reloaded while Rust is running an asynchronous operation."
],
"callbacks": 0
},
"afterPostMessageReload": [
"[TAURI] Couldn't find callback id 2554231366. This might happen when the app is reloaded while Rust is running an asynchronous operation.",
"[TAURI] Couldn't find callback id 12345. This might happen when the app is reloaded while Rust is running an asynchronous operation."
],
"callbacks": 0
}
- 三行
start,不是两行。第一阶段的slow执行了两次:刷新取消了fetch,旧页面在卸载前跑到了拒绝处理函数,把它当成协议失败,经postMessage重发。开着tracing时能看到这条重发:ipc::request kind="post-message" uri="tauri://localhost/reload.html" request="{\"cmd\":\"slow\",...,\"options\":{\"customProtocolIpcBlocked\":true}...,时间紧跟在新页面的tauri://localhost/reload.html#2请求之后。这个结果连跑两次都一样(ipc1-reload-1.txt、ipc1-reload-2.txt)。 - 第二阶段的警告不是空的。旧页面那次
fetch的应答被 wry 丢弃了,但重发的那次走postMessage,300 ms 后它的eval落进了新页面,新页面的回调表里没有旧 id,runCallback打警告。 - 第三阶段:第二阶段的
slow本来就走postMessage,刷新不会让它失败,所以只执行一次(第三行start),它的应答同样落进新页面、同样警告。手动的runCallback(12345, 'late')打出同一句话。三个阶段结束时回调表都是空的:警告不会往表里加东西。
刷新时正在执行的命令如果不是幂等的(写文件、扣款、发消息),刷新本身就可能让它执行两次。
小结
- 回调存在
core.js闭包的Map里,id 是随机 u32;invoke的一对回调都是一次性的、互相清理;没有应答的调用会一直占着表项。 - 找不到 id 时
runCallback只打警告。postMessage路上的晚到应答会在新页面里触发这条警告,fetch路上的晚到应答由 wry 静默丢弃。 processIpcMessage只看顶层:ArrayBuffer、类型化数组、数组走application/octet-stream,其余JSON.stringify;顶层普通数组会被发成它的字符串形式"1,2,3"。- 走
postMessage时整条消息是一个 JSON 字符串,Rust 收到的正文永远是Json,字节变成数字数组,Float32Array变成对象。 fetch链上任何一步失败(包括响应声明是 JSON 却解析不了、页面刷新取消了请求)都会让页面永久改用postMessage并重发这条消息,命令可能因此执行两次。