Rust in Everything
基于 tauri 2.12.5

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。

📝NOTE
冷知识:回调以前直接挂在 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 收到
对象、字符串、nullapplication/json,InvokeBody::JsonJson,同样的值
undefinedJson({}):invoke 的默认参数 payload = {} 先生效同左
Uint8Array、ArrayBufferapplication/octet-stream,InvokeBody::Raw,3 个字节Json([1,2,3]),数字数组
Float32ArrayRaw,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、MapJson,数组 / 对象同左

有两处值得记住。

第一,顶层的普通数组。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、命令已经执行,那么它会再执行一次。

📝NOTE
冷知识:退路本身出过一次错

#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。

❗IMPORTANT
动脑筋:整个实验里 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 并重发这条消息,命令可能因此执行两次。

💬 关联讨论

发起讨论
加载中...