Rust in Everything
基于 tauri 2.12.5

一次 invoke

主线位置:调用主线从头到尾。本页不深入任何一层,只把 invoke('greet', { name }) 经过的函数串起来:先用一个实验同时抓 JS 侧发出的请求、Rust 侧收到的请求和每一步所在的线程,再逐步走一遍,每一段末尾链到详细讲它的那一章。

sequenceDiagram
  participant P as 页面 JS
  participant C as core.js
  participant I as ipc-protocol.js
  participant K as WebKit 和 wry
  participant H as ipc 协议
  participant V as on_message
  participant D as 命令包装函数
  P->>C: invoke greet
  C->>C: 登记 cbid 和 errid
  C->>I: ipc 再到 postMessage
  I->>K: fetch POST ipc://localhost/greet
  K->>H: start_task 主线程
  H->>V: parse_invoke_request
  V->>V: 比对 invoke key 查 ACL
  V->>D: generate_handler 分发
  D->>H: InvokeResolver 应答
  H->>K: 200 加 Tauri-Response
  K-->>I: Response
  I->>C: runCallback cbid
  C-->>P: Promise 兑现
步骤文件 · 函数关键的一两行详见
1. 登记一对回调 idscripts/core.js · invokereturn window.crypto.getRandomValues(new Uint32Array(1))[0]JS 侧的 invoke
2. 编码参数,fetch 到 ipc://scripts/ipc-protocol.js · sendIpcMessagefetch(window.__TAURI_INTERNALS__.convertFileSrc(cmd, 'ipc'), { method: 'POST', ... })JS 侧的 invoke
3. WebKit 把请求交给 scheme handlerwry · start_tasklet mut http_request = Request::builder().uri(uri).method(method.as_str());wry 在 macOS 上
4. 拼出 InvokeRequestipc/protocol.rs · get、parse_invoke_request.ok_or("missing Tauri-Invoke-Key header")?ipc 协议
5. invoke key、ACL、分流webview/mod.rs · Webview::on_messageif request.invoke_key != expected { ... return; }分发、运行时检查
6. 按命令名分发,包装函数取参数、调函数tauri-macros/src/command/wrapper.rs · body_blocking、body_async#resolver.respond_async_serialized(async move {#[command] 与 generate_handler!、参数与 InvokeResolver
7. 结果编码成 HTTP 响应ipc/mod.rs · return_result;ipc/protocol.rs 里的应答闭包.insert(TAURI_RESPONSE_HEADER_NAME, response_header.parse().unwrap());参数与 InvokeResolver
8. 选回调、解析正文、兑现 Promisescripts/ipc-protocol.js;scripts/core.js · runCallbackresponse.headers.get('Tauri-Response') === 'ok' ? callback : errorJS 侧的 invoke

实验:两头一起抓

模板同一次启动:隐藏窗口,tracing feature,加上同一个只打一行的 tracing layer。四个命令:同步的 greet、async 的 greet_async、把收到的请求头原样打印出来的 inspect(参数 tauri::ipc::Request 能拿到这次调用的头和正文),以及收尾的 report。命令里都记一行日志,好看出在哪个线程上:

mod trace;
use tauri::{ipc::Request, Manager};
use tracing_subscriber::prelude::*;

fn here(what: &str) {
  tracing::info!(target: "exp", "{what}");
}

#[tauri::command]
fn greet(name: &str) -> String {
  here(&format!("command greet(name={name:?})"));
  format!("hello, {name}")
}

#[tauri::command]
async fn greet_async(name: String) -> String {
  here(&format!("command greet_async(name={name:?})"));
  format!("hello again, {name}")
}

#[tauri::command]
fn inspect(webview: tauri::Webview, request: Request<'_>) {
  here("command inspect");
  println!("RUST url of webview = {}", webview.url().unwrap());
  for (k, v) in request.headers() {
    println!("RUST header {k}: {}", v.to_str().unwrap_or("<bin>"));
  }
  println!("RUST body = {:?}", request.body());
}

#[tauri::command]
fn report(app: tauri::AppHandle, msg: String) {
  println!("RUST invoke_key = {}", app.invoke_key());
  println!("REPORT {msg}");
  app.exit(0);
}

fn main() {
  trace::init();
  tracing_subscriber::registry().with(trace::Line).init();
  std::thread::spawn(|| {
    std::thread::sleep(std::time::Duration::from_secs(15));
    eprintln!("TIMEOUT");
    std::process::exit(2);
  });
  tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![greet, greet_async, inspect, report])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

JS 侧在调用之前先把全局的 fetch 换成一个记录版本。ipc-protocol.js 是在调用时才查找全局 fetch 的,所以它发出的请求和收到的响应都会经过这个包装;__TAURI_INTERNALS__ 上的属性改不了,window.fetch 可以:

<script>
const ti = window.__TAURI_INTERNALS__;
const log = [];
const origFetch = window.fetch;
window.fetch = async (url, init) => {
  const entry = {
    url: String(url),
    method: init && init.method,
    headers: init && init.headers ? Object.fromEntries(new Headers(init.headers).entries()) : {},
    body: init && init.body,
  };
  log.push(entry);
  const res = await origFetch(url, init);
  entry.status = res.status;
  entry.resHeaders = Object.fromEntries(res.headers.entries());
  entry.resText = await res.clone().text();
  return res;
};
(async () => {
  const out = {};
  out.callbacksBefore = ti.callbacks.size;
  const p = ti.invoke('greet', { name: 'rie' });
  out.callbacksPending = [...ti.callbacks.keys()];
  out.greet = await p;
  out.callbacksAfter = ti.callbacks.size;
  out.greet_async = await ti.invoke('greet_async', { name: 'rie' });
  await ti.invoke('inspect', { a: 1 });
  try { await ti.invoke('nope', {}); } catch (e) { out.nope = e; }
  try { await ti.invoke('greet', {}); } catch (e) { out.missingArg = e; }
  // a hand-made request with a wrong invoke key
  const t = performance.now();
  out.badKey = await Promise.race([
    origFetch('ipc://localhost/greet', {
      method: 'POST',
      body: JSON.stringify({ name: 'x' }),
      headers: { 'Content-Type': 'application/json', 'Tauri-Callback': '1', 'Tauri-Error': '2', 'Tauri-Invoke-Key': 'not-the-key' },
    }).then(r => 'responded ' + r.status, e => 'rejected ' + e),
    new Promise(r => setTimeout(() => r('no answer after ' + Math.round(performance.now() - t) + ' ms'), 2000)),
  ]);
  out.fetches = log;
  window.fetch = origFetch;
  await ti.invoke('report', { msg: JSON.stringify(out, null, 1) });
})();
</script>

debug 构建、--features tauri/custom-protocol,M2 Max、macOS 27。Rust 侧的输出(去掉了启动部分;greet_async、nope 两段完整保留,其余同形的段落用 ... 省略):

  481.636ms [main] > wry::custom_protocol::handle
  481.693ms [main] = wry::custom_protocol::handle uri="ipc://localhost/greet"
  481.759ms [main] > wry::custom_protocol::call_handler
  481.787ms [main] > ipc::request kind="custom-protocol"
  481.846ms [main] > ipc::request::deserialize
  482.117ms [main] < ipc::request::deserialize
  482.146ms [main] = ipc::request request="{\"name\":\"rie\"}"
  482.153ms [main] > ipc::request::handle cmd="greet"
  482.196ms [main] > ipc::request::handler cmd="greet" kind="sync"
  482.202ms [main] < ipc::request::handler
  482.209ms [main] > ipc::request::run
  482.228ms [main] > ipc::request::deserialize_arg
  482.235ms [main] < ipc::request::deserialize_arg
  482.254ms [main] * exp: command greet(name="rie")
  482.264ms [main] > ipc::request::respond
  482.291ms [main] > ipc::request::response response="\"hello, rie\""
  482.303ms [main] = ipc::request::response mime_type="application/json"
  482.314ms [main] > wry::custom_protocol::call_handler
  482.380ms [main] < wry::custom_protocol::call_handler
...
  482.420ms [main] < wry::custom_protocol::handle
  543.852ms [main] > wry::custom_protocol::handle
  543.931ms [main] = wry::custom_protocol::handle uri="ipc://localhost/greet_async"
  543.993ms [main] > wry::custom_protocol::call_handler
  544.006ms [main] > ipc::request kind="custom-protocol"
  544.058ms [main] > ipc::request::deserialize
  544.075ms [main] < ipc::request::deserialize
  544.093ms [main] = ipc::request request="{\"name\":\"rie\"}"
  544.099ms [main] > ipc::request::handle cmd="greet_async"
  544.136ms [main] > ipc::request::handler cmd="greet_async" kind="async"
  544.144ms [main] < ipc::request::handler
  544.152ms [main] > ipc::request::run
  544.183ms [main] < ipc::request
  544.189ms [main] < wry::custom_protocol::call_handler
  544.194ms [main] < wry::custom_protocol::handle
  545.036ms [tokio-rt-worker] > ipc::request::deserialize_arg
  545.056ms [tokio-rt-worker] < ipc::request::deserialize_arg
  545.085ms [tokio-rt-worker] * exp: command greet_async(name="rie")
  545.111ms [tokio-rt-worker] < ipc::request::run
  545.118ms [tokio-rt-worker] < ipc::request::run
  545.130ms [tokio-rt-worker] > ipc::request::respond
  545.139ms [tokio-rt-worker] > ipc::request::response response="\"hello again, rie\""
  545.151ms [tokio-rt-worker] = ipc::request::response mime_type="application/json"
  545.165ms [tokio-rt-worker] > wry::custom_protocol::call_handler
  545.326ms [tokio-rt-worker] < wry::custom_protocol::call_handler
  545.334ms [tokio-rt-worker] < ipc::request::response
  545.339ms [tokio-rt-worker] < ipc::request::respond
...
  545.854ms [main] * exp: command inspect
RUST url of webview = tauri://localhost
RUST header accept: */*
RUST header content-type: application/json
RUST header tauri-callback: 2479660327
RUST header origin: tauri://localhost
RUST header tauri-invoke-key: e{y?Nh0D)}d#9D<hZ^I]
RUST header user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)
RUST header referer: tauri://localhost
RUST header tauri-error: 2362239575
RUST body = Json(Object {"a": Number(1)})
...
  546.245ms [main] = wry::custom_protocol::handle uri="ipc://localhost/nope"
  546.276ms [main] > wry::custom_protocol::call_handler
  546.284ms [main] > ipc::request kind="custom-protocol"
  546.308ms [main] > ipc::request::deserialize
  546.315ms [main] < ipc::request::deserialize
  546.322ms [main] = ipc::request request="{}"
  546.340ms [main] > ipc::request::handle cmd="nope"
  546.361ms [main] > ipc::request::respond
  546.369ms [main] > ipc::request::response error="InvokeError(String(\"Command nope not found\"))"
  546.388ms [main] = ipc::request::response mime_type="application/json"
...
  546.926ms [main] > ipc::request::response error="InvokeError(String(\"invalid args `name` for command `greet`: command greet missing required key name\"))"
...
  547.287ms [main] = wry::custom_protocol::handle uri="ipc://localhost/greet"
...
  547.361ms [main] > ipc::request::handle cmd="greet"
  547.370ms [main] * tauri::webview: __TAURI_INVOKE_KEY__ expected e{y?Nh0D)}d#9D<hZ^I] but received not-the-key
  547.378ms [main] < ipc::request
  547.383ms [main] < wry::custom_protocol::call_handler
  547.387ms [main] < wry::custom_protocol::handle
 2551.619ms [main] > wry::custom_protocol::handle
 2552.090ms [main] = wry::custom_protocol::handle uri="ipc://localhost/report"
...
RUST invoke_key = e{y?Nh0D)}d#9D<hZ^I]

JS 侧交回来的 REPORT(fetches 里同形的条目只留前两条和 nope 那条):

REPORT {
 "callbacksBefore": 0,
 "callbacksPending": [
  4293199706,
  345660764
 ],
 "greet": "hello, rie",
 "callbacksAfter": 0,
 "greet_async": "hello again, rie",
 "nope": "Command nope not found",
 "missingArg": "invalid args `name` for command `greet`: command greet missing required key name",
 "badKey": "no answer after 2003 ms",
 "fetches": [
  {
   "url": "ipc://localhost/greet",
   "method": "POST",
   "headers": {
    "content-type": "application/json",
    "tauri-callback": "4293199706",
    "tauri-error": "345660764",
    "tauri-invoke-key": "e{y?Nh0D)}d#9D<hZ^I]"
   },
   "body": "{\"name\":\"rie\"}",
   "status": 200,
   "resHeaders": {
    "content-length": "12",
    "content-type": "application/json",
    "tauri-response": "ok"
   },
   "resText": "\"hello, rie\""
  },
  {
   "url": "ipc://localhost/greet_async",
   "method": "POST",
   "headers": {
    "content-type": "application/json",
    "tauri-callback": "1856138978",
    "tauri-error": "3746402957",
    "tauri-invoke-key": "e{y?Nh0D)}d#9D<hZ^I]"
   },
   "body": "{\"name\":\"rie\"}",
   "status": 200,
   "resHeaders": {
    "content-length": "18",
    "content-type": "application/json",
    "tauri-response": "ok"
   },
   "resText": "\"hello again, rie\""
  },
...
  {
   "url": "ipc://localhost/nope",
   "method": "POST",
   "headers": {
    "content-type": "application/json",
    "tauri-callback": "1398750501",
    "tauri-error": "3418338117",
    "tauri-invoke-key": "e{y?Nh0D)}d#9D<hZ^I]"
   },
   "body": "{}",
   "status": 200,
   "resHeaders": {
    "content-length": "24",
    "content-type": "application/json",
    "tauri-response": "error"
   },
   "resText": "\"Command nope not found\""
  },
...
 ]
}

七次请求对应 Rust 侧七个 ipc::request span,每个都走了 POST 的分支(有 deserialize);WebKit 没有为这些带自定义头的跨源请求先发 OPTIONS 预检。下面按调用主线的顺序把两份输出对起来。

1. invoke:两个随机数作回调 id

invoke 返回一个 Promise,在里面登记两个回调:成功时调 resolve 并注销失败回调,失败时反过来;两个都是 once,被调用一次就把自己从表里删掉。id 来自 uid(),也就是 crypto.getRandomValues(new Uint32Array(1))[0],不是自增计数。

实验里调用前表是空的(callbacksBefore: 0),invoke 返回、还没 await 的那一刻表里正好两项 4293199706 和 345660764,等 Promise 兑现后又是 0。这两个数就是第一条请求头里的 tauri-callback 和 tauri-error。回调表是一个普通的 Map,挂在 __TAURI_INTERNALS__.callbacks 上(注释说「just for the debugging purposes」)。

invoke 不直接发请求,而是调 __TAURI_INTERNALS__.ipc。在没开隔离模式时(__TAURI_PATTERN__ 是 brownfield),ipc 原样转给 __TAURI_INTERNALS__.postMessage;开了隔离模式时这里先把消息交给隔离 iframe 加密,见 CSP、原型冻结与隔离模式。

2. ipc-protocol.js:一个 fetch

postMessage 由 ipc-protocol.js 定义,核心是这一段:

    if (
      !customProtocolIpcFailed
      && (canUseCustomProtocol || cmd === fetchChannelDataCommand)
    ) {
      const { contentType, data } = processIpcMessage(payload)

      const headers = new Headers((options && options.headers) || {})
      headers.set('Content-Type', contentType)
      headers.set('Tauri-Callback', callback)
      headers.set('Tauri-Error', error)
      headers.set('Tauri-Invoke-Key', __TAURI_INVOKE_KEY__)

      fetch(window.__TAURI_INTERNALS__.convertFileSrc(cmd, 'ipc'), {
        method: 'POST',
        body: data,
        headers
      })

和实验抓到的第一条请求逐项对应:URL 是 ipc://localhost/greet(convertFileSrc 在 macOS 和 Linux 上生成 ipc://localhost/<cmd>,在 Windows 和 Android 上是 http://ipc.localhost/<cmd>,后者只读了源码),方法 POST,正文是 JSON.stringify 之后的参数对象 {"name":"rie"},四个头一个不差。processIpcMessage 遇到 ArrayBuffer、类型化数组或数组时改用 application/octet-stream 直接发字节。

__TAURI_INVOKE_KEY__ 是这个立即执行函数里的一个 const,没有挂在任何全局对象上;文件开头的注释说这样做是为了不让它通过 toString() 泄露出去。实验的 report 在 Rust 侧打印的 app.invoke_key() 和请求头里的值是同一个 20 字符的 Z85 串。

📝NOTE
冷知识:2.0 之前 invoke 走的是 postMessage

#7170(「refactor(core): use webview's URI schemes for IPC」,首次发布于 tauri 2.0.0-alpha.11,是 alpha 版)新增了 scripts/ipc-protocol.js,把 IPC 改成上面这种对自定义 scheme 发 fetch。在此之前,消息经 wry 的 window.ipc.postMessage 送到 Rust,结果再拼成一段 JS 用 eval 送回页面。CHANGELOG 的原话是「Use custom protocols on the IPC implementation to enhance performance.」。旧路并没有删:fetch 失败时 ipc-protocol.js 仍然退回 window.ipc.postMessage,本页最后一节就让它退回一次。

3. wry:主线程上的 start_task

WebKit 把对 ipc:// 的请求交给 wry 注册的 WKURLSchemeHandler。wry 的 start_task 在主线程上被调用(输出里 wry::custom_protocol::handle 都在 [main]),把 NSURLRequest 的 URL、方法、allHTTPHeaderFields 和正文(HTTPBody,或者从 HTTPBodyStream 每次读 128 字节)抄进一个 http::Request<Vec<u8>>,连同一个应答闭包交给 tauri 注册的处理函数。

inspect 打印的就是这时的头:JS 设的四个之外,WebKit 自己加了 accept、origin、referer 和 user-agent。origin 是 tauri://localhost,后面判断请求是不是来自本地页面就靠它。wry 怎么管理这些 task 的生命周期、为什么每次回调前都要检查 task 是否还有效,见 wry 在 macOS 上。

4. parse_invoke_request:URL 和头里各取一部分

ipc 协议的处理函数(ipc::protocol::get)按方法分三路:POST 是正常调用,OPTIONS 回一个带 Access-Control-Allow-Headers: * 的空响应,其他方法回 405。POST 时先找到发请求的 webview,再调 parse_invoke_request:

InvokeRequest 字段从哪来本次的值
cmdURL 路径去掉开头的 /,再做百分号解码greet
callback、errorTauri-Callback、Tauri-Error 头,解析成数字4293199706、345660764
invoke_keyTauri-Invoke-Key 头那个 20 字符的串
urlOrigin 头tauri://localhost
body按 Content-Type:JSON 解析成 serde_json::Value,application/octet-stream 原样保留,其他类型报错Json({"name": "rie"})
headers整个头表原样保留上面那八个

缺任何一个头、头的值不是数字,都在这里以 500 和一行纯文本结束,不会进到 on_message。输出里 ipc::request::deserialize 这个 span 就是正文的 JSON 解析。每一项的细节见 ipc 协议。

📝NOTE
冷知识:来源 URL 以前取的是 webview 的地址

url 字段取自 Origin 头是 #9100 改的(首次发布于 tauri 2.0.0-beta.14)。CHANGELOG 的原话是:「Enhance the IPC URL check by using the Origin header on the custom protocol IPC and the new request URI field on the postMessage IPC instead of using Webview::url() which only returns the URL of the main frame and is not suitable for iframes」。之前用的是 Webview::url(),也就是主 frame 的地址,页面里嵌的 iframe 发来的请求会被当成主 frame 发的;现在按请求自己的来源判断本地还是远程,ACL 的 remote.urls 匹配的也是它。

5. on_message:invoke key、ACL、分流

Webview::on_message 第一件事是比对 invoke key:

    // ensure the passed key matches what our manager should have injected
    let expected = manager.invoke_key();
    if request.invoke_key != expected {
      #[cfg(feature = "tracing")]
      tracing::error!(
        "__TAURI_INVOKE_KEY__ expected {expected} but received {}",
        request.invoke_key
      );

      #[cfg(not(feature = "tracing"))]
      eprintln!(
        "__TAURI_INVOKE_KEY__ expected {expected} but received {}",
        request.invoke_key
      );

      return;
    }

不匹配时只打一行日志然后 return,注意这里没有调用 responder。实验最后那个手工构造的请求就是这种情况。

❗IMPORTANT
动脑筋:页面绕过 invoke,自己用 fetch 往 ipc://localhost/greet 发一个格式完全正确、只有 Tauri-Invoke-Key 是错的请求。2 秒之内,这个 fetch 返回的 Promise 是兑现成某个状态码、被拒绝,还是什么都不发生?

平台 M2 Max、macOS 27,tauri 2.12.5,debug 构建,带 tracing feature。

key 对上之后,on_message 建一个 InvokeResolver(包着上一步传进来的应答闭包和两个回调 id),按 Origin 判断本地还是远程,然后 resolve_access。本次的四个命令都没被拦,原因是 ACL 的强制条件:

    if (plugin_command.is_some() || has_app_acl_manifest || !is_local)
      // TODO: Remove this special check in v3
      && request.cmd != crate::ipc::channel::FETCH_CHANNEL_DATA_COMMAND
      && invoke.acl.is_none()

greet 不是插件命令(没有 plugin: 前缀),模板没有声明应用自己的 ACL 清单,来源 tauri://localhost 是本地,三个条件都不成立,resolve_access 的结果是什么都不影响。这条规则的来历和远程页面的情形见运行时检查。

最后是分流:plugin:<插件>|<命令> 形式的交给插件的处理函数,其余交给 run_invoke_handler,也就是 generate_handler! 生成的那个闭包;它返回 false(没有这个命令)时 resolver 以 Command nope not found 拒绝,实验里的 nope 就是这样。见分发。

6. 包装函数:同步的就地跑,async 的交给 tokio

generate_handler! 生成的闭包按命令名 match,每个分支调 #[command] 生成的包装函数(输出里的 ipc::request::handler cmd="greet" kind="sync")。包装函数对每个参数调 CommandArg::from_command,普通参数从 JSON 正文里按 camelCase 的键取出来反序列化(ipc::request::deserialize_arg),tauri::Webview、tauri::ipc::Request 这类参数从调用本身取。缺键时得到实验里的 invalid args `name` for command `greet`: command greet missing required key name,同样走失败回调。

线程是这次实验最想看的东西:

  • greet 是普通 fn,body_blocking 生成的代码就地调用、就地 respond。从 start_task 到命令执行再到应答,全在 [main] 上,整个 wry::custom_protocol::handle span 不到 1 毫秒。同步命令跑多久,主线程(也就是事件循环和界面)就停多久。
  • greet_async 是 async fn,body_async 生成的代码调 respond_async_serialized,把参数解析、函数体和应答整个 spawn 到 tauri::async_runtime。输出里主线程上的 span 在 544.19 毫秒就退出了,参数解析、函数体、应答都在 [tokio-rt-worker] 上。async_runtime 是一个全局的 tokio 多线程运行时,见 async_runtime 和 tokio 卷。

宏具体展开成什么见 #[command] 与 generate_handler!,参数怎么经 serde 反序列化见参数与 InvokeResolver。

7. 应答:状态码总是 200,成败看头

不管成败,resolver 最后都走到 return_result,取出应答闭包调一次(再调会 panic:resolver consumed)。闭包在 ipc/protocol.rs 里,把 InvokeResponse 编成 HTTP 响应:

                  let response_header = match &response {
                    InvokeResponse::Ok(_) => TAURI_RESPONSE_HEADER_OK,
                    InvokeResponse::Err(_) => TAURI_RESPONSE_HEADER_ERROR,
                  };

                  let (mut response, mime_type) = match response {
                    InvokeResponse::Ok(InvokeResponseBody::Json(v)) => (
                      http::Response::new(v.into_bytes().into()),
                      mime::APPLICATION_JSON,
                    ),
                    InvokeResponse::Ok(InvokeResponseBody::Raw(v)) => (
                      http::Response::new(v.into()),
                      mime::APPLICATION_OCTET_STREAM,
                    ),
                    InvokeResponse::Err(e) => (
                      http::Response::new(serde_json::to_vec(&e.0).unwrap().into()),
                      mime::APPLICATION_JSON,
                    ),
                  };

外层的 respond 再给每个响应加上 Access-Control-Allow-Origin: * 和 Access-Control-Expose-Headers: Tauri-Response。拿实验里 JS 看到的响应头对照:

Rust 设的头JS 看到的原因
Tauri-Response: ok / error看到了Access-Control-Expose-Headers 点名放行了它
Content-Type看到了CORS 规定的安全响应头
Content-Length(wry 加的)看到了同上
Access-Control-Allow-Origin、Access-Control-Expose-Headers没看到页面源是 tauri://localhost,请求发往 ipc://localhost,是跨源请求,非安全列表里的头只有被 expose 的才对脚本可见

这解释了 Access-Control-Expose-Headers 为什么必须有:没有它,JS 读不到 Tauri-Response,就分不清成败。状态码则一律是 200,nope 和缺参数的两次失败也是 200,只有 tauri-response: error 不同;正文是错误值的 JSON(这里是一个字符串,所以 resText 带引号)。content-length 12、18、24 分别是 "hello, rie"、"hello again, rie"、"Command nope not found" 带引号的字节数。

greet_async 的应答在 tokio 线程上调 wry 的应答闭包,wry 在同一个线程上调 didReceiveResponse / didReceiveData / didFinish(输出里 [tokio-rt-worker] 上的 wry::custom_protocol::call_handler)。见参数与 InvokeResolver。

8. 回到 JS:按头选回调

ipc-protocol.js 收到响应后:Tauri-Response 是 ok 就用成功回调的 id,否则用失败回调的 id;content-type 是 application/json 就 response.json(),text/plain 就 text(),其他一律 arrayBuffer();最后 runCallback。成功回调 resolve 并注销失败回调,自己因为是 once 也被注销,所以 callbacksAfter 回到 0。找不到 id 时(比如页面在 Rust 还没应答时刷新了)只打一条 console.warn。

退路:CSP 拦下 fetch 之后

fetch 有可能失败。最常见的原因是 CSP:页面配了 default-src 'self',而 connect-src 没有放行 ipc:。把一次启动的模板配上 "csp": "default-src 'self'",页面先调一个不存在的命令 ping,再把收集到的警告报回来:

<script>
const warns = [];
const ow = console.warn;
console.warn = (...a) => { warns.push(a.map(String).join(' ')); ow(...a); };
window.addEventListener('securitypolicyviolation', e => warns.push('CSP violation: ' + e.violatedDirective + ' ' + e.blockedURI));
(async () => {
  const ti = window.__TAURI_INTERNALS__;
  try { await ti.invoke('ping', {}); } catch (e) { warns.push('ping rejected: ' + e); }
  await ti.invoke('report', { msg: JSON.stringify(warns) });
})();
</script>

输出(只留 IPC 相关的行,payl... 是 layer 截断的):

  480.676ms [main] > wry::ipc::handle
  480.755ms [main] > ipc::request kind="post-message" uri="tauri://localhost/" request="{\"cmd\":\"ping\",\"callback\":284364338,\"error\":1451044666,\"options\":{\"customProtocolIpcBlocked\":true},\"payload...
  480.958ms [main] > wry::eval
  481.027ms [main] < wry::ipc::handle
  542.879ms [main] > wry::ipc::handle
  542.979ms [main] > ipc::request kind="post-message" uri="tauri://localhost/" request="{\"cmd\":\"report\",\"callback\":1019808056,\"error\":2235131028,\"options\":{\"customProtocolIpcBlocked\":true},\"payl...
  543.139ms [main] * exp: command report
REPORT ["CSP violation: connect-src ipc://localhost/ping","IPC custom protocol failed, Tauri will now use the postMessage interface instead TypeError: Load failed","ping rejected: Command ping not found"]
  543.201ms [main] > wry::eval

整个过程里没有任何一个 ipc:// 请求到达 Rust:CSP 在 WebKit 里就把它拦了(connect-src ipc://localhost/ping),fetch 以 TypeError: Load failed 拒绝,ipc-protocol.js 打一条警告、把 customProtocolIpcFailed 置为真,重发这条消息。这次走 window.ipc.postMessage:整条消息(命令名、两个回调 id、options.customProtocolIpcBlocked: true、参数、__TAURI_INVOKE_KEY__)编成一个 JSON 字符串,Rust 侧从 wry::ipc::handle 进来,kind="post-message",经 handle_ipc_message 解析后同样交给 on_message;应答不再是 HTTP 响应,而是一段 wry::eval 直接在页面里调回调。customProtocolIpcFailed 一旦为真,这个页面之后的调用(这里的 report)都直接走 postMessage。

调用照样成功了,只是换了一条更慢的路,开发者除了一条控制台警告什么也看不到。两条路的差别(二进制正文、大响应)见 ipc 协议,CSP 该怎么配见 CSP、原型冻结与隔离模式。

动脑筋答案

  547.361ms [main] > ipc::request::handle cmd="greet"
  547.370ms [main] * tauri::webview: __TAURI_INVOKE_KEY__ expected e{y?Nh0D)}d#9D<hZ^I] but received not-the-key
  547.378ms [main] < ipc::request
...
 "badKey": "no answer after 2003 ms",
  • 什么都不发生:2 秒的计时器先到,fetch 既没有兑现也没有被拒绝。
  • Rust 侧请求正常到达,parse_invoke_request 也通过了(头都在、格式都对),在 on_message 第一步因为 key 不对打出那行 tracing::error!(没开 tracing feature 时是 eprintln!),随即 return。
  • return 时没有调用应答闭包,wry 也就没有调 didReceiveResponse / didFinish,这个 WKURLSchemeTask 一直挂着,页面这边的 fetch 就一直等。对伪造请求的一方来说,它得不到任何「key 错了」的信号。

小结

  • invoke 用两个 crypto.getRandomValues 生成的 u32 作回调 id,经 ipc → postMessage 发出 POST ipc://localhost/<cmd>,头里带两个 id 和 invoke key,正文是 JSON 或原始字节。
  • wry 在主线程上把请求转成 http::Request,parse_invoke_request 从路径、四个头和正文拼出 InvokeRequest,来源 URL 取自 Origin 头。
  • on_message 先比对 invoke key(不对就丢弃,请求永远不会得到应答),再按条件查 ACL,最后分给插件或 generate_handler! 的闭包。
  • 同步命令在主线程上就地执行和应答;async 命令连同参数解析整个 spawn 到 tokio 线程。
  • 应答一律 200,成败看 Tauri-Response 头;这个头能被 JS 读到全靠 Access-Control-Expose-Headers。
  • fetch 失败(典型是 CSP 没放行 ipc:)时悄悄退回 window.ipc.postMessage + eval,调用照样成功,只留一条控制台警告。

💬 关联讨论

发起讨论
加载中...