一次 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. 登记一对回调 id | scripts/core.js · invoke | return window.crypto.getRandomValues(new Uint32Array(1))[0] | JS 侧的 invoke |
2. 编码参数,fetch 到 ipc:// | scripts/ipc-protocol.js · sendIpcMessage | fetch(window.__TAURI_INTERNALS__.convertFileSrc(cmd, 'ipc'), { method: 'POST', ... }) | JS 侧的 invoke |
| 3. WebKit 把请求交给 scheme handler | wry · start_task | let mut http_request = Request::builder().uri(uri).method(method.as_str()); | wry 在 macOS 上 |
4. 拼出 InvokeRequest | ipc/protocol.rs · get、parse_invoke_request | .ok_or("missing Tauri-Invoke-Key header")? | ipc 协议 |
| 5. invoke key、ACL、分流 | webview/mod.rs · Webview::on_message | if 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. 选回调、解析正文、兑现 Promise | scripts/ipc-protocol.js;scripts/core.js · runCallback | response.headers.get('Tauri-Response') === 'ok' ? callback : error | JS 侧的 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 串。
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 字段 | 从哪来 | 本次的值 |
|---|---|---|
cmd | URL 路径去掉开头的 /,再做百分号解码 | greet |
callback、error | Tauri-Callback、Tauri-Error 头,解析成数字 | 4293199706、345660764 |
invoke_key | Tauri-Invoke-Key 头 | 那个 20 字符的串 |
url | Origin 头 | 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 协议。
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。实验最后那个手工构造的请求就是这种情况。
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::handlespan 不到 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!(没开tracingfeature 时是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,调用照样成功,只留一条控制台警告。