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) |
|---|---|---|
cmd | URL 路径,去掉开头的 /,百分号解码 | 消息 JSON 的 cmd |
callback、error | Tauri-Callback、Tauri-Error 头,解析成 u32 | 消息 JSON 的 callback、error |
invoke_key | Tauri-Invoke-Key 头 | 消息 JSON 的 __TAURI_INVOKE_KEY__ |
url | Origin 头 | 发消息那个 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。
#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 | 必须能解析成 u32 | abc、-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 发不出这样的请求。
fetch('ipc:foo', { method: 'POST' }),一个头都不带。tauri 2.12.5 上这个 fetch 拿到什么状态码和正文?平台 M2 Max、macOS 27,debug 构建。提示:ipc:foo 没有 //,http::Uri 把它解析成只有 authority 的形式,path() 是空串。
答案依赖两件事:空路径在第一步会怎样,以及之后哪一项检查先失败。第一步在 2.12.4 之前不是这样写的。
#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 MiB | fetch(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)。