Rust in Everything
基于 tauri 2.12.5

写一个迷你 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.jsJS 侧的 invoke14
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)。

📝NOTE
没有蠢问题

问: 允许列表只认 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
  }
情形状态码正文
不是 POST405only POST is allowed
没有 Origin400missing Origin header
命令不在表里404unknown command <cmd>
来源没被允许调这个命令403command <cmd> not allowed for <origin>
正文不是 JSON400bad JSON body: ...
命令返回 Err500命令给的那句话
命令返回 Ok200Value 的 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 上同样没看到预检。
  • 一行日志对应一次执行:没有哪个命令跑了两次。
❗IMPORTANT
动脑筋:把 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。)同样四次调用,两边的线上差别:

minitauri 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://localhostsecret not allowed. Permissions associated with this command: allow-secret
nope(不存在)unknown command nopenope 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 恰恰是从这里改走的。

📝NOTE
冷知识:tauri 的命令出错曾经回 400

#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 模型、运行时检查)多出这些维度:

维度minitauri
来源精确的源字符串本地 / 远程;远程按 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 先找命令,结果更直白,但让没被放行的来源能探出哪些命令存在。

💬 关联讨论

发起讨论
加载中...