写一个迷你 IPC
主线位置:把调用主线自己走一遍。JS
invoke发fetch POST ipc://localhost/<cmd>→ wry 在主线程上调协议处理函数 → 检查方法、Origin、命令名、允许列表 → 调命令表里的函数 → HTTP 响应 →fetch的 Promise 兑现或拒绝。tauri 在这条路上多出来的东西(invoke key、回调 id、Tauri-Response头、二进制正文、Channel、postMessage退路、ACL)放在最后逐项对照。启动主线只取一步:一个app://协议从内存里吐出页面。
flowchart TB
inv["invoke cmd args"] --> fetchp["fetch POST ipc://"]
fetchp --> wryp["wry 协议处理 主线程"]
wryp --> meth{"是 POST 吗"}
meth -->|否| r405["405"]
meth -->|是| org{"有 Origin 吗"}
org -->|无| r400["400"]
org -->|有| look{"命令在表里吗"}
look -->|否| r404["404 unknown command"]
look -->|是| allow{"来源允许吗"}
allow -->|否| r403["403 not allowed"]
allow -->|是| runcmd["fn Value 到 Result"]
runcmd --> resp["200 JSON 或 500 文本"]
resp --> prom["Promise 兑现或拒绝"]
mini 是一个只依赖 wry、tao、http、serde_json 的库,外加一个演示程序和三个测试:
[dependencies]
wry = "=0.57.1"
tao = "=0.37.2"
http = "1"
serde_json = "1"
另有一个对照程序 hands-cmp,是普通的 tauri 2.12.5 应用(从 on_message 与分发 的 ipc2-acl 复制),页面调同样几个命令,记下线上收发的东西。只有它依赖 tauri。平台是 M2 Max、macOS 27、rustc 1.99.0,debug 构建,窗口全部隐藏。
范围
| 文件 | 做什么 | 代码行(不含空行和注释) |
|---|---|---|
src/ipc.rs | 命令表、允许列表、ipc:// 请求的检查和应答 | 62 |
src/window.rs | 隐藏窗口、webview、两个协议、等页面交回结果 | 59 |
src/invoke.js | JS 侧的 invoke | 14 |
src/lib.rs | 导出,三个示例命令 | 20 |
src/main.rs | 演示程序 | 52 |
tests/*.rs | 三个窗口测试 | 81 |
做事的是前三个文件,135 行;库一共 155 行。
| 实现了 | 没实现(tauri 有) |
|---|---|
app:// 协议从内存里吐出页面 | 内嵌资源的压缩、CSP 注入(tauri:// 协议) |
ipc:// 协议接 POST,按路径取命令名 | 命令名百分号解码、OPTIONS 预检分支 |
命令表:fn(Value) -> Result<Value, String> | #[command] 宏、State 等特殊参数、async 命令 |
| 允许列表:来源 × 命令名 | 能力、窗口和 webview 标签、远程 URL 模式、拒绝、作用域 |
| 成败用状态码表示 | Tauri-Response 头、错误是任意 JSON |
| JSON 正文 | 原始字节进出、Channel、postMessage 退路、invoke key |
两个协议,一个隐藏窗口
window.rs 的 run 建一个隐藏窗口,挂两个协议,打开 app://localhost/,然后等页面交回一行结果:
pub fn run(ipc: Ipc, script: &str, timeout: Duration) -> Result<String, String> {
let mut event_loop = EventLoopBuilder::<String>::with_user_event().build();
let proxy = event_loop.create_proxy();
let window = WindowBuilder::new()
.with_visible(false)
.build(&event_loop)
.map_err(|e| e.to_string())?;
let page = format!("<!doctype html><html><body><script>{INVOKE_JS}</script><script>{script}</script></body></html>");
let ipc = Arc::new(ipc);
let webview = WebViewBuilder::new()
.with_custom_protocol("app".into(), move |_, req| serve_page(&page, &req))
.with_custom_protocol("ipc".into(), move |_, req| ipc.handle(&req))
.with_ipc_handler(move |req| {
// 事件循环已经退出时发送会失败,那时也没人要这个结果了
let _ = proxy.send_event(req.into_body());
})
.with_url("app://localhost/")
.build(&window)
.map_err(|e| e.to_string())?;
let deadline = Instant::now() + timeout;
let mut result = Err(format!("no report within {timeout:?}"));
event_loop.run_return(|event, _, control_flow| {
*control_flow = ControlFlow::WaitUntil(deadline);
match event {
Event::UserEvent(report) => {
result = Ok(report);
*control_flow = ControlFlow::Exit;
}
Event::NewEvents(StartCause::ResumeTimeReached { .. }) => *control_flow = ControlFlow::Exit,
_ => {}
}
});
drop(webview);
result
}
几处取舍:
- 同步的协议处理函数。
with_custom_protocol收一个「请求进、响应出」的闭包,wry 在主线程上调它、拿到返回值就交给 WebKit(wry 在 macOS 上)。所以 mini 的每个命令都在主线程上执行,和 tauri 的同步命令一样,一个慢命令会让别的请求排队(参数、返回值与 InvokeResolver)。tauri 在tauri-runtime-wry里用的是with_asynchronous_custom_protocol,回答闭包可以带到 tokio 线程上晚些再调,async 命令靠的就是它。 Arc<Ipc>。 两个注册函数都要求闭包Send + Sync + 'static。Ipc里只有HashMap和fn指针,自动满足;命令用fn指针而不用闭包,也就拿不到任何状态。tauri 让命令通过State<T>参数取状态(State 与 ResourceTable)。- 结果走另一条路交回。 测试要知道页面看到了什么,又不能让被测的
invoke自己报告自己,所以页面用 wry 的window.ipc.postMessage交回一行字符串,with_ipc_handler收到后经EventLoopProxy送回事件循环。这个对象只注入主 frame(wry 在 macOS 上),所以下面的 iframe 要先parent.postMessage给主页面。 run_return而不是run。 tao 的EventLoop::run返回!,事件循环结束时直接退出进程;EventLoopExtRunReturn::run_return会返回,测试才能在之后断言。超时用ControlFlow::WaitUntil,不另起看门狗线程。
app:// 协议的处理函数 serve_page 对任何主机的 / 都回同一张页面,别的路径 404。页面先定义 invoke,再跑调用方给的脚本。
invoke:fetch 的 Promise 就是回调
invoke.js 全文:
// mini-ipc 的 invoke:一次 fetch 就是一次调用,fetch 的 Promise 就是回调。
Object.defineProperty(window, 'invoke', {
value: (cmd, args = {}) =>
fetch('ipc://localhost/' + cmd, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
}).then(async (res) => {
const text = await res.text();
if (res.ok) {
return JSON.parse(text);
}
throw text;
}),
});
和 tauri 的 invoke 比,少了登记回调这一步:一次调用就是一个 fetch,fetch 自己的 Promise 一直等到响应回来,用不着另记「回来时该叫谁」。成功是 2xx,正文按 JSON 解析;失败时把正文那行文本当作拒绝的值。命令名直接拼进路径,没有编码;Rust 侧也不解码。
ipc 协议:四道检查
ipc.rs 里 Ipc 只有两张表:
pub type Command = fn(Value) -> Result<Value, String>;
#[derive(Default)]
pub struct Ipc {
commands: HashMap<&'static str, Command>,
/// 来源 -> 这个来源能调的命令名
allowed: HashMap<String, HashSet<String>>,
/// 每个请求打一行日志
pub log: bool,
}
command(name, f) 和 allow(origin, &[names]) 往里填。处理一个请求:
fn dispatch(&self, req: &Request<Vec<u8>>) -> Result<Value, (StatusCode, String)> {
if req.method() != Method::POST {
return Err((StatusCode::METHOD_NOT_ALLOWED, "only POST is allowed".into()));
}
let path = req.uri().path();
let name = path.strip_prefix('/').unwrap_or(path);
// 页面脚本设不了 Origin,WebKit 填的就是发请求的 frame 的源
let origin = req
.headers()
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
.ok_or((StatusCode::BAD_REQUEST, "missing Origin header".to_string()))?;
let Some(command) = self.commands.get(name) else {
return Err((StatusCode::NOT_FOUND, format!("unknown command {name}")));
};
let allowed = self.allowed.get(origin).is_some_and(|names| names.contains(name));
if !allowed {
return Err((StatusCode::FORBIDDEN, format!("command {name} not allowed for {origin}")));
}
let args = serde_json::from_slice(req.body())
.map_err(|e| (StatusCode::BAD_REQUEST, format!("bad JSON body: {e}")))?;
command(args).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))
}
取命令名用 strip_prefix('/').unwrap_or(path),不写 &path[1..]:路径可以是空串,tauri 在 2.12.4 之前就是在这里切片越界、让整个应用退出的(ipc 协议)。
来源取自 Origin 头。页面在 app://localhost,请求发往 ipc://localhost,是跨源请求,WebKit 每次都带上 Origin;tauri 的 parse_invoke_request 也只读这个头(一次 invoke)。
问: 允许列表只认 Origin 头。页面在 fetch 里自己写一个 Origin: app://localhost,不就混过去了?
答: 写不进去。Origin 是 fetch 规范里脚本不能设置的头,WebKit 会静默丢掉页面给的值,换成真实的源。ipc 协议 的实验里,页面设的 Origin: tauri://evil 到了 Rust 还是 tauri://localhost。允许列表靠得住的前提就是这一点;页面能控制的东西(路径、正文、别的头)都不能拿来判断来源。
dispatch 先查命令表、再查允许列表。顺序是有后果的:一个不在允许列表里的来源调 nope 得到 404,调 secret 得到 403,它能借此分辨哪些命令存在。tauri 反过来,先查 ACL 再找命令,下面并排跑时会看到。
handle 把结果变成 HTTP 响应:
pub fn handle(&self, req: &Request<Vec<u8>>) -> Response<Cow<'static, [u8]>> {
let (status, content_type, body) = match self.dispatch(req) {
Ok(value) => (StatusCode::OK, "application/json", value.to_string()),
Err((status, msg)) => (status, "text/plain", msg),
};
// ... log 为真时打印一行
let mut res = Response::new(Cow::Owned(body.into_bytes()));
*res.status_mut() = status;
let headers = res.headers_mut();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
// 页面在 app://localhost,请求发往 ipc://localhost,是跨源请求:没有这个头,脚本拿不到响应
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
res
}
| 情形 | 状态码 | 正文 |
|---|---|---|
不是 POST | 405 | only POST is allowed |
没有 Origin | 400 | missing Origin header |
| 命令不在表里 | 404 | unknown command <cmd> |
| 来源没被允许调这个命令 | 403 | command <cmd> not allowed for <origin> |
| 正文不是 JSON | 400 | bad JSON body: ... |
命令返回 Err | 500 | 命令给的那句话 |
命令返回 Ok | 200 | Value 的 JSON |
Response::new 加 status_mut / headers_mut 是故意的:Response::builder()...body(..) 返回 Result,这里的状态码和头都是常量,不会失败,用不着为它处理一个不可能的错误。
跑一下
演示程序注册 greet、add、secret 三个命令,只给 app://localhost 放行前两个,打开日志:
let mut ipc = Ipc::default()
.command("greet", demo::greet)
.command("add", demo::add)
.command("secret", demo::secret)
.allow("app://localhost", &["greet", "add"]);
ipc.log = true;
页面先用 invoke 调五次,再用原始的 fetch 发四次(看状态码和 Content-Type),最后插一个 app://other/ 的 iframe,让它调 greet:
out.push('invoke greet => ' + await settle(invoke('greet', { name: 'rie' })));
out.push('invoke add => ' + await settle(invoke('add', { a: 2, b: 40 })));
out.push('invoke greet {} => ' + await settle(invoke('greet')));
out.push('invoke secret => ' + await settle(invoke('secret')));
out.push('invoke nope => ' + await settle(invoke('nope')));
out.push('fetch POST greet => ' + await raw('greet', { name: 'raw' }));
out.push('fetch POST secret => ' + await raw('secret', {}));
out.push('fetch POST nope => ' + await raw('nope', {}));
out.push('fetch GET greet => ' + await raw('greet', null, 'GET'));
out.push('iframe greet => ' + await new Promise((resolve) => {
window.addEventListener('message', (e) => resolve(e.data));
const f = document.createElement('iframe');
f.src = 'app://other/';
document.body.appendChild(f);
}));
[ipc] POST ipc://localhost/greet origin=Some("app://localhost") -> 200 "hello, rie"
[ipc] POST ipc://localhost/add origin=Some("app://localhost") -> 200 42
[ipc] POST ipc://localhost/greet origin=Some("app://localhost") -> 500 missing string arg name
[ipc] POST ipc://localhost/secret origin=Some("app://localhost") -> 403 command secret not allowed for app://localhost
[ipc] POST ipc://localhost/nope origin=Some("app://localhost") -> 404 unknown command nope
[ipc] POST ipc://localhost/greet origin=Some("app://localhost") -> 200 "hello, raw"
[ipc] POST ipc://localhost/secret origin=Some("app://localhost") -> 403 command secret not allowed for app://localhost
[ipc] POST ipc://localhost/nope origin=Some("app://localhost") -> 404 unknown command nope
[ipc] GET ipc://localhost/greet origin=Some("app://localhost") -> 405 only POST is allowed
[ipc] POST ipc://localhost/greet origin=Some("app://other") -> 403 command greet not allowed for app://other
REPORT
invoke greet => ok "hello, rie"
invoke add => ok 42
invoke greet {} => err missing string arg name
invoke secret => err command secret not allowed for app://localhost
invoke nope => err unknown command nope
fetch POST greet => 200 application/json "hello, raw"
fetch POST secret => 403 text/plain command secret not allowed for app://localhost
fetch POST nope => 404 text/plain unknown command nope
fetch GET greet => 405 text/plain only POST is allowed
iframe greet => err command greet not allowed for app://other
- 每个请求都带着
Origin,同源页面是app://localhost,iframe 是app://other。iframe 里的invoke和主页面是同一份代码,被拦下只因为来源不同。 - 日志里只有
POST和那一个GET,没有OPTIONS。带Content-Type: application/json的跨源POST按 CORS 规则要先预检,WebKit 对自定义 scheme 没有发。tauri 的ipc://处理函数留着OPTIONS分支,一次 invoke 在 macOS 上同样没看到预检。 - 一行日志对应一次执行:没有哪个命令跑了两次。
handle 里插入 Access-Control-Allow-Origin 的那一行删掉,再跑演示程序。Rust 日志会少几行?页面上 invoke('greet', { name: 'rie' }) 得到什么?平台同上,debug 构建。答案在页末。测试
tao 的 EventLoop 只能在主线程上建(tao:窗口与主线程),而 libtest 把每个测试放在子线程里跑。所以三个窗口测试各是一个 harness = false 的测试程序,自己写 main:
[[test]]
name = "round_trip"
harness = false
rejected 只给 app://localhost 放行 greet,同时注册了 add;页面调一次 greet、一次 add,再让 app://other/ 的 iframe 调 greet:
fn main() {
// add 注册了,但没给 app://localhost;greet 只给了 app://localhost
let ipc = Ipc::default()
.command("greet", demo::greet)
.command("add", demo::add)
.allow("app://localhost", &["greet"]);
// ... script:主页面 settle 两次 invoke,iframe 调 greet 后 parent.postMessage
let report = run(ipc, script, Duration::from_secs(10)).unwrap();
println!("report: {report}");
let got: Value = serde_json::from_str(&report).unwrap();
assert_eq!(
got,
json!({
"greet": { "ok": "hello, rie" },
"add": { "err": "command add not allowed for app://localhost" },
"iframe": { "err": "command greet not allowed for app://other" },
})
);
println!("rejected ok");
}
round_trip 调 greet 和 add,unknown 调 nope。cargo test --offline(节选):
Running tests/rejected.rs (~/exp-tauri/target-hands/debug/deps/rejected-3c3eb90b88bcb27b)
report: {"greet":{"ok":"hello, rie"},"add":{"err":"command add not allowed for app://localhost"},"iframe":{"err":"command greet not allowed for app://other"}}
rejected ok
Running tests/round_trip.rs (~/exp-tauri/target-hands/debug/deps/round_trip-4b1877a640a2a761)
report: {"greet":"hello, rie","add":42}
round_trip ok
Running tests/unknown.rs (~/exp-tauri/target-hands/debug/deps/unknown-b764f99b81a17e65)
report: {"err":"unknown command nope"}
unknown ok
一个从来没红过的测试说明不了什么。把 dispatch 里的 if !allowed 改成 if false && !allowed 再跑 rejected:
report: {"greet":{"ok":"hello, rie"},"add":{"ok":3},"iframe":{"ok":"hello, iframe"}}
thread 'main' (2691915) panicked at tests/rejected.rs:34:3:
assertion `left == right` failed
left: Object {"add": Object {"ok": Number(3)}, "greet": Object {"ok": String("hello, rie")}, "iframe": Object {"ok": String("hello, iframe")}}
right: Object {"add": Object {"err": String("command add not allowed for app://localhost")}, "greet": Object {"ok": String("hello, rie")}, "iframe": Object {"err": String("command greet not allowed for app://other")}}
允许列表一失效,没放行的命令和别的来源都调通了,测试能抓到。
和 tauri 并排跑
hands-cmp 的配置和 on_message 与分发 里的 ipc2-acl 相同:build.rs 声明应用 ACL 清单(greet、secret、report),能力只放行 greet 和 report。页面把 window.fetch 换成记录版本(ipc-protocol.js 调用时才查全局 fetch),然后用 __TAURI_INTERNALS__.invoke 调四次,记下 JS 发出的头和收到的状态码、响应头:
REPORT {
"greet": {
"ok": "hello, rie"
},
"greet {}": {
"err": "invalid args `name` for command `greet`: command greet missing required key name"
},
"secret": {
"err": "secret not allowed. Permissions associated with this command: allow-secret"
},
"nope": {
"err": "nope not allowed. Command not found"
},
"wire": [
{
"url": "ipc://localhost/greet",
"sent": {
"content-type": "application/json",
"tauri-callback": "2459172879",
"tauri-error": "3931786908",
"tauri-invoke-key": "kSFw$i:zP/P<tnI[+kQt"
},
"body": "{\"name\":\"rie\"}",
"status": 200,
"got": {
"content-length": "12",
"content-type": "application/json",
"tauri-response": "ok"
}
},
...
"url": "ipc://localhost/nope",
...
"status": 200,
"got": {
"content-length": "37",
"content-type": "application/json",
"tauri-response": "error"
}
}
]
}
(... 处省掉的两条 greet {}、secret 也是 status: 200、tauri-response: error。)同样四次调用,两边的线上差别:
| mini | tauri 2.12.5 | |
|---|---|---|
| 请求头 | Content-Type(加 WebKit 的 Origin) | 再加 Tauri-Callback、Tauri-Error、Tauri-Invoke-Key |
| 成败 | 状态码:200 / 403 / 404 / 500 | 一律 200,看 Tauri-Response: ok / error |
| 错误的正文 | text/plain 一行字 | application/json,错误值序列化后的 JSON |
secret(存在,没放行) | command secret not allowed for app://localhost | secret not allowed. Permissions associated with this command: allow-secret |
nope(不存在) | unknown command nope | nope not allowed. Command not found |
下面逐项看 tauri 多出来的东西各解决什么问题。
invoke key
generate_invoke_key 在启动时生成 16 个随机字节、Z85 编码成 20 个字符(上面的 kSFw$i:zP/P<tnI[+kQt),作为常量写进 ipc-protocol.js 的闭包。Webview::on_message 第一件事就是比对它,不对就 return,连拒绝都不回(on_message 与分发)。ipc-protocol.js 里那个常量的注释写的是「A runtime generated key to ensure an IPC call comes from an initialized frame.」。现在这套是 tauri 2.0.0-beta.20 的 commit d950ac123 加的,那是安全公告 GHSA-57fm-592m-34r7 的修复,CHANGELOG 只有一句「Only process IPC commands from the main frame.」。
它依赖的事实是:tauri 自己的初始化脚本全部只进主 frame(初始化脚本),子 frame 里没有这个常量,自己拼的 fetch 带不出正确的 key。mini 没有这一层:任何 frame 都能直接 fetch('ipc://localhost/...'),挡住它的只有允许列表。上面的 iframe 被拦,是因为它的源 app://other 不在表里;按代码,换成一个同源 app://localhost/ 的 iframe,mini 就会放行。key 也不是秘密:主 frame 里的任何脚本都能截下 fetch 的头读到它(上面的对照程序就是这么做的),远程页面也一样拿得到(ipc 协议)。它分的是 frame,不是脚本。
回调 id
tauri 每次调用登记两个随机 u32,放进 core.js 闭包里的 callbacks 表,经 Tauri-Callback、Tauri-Error 头发出去(JS 侧:invoke 与回调)。在 fetch 路上它们其实用不上:协议的应答闭包不看这两个 id,JS 侧按 Tauri-Response 选回调,和 mini 按 res.ok 分支是一回事。
id 是给另外两条路准备的:postMessage 没有回程,Rust 只能 eval 一段 window.__TAURI_INTERNALS__.runCallback(<id>, ...),必须先知道 id;Channel 要在一次调用结束之后反复往页面推消息,也是借一个回调 id。mini 只有 fetch 这一条路,一个请求对一个响应,fetch 的 Promise 足够。
成败:状态码还是响应头
mini 用状态码表示成败,最自然;tauri 恰恰是从这里改走的。
#10585(「refactor(core): return 200 on any IPC call」,首次发布于 tauri 2.0.0-rc.3,是 rc 版)之前,命令返回 Err 时协议回 StatusCode::BAD_REQUEST,解析失败也是 400。提交说明写的原因是:「By default the webview prints a Failed to load resource: the server responded with a status of 400 (Bad Request) ipc://localhost error message when a command returns an error, which is confusing to users.」改法是「return status 200 on any call, with a header to indicate whether the result was ok or not」:加了 Tauri-Response 头和让 JS 读得到它的 Access-Control-Expose-Headers,parse_invoke_request 失败改回 500。CHANGELOG 记的是「Change how IPC handles errors to simplify what's logged in the console.」
另一处差别是错误的形状。mini 的命令只能返回 String 错误,正文是纯文本;tauri 的错误值只要实现 Serialize 就行,序列化成 JSON 交给 reject,页面拿到的可以是带 kind、message 的对象(参数、返回值与 InvokeResolver)。
二进制正文
mini 的 invoke 一律 JSON.stringify,命令签名也只有 Value。tauri 的 processIpcMessage 看顶层参数:ArrayBuffer、类型化数组、数组走 application/octet-stream,Rust 侧是 InvokeBody::Raw;命令返回 ipc::Response 时回 application/octet-stream,页面拿到 ArrayBuffer(JS 侧:invoke 与回调、参数、返回值与 InvokeResolver)。在 mini 上加这一项,要改三处:invoke 按参数类型选 Content-Type,dispatch 按 Content-Type 分两支,命令签名换成能装字节的类型。
Channel
mini 的命令返回一次就结束。tauri 的 ipc::Channel 是一个页面登记的回调 id,Rust 拿着它可以在命令返回之后反复 send:小消息 eval,大消息先存起来、再由页面 fetch 取回,每条带序号(Channel)。这需要三样 mini 没有的东西:回调 id、eval 的通道、一张跨请求保存消息的表。
postMessage 退路
mini 的 fetch 一失败,invoke 就以那个错误拒绝,到此为止。tauri 的 ipc-protocol.js 在 fetch 链上任何一步失败时,永久切到 window.ipc.postMessage,把同一条消息再发一遍(JS 侧:invoke 与回调)。它因此能在 CSP 拦下 fetch 时照常工作(一次 invoke),代价是请求已经到达 Rust 时命令会执行两次。动脑筋那个实验就是一个现成的例子:响应到了、页面却读不到,mini 只执行一次、页面报错;换成 tauri 的写法,会重发一次。
ACL
mini 的允许列表是「来源字符串 × 命令名」的精确匹配,每个请求都查。tauri 的 ACL(ACL 模型、运行时检查)多出这些维度:
| 维度 | mini | tauri |
|---|---|---|
| 来源 | 精确的源字符串 | 本地 / 远程;远程按 remote.urls 的 URL 模式 |
| 窗口、webview | 不区分 | 能力按窗口和 webview 标签的 glob 生效 |
| 拒绝 | 没有,不在表里就是拒绝 | 单独的 deny,只看来源 |
| 作用域 | 没有 | 命令通过 CommandScope / GlobalScope 取到 |
| 什么时候查 | 每个请求 | 插件命令、有应用清单、远程来源时才强制;本地页面调应用命令且没有应用清单时不查 |
| 和找命令的先后 | 先找命令 | 先查 ACL |
最后一行就是上面 nope 两边结果不同的原因。tauri 的 on_message 不知道 generate_handler! 里有哪些命令,只能先判断「这个名字在这里能不能调」,再把请求交给处理函数、看它返回 true 还是 false(on_message 与分发)。mini 的命令表是一个 HashMap,可以先查,代价是让没被放行的来源分辨出哪些命令存在。
参数和线程
mini 的命令自己从 Value 里取字段(args["name"].as_str()),缺了就自己报错。tauri 的 #[command] 宏为每个参数生成一次 CommandArg::from_command,按 camelCase 的键取值交给 serde,错误文本是上面那句 invalid args `name` for command `greet`: command greet missing required key name(#[command] 与 generate_handler!)。mini 的命令全在主线程上跑;tauri 的 async 命令在 tokio 线程上执行,协议的应答不必回到主线程(参数、返回值与 InvokeResolver)。
动脑筋答案
删掉 ACCESS_CONTROL_ALLOW_ORIGIN 那一行,重新编译运行:
[ipc] POST ipc://localhost/greet origin=Some("app://localhost") -> 200 "hello, rie"
[ipc] POST ipc://localhost/add origin=Some("app://localhost") -> 200 42
[ipc] POST ipc://localhost/greet origin=Some("app://localhost") -> 500 missing string arg name
[ipc] POST ipc://localhost/secret origin=Some("app://localhost") -> 403 command secret not allowed for app://localhost
[ipc] POST ipc://localhost/nope origin=Some("app://localhost") -> 404 unknown command nope
[ipc] POST ipc://localhost/greet origin=Some("app://localhost") -> 200 "hello, raw"
[ipc] POST ipc://localhost/secret origin=Some("app://localhost") -> 403 command secret not allowed for app://localhost
[ipc] POST ipc://localhost/nope origin=Some("app://localhost") -> 404 unknown command nope
[ipc] GET ipc://localhost/greet origin=Some("app://localhost") -> 405 only POST is allowed
[ipc] POST ipc://localhost/greet origin=Some("app://other") -> 403 command greet not allowed for app://other
REPORT
invoke greet => err TypeError: Load failed
invoke add => err TypeError: Load failed
invoke greet {} => err TypeError: Load failed
invoke secret => err TypeError: Load failed
invoke nope => err TypeError: Load failed
fetch POST greet => rejected TypeError: Load failed
fetch POST secret => rejected TypeError: Load failed
fetch POST nope => rejected TypeError: Load failed
fetch GET greet => rejected TypeError: Load failed
iframe greet => err TypeError: Load failed
- Rust 日志一行不少,十个请求全部到达、全部执行,
greet照样返回了"hello, rie"。WebKit 没有预检,请求直接发出;CORS 检查发生在响应回来之后。 - 页面上每一次都是
TypeError: Load failed。响应没有Access-Control-Allow-Origin,WebKit 不把它交给脚本,fetch直接拒绝,连状态码都看不到:成功、403、404 在页面上变成了同一个错误。 - 每个命令都只执行了一次,因为 mini 的
invoke不重试。tauri 的ipc-protocol.js遇到同样的拒绝,会切到postMessage再发一遍,命令执行第二次。tauri 的get把这个头放进所有分支共用的respond出口(ipc 协议),就是为了不让任何一种响应漏掉它。
小结
- 一个能用的 IPC 只要三样东西:一个接
POST的自定义协议、一张「命令名 → 函数」的表、一个把fetch包成invoke的十几行 JS。mini 的核心 135 行,直接用 wry 0.57.1 和 tao 0.37.2。 - 页面在
app://、请求发往ipc://,是跨源请求:响应必须带Access-Control-Allow-Origin,否则命令照样执行,页面却只看到TypeError: Load failed。来源从Origin头取,页面伪造不了。 - 同步的
with_custom_protocol让每个命令在主线程上跑;tauri 用异步的那个注册函数,回答可以晚些从 tokio 线程发出。 - tauri 多出来的东西各有用处:invoke key 把调用限制在 tauri 初始化过的主 frame;回调 id 给
postMessage和 Channel 用,fetch路上其实不需要;一律 200 加Tauri-Response头,是为了不让命令出错在控制台里显示成加载失败。 - tauri 先查 ACL、再找命令,所以有应用清单时拼错的命令名得到「not allowed」;mini 先找命令,结果更直白,但让没被放行的来源能探出哪些命令存在。