Rust in Everything
基于 tauri 2.12.5

ipc 协议

主线位置:调用主线的第二段和倒数第二段。请求从 WebKit 出来、到 Webview::on_message 为止;应答从 InvokeResolver 交回的闭包开始、到 WebKit 收下为止。前面一段(JS 怎么发)见 JS 侧:invoke 与回调,后面一段(on_message 做什么)见分发。

flowchart TB
  fetchp["fetch POST ipc://"] --> wryp["wry start_task"]
  wryp --> getfn["protocol::get 闭包"]
  getfn --> method{"方法"}
  method -->|OPTIONS| opt["200 空正文"]
  method -->|其他| m405["405 纯文本"]
  method -->|POST| parse["parse_invoke_request"]
  parse -->|Err| e500["500 纯文本"]
  parse -->|Ok| onmsg["Webview::on_message"]
  pm["window.ipc.postMessage"] --> wryi["wry did_receive"]
  wryi --> hipc["handle_ipc_message"]
  hipc --> onmsg
  onmsg --> resp1["HTTP 响应 加 Tauri-Response"]
  onmsg --> resp2["responder_eval 一段 JS"]

源码是 ipc/protocol.rs 一个文件,约 430 行(不含测试),加上它调用的 ipc/format_callback.rs。一次 invoke 已经列出了一次正常请求里 InvokeRequest 每个字段从哪来、响应带哪些头;本页看不正常的请求、两条入口的差别和应答的代价。

实验应用 ipc1-lab 和上一页是同一个(tauri =2.12.5,tracing feature,隐藏窗口),页面用记录下来的真实 invoke key 手工构造 fetch,看每种畸形请求得到什么。另有 ipc1-origin(多声明了 app ACL 清单和一个 remote 能力)和一个 python3 -m http.server 在 127.0.0.1:18950 上提供远程页面。平台 M2 Max、macOS 27、rustc 1.99.0;没特别说明时是 debug 构建。

两个入口,一个 InvokeRequest

ipc 协议和 postMessage 处理函数都是在 prepare_pending_webview 这一带给每个 webview 装上的:注册 ipc 协议时先看 registered_scheme_protocols 里有没有 "ipc",用户自己用 register_uri_scheme_protocol("ipc", ..) 注册过就不再装 tauri 的;pending.ipc_handler 则总是 message_handler,它直接转给 handle_ipc_message。两个入口都在主线程上被 wry 调用,最后都调 webview.on_message(request, responder),区别在于 InvokeRequest 的每个字段从哪来:

InvokeRequest 字段ipc:// 协议(parse_invoke_request)postMessage(handle_ipc_message)
cmdURL 路径,去掉开头的 /,百分号解码消息 JSON 的 cmd
callback、errorTauri-Callback、Tauri-Error 头,解析成 u32消息 JSON 的 callback、error
invoke_keyTauri-Invoke-Key 头消息 JSON 的 __TAURI_INVOKE_KEY__
urlOrigin 头发消息那个 frame 的 URL(wry 从 WKFrameInfo 取)
body按 Content-Type:Json 或 Raw消息 JSON 的 payload,永远是 Json
headers整张请求头表只有 options.headers

url 那一行决定了 ACL 把请求当本地还是远程。协议路上它是 Origin(tauri://localhost,没有路径),postMessage 路上是完整的页面地址(实验里是 tauri://localhost/reload.html)。后者用 Url::parse(..).expect("invalid IPC request URL") 解析,是这个文件里少数几处在主线程上可能 panic 的地方;wry 给的是 WebKit 自己的 absoluteString,实验里没能造出解析失败的情形。

get:按方法分三路,每个响应都带两个 CORS 头

get 返回的闭包一开始就定义了统一的出口:

    let respond = move |mut response: http::Response<Cow<'static, [u8]>>| {
      response
        .headers_mut()
        .insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
      response.headers_mut().insert(
        ACCESS_CONTROL_EXPOSE_HEADERS,
        HeaderValue::from_static(TAURI_RESPONSE_HEADER_NAME),
      );
      responder.respond(response);
    };

之后的每个分支,成功的、失败的、OPTIONS、405,都经过它。页面源是 tauri://localhost、请求发往 ipc://localhost,是跨源请求。按 CORS 规则,没有 Access-Control-Allow-Origin 的响应,脚本连状态码都拿不到;没有 Access-Control-Expose-Headers,脚本读不到 Tauri-Response(一次 invoke 对照过 JS 侧能看到哪些头)。

方法分三路:POST 往下走;OPTIONS 回一个空正文、带 Access-Control-Allow-Headers: * 的 200,给预检用(一次 invoke 观察到 WebKit 对 ipc:// 并不发预检,这个分支是给别的平台的 webview 准备的);其余方法回 405 和一行 only POST and OPTIONS are allowed。POST 时先按标签找 webview,找不到回 500 failed to acquire webview reference。

📝NOTE
冷知识:错误响应一开始没有 CORS 头

#8419(「feat(core): set CORS headers on protocol errors」,首次发布于 tauri 2.0.0-alpha.19,是 alpha 版)之前,CORS 头只加在成功的响应上,parse_invoke_request 失败时的 500 没有它。CHANGELOG 的原话是「Include CORS header on custom protocol response errors to ensure frontend can read the error message.」:没有这个头,跨源的 fetch 拿不到 500 的正文,只会被拒绝。现在的写法把两个头收进 respond 这一个出口,任何分支都绕不过去。

parse_invoke_request:检查的顺序

fn parse_invoke_request<R: Runtime>(
  #[allow(unused_variables)] manager: &AppManager<R>,
  request: http::Request<Vec<u8>>,
) -> std::result::Result<InvokeRequest, String> {
  #[allow(unused_mut)]
  let (parts, mut body) = request.into_parts();

  // skip leading `/`
  let path = parts.uri.path().as_bytes();
  let cmd = percent_encoding::percent_decode(path.strip_prefix(b"/").unwrap_or(path))
    .decode_utf8_lossy()
    .to_string();

  // on Android we cannot read the request body
  // so we must ignore it because some commands use the IPC for faster response
  let has_payload = !body.is_empty();

  #[allow(unused_mut)]
  let mut content_type = parts
    .headers
    .get(http::header::CONTENT_TYPE)
    .and_then(|h| h.to_str().ok())
    .map(|mime| mime.parse())
    .unwrap_or(Ok(mime::APPLICATION_OCTET_STREAM))
    .map_err(|_| "unknown content type")?;

  // ... 隔离模式下在这里解密正文

  let invoke_key = parts
    .headers
    .get(TAURI_INVOKE_KEY_HEADER_NAME)
    .ok_or("missing Tauri-Invoke-Key header")?
  // ...
  let url = Url::parse(
    parts
      .headers
      .get("Origin")
      .ok_or("missing Origin header")?
  // ...
  let callback = CallbackFn(
  // ... Tauri-Callback,同样的写法再来一遍给 Tauri-Error

  let body = if content_type == mime::APPLICATION_OCTET_STREAM {
    body.into()
  } else if content_type == mime::APPLICATION_JSON {
    // if the platform does not support request body, we ignore it
    if has_payload {
      serde_json::from_slice::<serde_json::Value>(&body)
        .map_err(|e| e.to_string())?
        .into()
    } else {
      serde_json::Value::Object(Default::default()).into()
    }
  } else {
    return Err(format!("content type {content_type} is not implemented"));
  };

每个 ? 都把一个 &str 或 String 带回 get,变成 500、Content-Type: text/plain、正文就是这句话。edge.html 先调一次 greet 拿到真实的 invoke key,再用原始的 fetch 逐个发出畸形请求,每个等 1 秒:

  const key = await invokeKey();
  const H = (extra) => Object.assign({ 'Content-Type': 'application/json', 'Tauri-Callback': '1', 'Tauri-Error': '2', 'Tauri-Invoke-Key': key }, extra);
  const drop = (name) => { const h = H({}); delete h[name]; return h; };
  const cases = [
    ['ok', 'ipc://localhost/greet', 'POST', H({}), '{"name":"x"}'],
    ['no Tauri-Invoke-Key', 'ipc://localhost/greet', 'POST', drop('Tauri-Invoke-Key'), '{"name":"x"}'],
    ['Tauri-Callback: -1', 'ipc://localhost/greet', 'POST', H({ 'Tauri-Callback': '-1' }), '{"name":"x"}'],
    ['json; charset=utf-8', 'ipc://localhost/greet', 'POST', H({ 'Content-Type': 'application/json; charset=utf-8' }), '{"name":"x"}'],
    ['gr%65et', 'ipc://localhost/gr%65et', 'POST', H({}), '{"name":"x"}'],
    ['Origin: tauri://evil', 'ipc://localhost/inspect', 'POST', H({ 'Origin': 'tauri://evil' }), '{}'],
    // ... 共 24 种,见下面的输出
  ];

输出(每行:用例 => 状态码、页面能读到的响应头、正文):

REPORT [
 "ok => 200 {\"content-length\":\"10\",\"content-type\":\"application/json\",\"tauri-response\":\"ok\"} \"\\\"hello, x\\\"\"",
 "no Tauri-Invoke-Key => 500 {\"content-length\":\"31\",\"content-type\":\"text/plain\"} \"missing Tauri-Invoke-Key header\"",
 "no Tauri-Callback => 500 {\"content-length\":\"29\",\"content-type\":\"text/plain\"} \"missing Tauri-Callback header\"",
 "Tauri-Callback: abc => 500 {\"content-length\":\"52\",\"content-type\":\"text/plain\"} \"Tauri callback header value must be a numeric string\"",
 "Tauri-Callback: -1 => 500 {\"content-length\":\"52\",\"content-type\":\"text/plain\"} \"Tauri callback header value must be a numeric string\"",
 "Tauri-Callback: 4294967296 => 500 {\"content-length\":\"52\",\"content-type\":\"text/plain\"} \"Tauri callback header value must be a numeric string\"",
 "text/plain => 500 {\"content-length\":\"42\",\"content-type\":\"text/plain\"} \"content type text/plain is not implemented\"",
 "json; charset=utf-8 => 500 {\"content-length\":\"63\",\"content-type\":\"text/plain\"} \"content type application/json; charset=utf-8 is not implemented\"",
 "Content-Type: ??? => 500 {\"content-length\":\"20\",\"content-type\":\"text/plain\"} \"unknown content type\"",
 "no Content-Type, bytes body => 200 {\"content-length\":\"97\",\"content-type\":\"application/json\",\"tauri-response\":\"ok\"} \"{\\\"body\\\":\\\"Raw(2 bytes, head [7, 8])\\\",\\\"contentType\\\":null,\\\"origin\\\":\\\"tauri://localhost\\\",\\\"xDemo\\\":null}\"",
 "bad JSON => 500 {\"content-length\":\"44\",\"content-type\":\"text/plain\"} \"EOF while parsing a value at line 1 column 8\"",
 "empty JSON body => 200 {\"content-length\":\"82\",\"content-type\":\"application/json\",\"tauri-response\":\"error\"} \"\\\"invalid args `name` for command `greet`: command greet missing required key name\\\"\"",
 "gr%65et => 200 {\"content-length\":\"10\",\"content-type\":\"application/json\",\"tauri-response\":\"ok\"} \"\\\"hello, x\\\"\"",
 "%FF => 200 {\"content-length\":\"23\",\"content-type\":\"application/json\",\"tauri-response\":\"error\"} \"\\\"Command � not found\\\"\"",
 "a/b => 200 {\"content-length\":\"23\",\"content-type\":\"application/json\",\"tauri-response\":\"error\"} \"\\\"Command a/b not found\\\"\"",
 "query string => 200 {\"content-length\":\"10\",\"content-type\":\"application/json\",\"tauri-response\":\"ok\"} \"\\\"hello, x\\\"\"",
 "other host => 200 {\"content-length\":\"10\",\"content-type\":\"application/json\",\"tauri-response\":\"ok\"} \"\\\"hello, x\\\"\"",
 "ipc://localhost/ => 200 {\"content-length\":\"20\",\"content-type\":\"application/json\",\"tauri-response\":\"error\"} \"\\\"Command  not found\\\"\"",
 "ipc://localhost => 200 {\"content-length\":\"20\",\"content-type\":\"application/json\",\"tauri-response\":\"error\"} \"\\\"Command  not found\\\"\"",
 "ipc:foo => 200 {\"content-length\":\"20\",\"content-type\":\"application/json\",\"tauri-response\":\"error\"} \"\\\"Command  not found\\\"\"",
 "Origin: tauri://evil => 200 {\"content-length\":\"94\",\"content-type\":\"application/json\",\"tauri-response\":\"ok\"} \"{\\\"body\\\":\\\"Json({})\\\",\\\"contentType\\\":\\\"application/json\\\",\\\"origin\\\":\\\"tauri://localhost\\\",\\\"xDemo\\\":null}\"",
 "GET => 405 {\"content-length\":\"33\",\"content-type\":\"text/plain\"} \"only POST and OPTIONS are allowed\"",
 "PUT => 405 {\"content-length\":\"33\",\"content-type\":\"text/plain\"} \"only POST and OPTIONS are allowed\"",
 "OPTIONS => 200 {\"content-length\":\"0\"} \"\"",
 "callbacks left in map: 0",
 "warns: []"
]

同一次运行开着 tracing 时,Rust 侧 wry::custom_protocol::handle 记下的 URI 和 ipc::request::handle 记下的命令名(节选):

  447.534ms [main] = wry::custom_protocol::handle uri="ipc://localhost/gr%65et"
  447.612ms [main] > ipc::request::handle cmd="greet"
  447.898ms [main] = wry::custom_protocol::handle uri="ipc://localhost/%FF"
  447.972ms [main] > ipc::request::handle cmd="�"
...
  449.047ms [main] = wry::custom_protocol::handle uri="ipc://elsewhere/greet"
  449.121ms [main] > ipc::request::handle cmd="greet"
  449.458ms [main] = wry::custom_protocol::handle uri="ipc://localhost/"
  449.535ms [main] > ipc::request::handle cmd=""
  449.856ms [main] = wry::custom_protocol::handle uri="ipc://localhost"
  449.928ms [main] > ipc::request::handle cmd=""
  450.244ms [main] = wry::custom_protocol::handle uri="ipc:foo"
  450.313ms [main] > ipc::request::handle cmd=""

按检查顺序归纳:

步骤规则实验里的例子
命令名只取路径;主机名、查询串都不看;百分号解码,非法 UTF-8 换成 U+FFFD;路径为空或只有 / 时命令名是空串,交给 on_message 之后才以 Command not found 失败gr%65et 调到了 greet;ipc://elsewhere/greet 照样成功;%FF 变成 �
Content-Type没有这个头按 application/octet-stream;解析不了是 unknown content type不带头的 Uint8Array 正文成了 Raw(2 bytes)
invoke key只检查有没有;对不对要到 on_message 才比缺头是 500;头在但值错,见分发:请求永远没有应答
Origin必须有且是合法 URL页面设的 Origin: tauri://evil 没有生效,Rust 看到的还是 tauri://localhost
两个回调 id必须能解析成 u32abc、-1、4294967296 一样被拒
正文Content-Type 与 application/octet-stream 或 application/json 整体相等才认;JSON 正文为空时当作 {}application/json; charset=utf-8 被拒;空正文得到的是 greet 缺参数的错误

几处细节:

  • 比较 Content-Type 用的是 mime::Mime 的 ==,参数也参与比较,带 charset 的 JSON 不被接受。ipc-protocol.js 只会发不带参数的两种,所以只有手工请求会撞上。
  • Origin 是 fetch 规范里页面脚本不能设置的头,WebKit 静默忽略了实验里的 tauri://evil。parse_invoke_request 用它判断来源,前提就是页面伪造不了它。Referer 也在头表里,但这里不读(一次 invoke 的更正)。
  • 检查的先后决定了错误信息:一个什么头都不带的请求,得到的是 missing Tauri-Invoke-Key header,而不是 missing Origin header。
  • 「JSON 正文为空当作 {}」那段注释写的是 Android:那里的 webview 读不到自定义协议的请求正文。

500 的响应没有 Tauri-Response 头,ipc-protocol.js 读到的是 null,于是选失败回调;Content-Type 是 text/plain,于是用 response.text()。如果某个页面真把这样的请求经 invoke 发出去,Promise 会以这行纯文本拒绝;正常的 invoke 发不出这样的请求。

❗IMPORTANT
动脑筋:页面执行 fetch('ipc:foo', { method: 'POST' }),一个头都不带。tauri 2.12.5 上这个 fetch 拿到什么状态码和正文?

平台 M2 Max、macOS 27,debug 构建。提示:ipc:foo 没有 //,http::Uri 把它解析成只有 authority 的形式,path() 是空串。

答案依赖两件事:空路径在第一步会怎样,以及之后哪一项检查先失败。第一步在 2.12.4 之前不是这样写的。

📝NOTE
冷知识:空路径曾经能让整个应用退出

#16197(「fix(core): don't panic on IPC/asset requests with an empty URI path」,首次发布于 tauri 2.12.4)之前,取命令名那一行是 percent_decode(&parts.uri.path().as_bytes()[1..]),默认路径总以 / 开头。CHANGELOG 的原话是「Fix a panic in the IPC and asset protocol handlers when the request URI has an empty path (for example ipc:foo).」,同一个修复也改了 asset 协议。现在写成 path.strip_prefix(b"/").unwrap_or(path),并加了测试 parse_invoke_request_empty_path。

用缓存里的 tauri 2.11.6(wry 0.55.1)编同一个页面,只跑了一次(这次运行会弹出 macOS 的崩溃报告窗口):

thread 'main' (2574817) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.6/src/ipc/protocol.rs:444:74:
range start index 1 out of range for slice of length 0
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
fatal runtime error: failed to initiate panic, error 5, aborting

退出码 134。这一行代码在主线程上、在 WebKit 调进来的 Objective-C 回调里执行,panic 展开不过 FFI 边界,进程直接 abort。也就是说,任何能在 webview 里执行脚本的页面,一行不带头的 fetch 就能让旧版本的应用退出:这一步在检查 invoke key 之前。

来自别的源的页面

ipc1-lab 让主窗口直接加载 http://127.0.0.1:18950/remote.html(WebviewUrl::External),页面检查 __TAURI_INTERNALS__ 在不在、用 invoke 调 greet、从记录到的请求头里取出 invoke key 再手工 fetch 一次,最后切到 postMessage 再调一次。远程页面调不了 report,所以结果经由导航到 /done?r=<json> 交回,Rust 在 on_navigation 里打印:

REPORT {
 "origin": "http://127.0.0.1:18950",
 "hasInternals": "object",
 "greetRejected": "greet not allowed. Plugin not found",
 "keyLeaked": true,
 "handFetch": "200 error \"greet not allowed. Plugin not found\"",
 "greetPmRejected": "greet not allowed. Plugin not found"
}
  • 远程页面同样有 __TAURI_INTERNALS__,invoke key 也就在它手里:tauri 的初始化脚本注入每一个主 frame,不看来源。
  • 从 http://127.0.0.1:18950 发往 ipc://localhost 的 fetch 没有被 WebKit 拦下,请求到达了 Rust,拿到的是一个正常的 200 加 tauri-response: error。拦它的是 on_message 里的 ACL:来源不是本地,就一定要查,而这个应用没给远程来源任何能力。拒绝的措辞在 debug 和 release 下不同,规则见运行时检查。
  • postMessage 路上结果一样,那里的来源是 frame 的 URL。

想看 Rust 收到的 Origin,得让命令真的执行。ipc1-origin 声明了 app ACL 清单(AppManifest::new().commands(&["inspect", "greet", "report"])),并加了一个能力:

{
  "identifier": "remote-test",
  "windows": ["main"],
  "remote": { "urls": ["http://127.0.0.1:18950/*"] },
  "permissions": ["allow-inspect"]
}

inspect 在这里多打印 Origin 和 Referer:

RUST inspect origin=Some("http://127.0.0.1:18950") referer=Some("http://127.0.0.1:18950/origin.html")
RUST inspect origin=None referer=None
REPORT {
 "origin": "http://127.0.0.1:18950",
 "inspect": {
  "body": "Json({\"a\":1})",
  "contentType": "application/json",
  "origin": "http://127.0.0.1:18950",
  "referer": "http://127.0.0.1:18950/origin.html",
  "xDemo": null
 },
 "greetRejected": "greet not allowed. Permissions associated with this command: allow-greet",
 "inspectPm": {
  "body": "Json({\"a\":1})",
  "contentType": null,
  "origin": null,
  "referer": null,
  "xDemo": null
 }
}

协议路上 Origin 是 http://127.0.0.1:18950,正是 parse_invoke_request 填进 InvokeRequest::url 的值;第二行是 postMessage 路,头表里没有 Origin,来源取自 frame 的 URL,同样匹配上了 http://127.0.0.1:18950/*。没在能力里的 greet 被拒。能力怎么写、remote.urls 怎么匹配见 07 安全。

协议路的应答:原样转交

on_message 收到的应答闭包(get 里那个 Box::new(move |_webview, _cmd, response, _callback, _error| ...))只做编码,一次 invoke 已经贴过:Ok(Json(String)) 原样作正文、application/json;Ok(Raw(Vec<u8>)) 原样作正文、application/octet-stream;Err 把错误值 serde_json::to_vec 之后作正文、application/json;Tauri-Response 按成败填 ok / error,状态码一律 200。闭包不用两个回调 id,它们只在 postMessage 路上用得着。

「原样」的意思是 Json 里的字符串不再校验。正常的返回值都是 serde_json::to_string 出来的,没问题;手工构造的 ipc::Response::new(String) 不是 JSON 时,JS 侧解析失败,会触发退回 postMessage 并让命令再执行一次,见 JS 侧:invoke 与回调。

postMessage 路:handle_ipc_message

postMessage 进来的是一个字符串。handle_ipc_message 把它反序列化成函数里的局部结构体:

  #[derive(Deserialize, Default)]
  #[serde(rename_all = "camelCase")]
  struct RequestOptions {
    #[serde(default)]
    headers: HeaderMap,
    #[serde(default)]
    custom_protocol_ipc_blocked: bool,
  }

  #[derive(Deserialize)]
  struct Message {
    cmd: String,
    callback: CallbackFn,
    error: CallbackFn,
    payload: serde_json::Value,
    options: Option<RequestOptions>,
    #[serde(rename = "__TAURI_INVOKE_KEY__")]
    invoke_key: String,
  }

HeaderMap 是同一函数里包了一层 http::HeaderMap 的新类型,从 HashMap<String, String> 反序列化,名字或值不合法就整条消息失败。反序列化失败时没有回调 id 可用,处理函数只能 eval 一句 console.error(<错误文本>),页面那边的 Promise 永远不兑现。

应答闭包按响应类型和平台分支,核心是里面定义的 responder_eval:

          fn responder_eval<R: Runtime>(
            webview: &crate::Webview<R>,
            js: crate::Result<String>,
            error: CallbackFn,
          ) {
            let eval_js = js.unwrap_or_else(|e| {
              crate::ipc::format_callback::format(error, &e.to_string())
                .expect("unable to serialize response error string to json")
            });
            let _ = webview.eval(eval_js);
          }

js 是 format_callback 生成的脚本;生成失败时(比如 Json 里的字符串不是合法 JSON)改为调失败回调、参数是错误文本,上一页 not_json 第二次执行得到的 JSON error: expected value at line 1 column 1 就是这里来的。

三种响应在 macOS 上都走 responder_eval:

响应生成脚本的函数页面拿到
Ok(Json(s))format_result_raw:s 当原始 JSON 嵌进去对应的 JS 值
Ok(Raw(v))format_result:v 先 serde_json 序列化数字数组,不是 ArrayBuffer
Err(e)format_result,失败回调错误值
            InvokeResponse::Ok(InvokeResponseBody::Json(v)) => {
              if !(cfg!(target_os = "macos") || cfg!(target_os = "ios"))
                && (v.starts_with('{') || v.starts_with('['))
                && can_use_channel_for_response
              {
                let _ =
                  Channel::from_callback_fn(webview, callback).send(InvokeResponseBody::Json(v));
              } else {

其他平台上,对象、数组和原始字节在 can_use_channel_for_response 为真时改用 Channel 送回。这个条件要求 options.customProtocolIpcBlocked 为假,而桌面上只有 fetch 失败过才会走 postMessage、此时它恰好为真,所以 Channel 分支实际上只在 Android(一开始就用 postMessage)上发生。这一段是读源码的结论,没有在 Windows / Linux / Android 上实测。

最后一步是 serialize_js_with:先 RawValue::from_string 校验 JSON,长度超过 MIN_JSON_PARSE_LEN(10 240 字节)的对象和数组包成 JSON.parse('...')(文档注释引用了 GoogleChromeLabs 的 json-parse-benchmark),其他直接作为 JS 字面量,再由 format_raw_js 拼成 window.__TAURI_INTERNALS__.runCallback(<id>, <js>)。eval 只有去程,回调执行时页面可能已经换了,这就是上一页刷新实验里新页面收到旧 id 的原因。

两条路的代价:5 MiB

sizes.html 在两条路上各跑三轮:big_raw 返回 5 MiB 的 ipc::Response(Vec<u8>,第 i 个字节是 i % 256),big_str 返回 5 MiB 的 String,sink 收一个 5 MiB 的 Uint8Array、返回 Rust 收到了什么。performance.now() 计 await invoke(..) 的整段时间。release 构建:

REPORT {
 "fetch": [
  "big_raw 5 MiB: 65 ms, got ArrayBuffer(5242880)",
  "big_str 5 MiB: 6 ms, got string(5242880)",
  "sink Uint8Array 5 MiB: 17 ms, got String \"Raw 5242880 bytes\"",
  "big_raw 5 MiB: 6 ms, got ArrayBuffer(5242880)",
  "big_str 5 MiB: 8 ms, got string(5242880)",
  "sink Uint8Array 5 MiB: 16 ms, got String \"Raw 5242880 bytes\"",
  "big_raw 5 MiB: 2 ms, got ArrayBuffer(5242880)",
  "big_str 5 MiB: 4 ms, got string(5242880)",
  "sink Uint8Array 5 MiB: 17 ms, got String \"Raw 5242880 bytes\""
 ],
 "postMessage": [
  "big_raw 5 MiB: 114 ms, got Array(5242880) [0,1,2,3]",
  "big_str 5 MiB: 5 ms, got string(5242880)",
  "sink Uint8Array 5 MiB: 360 ms, got String \"Json array of 5242880 elements\"",
  "big_raw 5 MiB: 113 ms, got Array(5242880) [0,1,2,3]",
  "big_str 5 MiB: 5 ms, got string(5242880)",
  "sink Uint8Array 5 MiB: 333 ms, got String \"Json array of 5242880 elements\"",
  "big_raw 5 MiB: 111 ms, got Array(5242880) [0,1,2,3]",
  "big_str 5 MiB: 5 ms, got string(5242880)",
  "sink Uint8Array 5 MiB: 342 ms, got String \"Json array of 5242880 elements\""
 ]
}
5 MiBfetch(release,后两轮)postMessage(release)debug 下的同一组(fetch / postMessage)
Rust → JS 字节2–6 ms,ArrayBuffer约 113 ms,Array约 47 ms / 约 1510 ms
Rust → JS 字符串4–8 ms约 5 ms约 125 ms / 约 139 ms
JS → Rust 字节约 16 ms,Raw约 340 ms,Json 数组约 527 ms / 约 820 ms

差距集中在字节上。postMessage 路上 5 MiB 字节要变成一个 JSON 数字数组:按这组数据算是 18 718 721 个字符,超过 10 KiB,包成 JSON.parse('...') 交给 eval,页面再解析出 524 万个元素的数组。反方向同样是 Array.from 加 JSON.stringify,Rust 再解析回 serde_json::Value 数组。字符串在两条路上都只是一个字面量,差别不大。

debug 下 fetch 上传也要半秒多:wry 读 HTTPBodyStream 时每次只读 128 字节(见 wry 在 macOS 上),5 MiB 是四万多次循环,没有优化的 debug 代码把它放大了;release 下降到 16 ms 左右。每组的第一轮偏慢(65 ms 那一行),后两轮稳定,表里取后两轮。

动脑筋答案

REPORT ipc:foo => 500 missing Tauri-Invoke-Key header
  • 500,正文 missing Tauri-Invoke-Key header,应用照常运行。
  • 第一步:路径是空串,strip_prefix(b"/") 返回 None,unwrap_or(path) 用整个空路径,命令名是空串,不 panic。开着 tracing 的运行里 wry 记下的 URI 正是 ipc:foo(见上面的节选)。
  • 之后 Content-Type 不存在,按 application/octet-stream 处理,不报错;第一项真正失败的检查是 invoke key,于是返回这一句。如果带齐四个头,请求会一直走到 on_message,以 Command not found 结束(输出里的 ipc:foo 那一行)。
  • 同一个页面在 tauri 2.11.6 上让进程以 134 退出,见上面的冷知识。

小结

  • ipc:// 协议和 postMessage 两个入口各自拼 InvokeRequest:前者从路径、四个头、Origin 和按 Content-Type 解析的正文取,后者全部从一个 JSON 字符串里取,来源是 frame 的 URL,正文永远是 Json。
  • 协议处理函数的每个响应都经同一个出口加上 Access-Control-Allow-Origin: * 和 Access-Control-Expose-Headers: Tauri-Response;OPTIONS 回 200,其他非 POST 方法回 405。
  • parse_invoke_request 依次检查命令名、Content-Type、invoke key 是否存在、Origin、两个回调 id、正文;失败回 500 纯文本、不带 Tauri-Response。命令名只取路径、百分号解码;Content-Type 必须整体等于两种之一。
  • 页面伪造不了 Origin;远程页面同样拿得到 invoke key,能把请求送到 on_message,挡住它的是 ACL。
  • postMessage 路的应答是一段 eval,字节变成数字数组;5 MiB 字节往返比 fetch 慢几十倍。
  • 2.12.4 之前,一个路径为空的 ipc: 请求能让应用 abort(#16197)。

💬 关联讨论

发起讨论
加载中...