on_message 与分发
主线位置:调用主线的中段。
ipc://协议处理函数用parse_invoke_request拼出InvokeRequest(见 ipc 协议),交给Webview::on_message;本页从这里讲到generate_handler!生成的闭包被调用为止。闭包里面的包装代码见 #[command] 与 generate_handler!,参数怎么取、结果怎么回去见参数、返回值与 InvokeResolver。
flowchart TB
req["InvokeRequest"] --> key{"invoke key 对吗"}
key -->|不对| drop["return 不应答"]
key -->|对| loc["is_local_url 定来源"]
loc --> acl["resolve_access 查表"]
acl --> need{"要强制检查吗"}
need -->|"要且没查到"| deny["reject not allowed"]
need -->|"不要或查到了"| pfx{"有 plugin: 前缀"}
pfx -->|有| plug["run_plugin_invoke_handler"]
pfx -->|没有| apph["run_invoke_handler"]
plug -->|"返回 false"| nf["Command X not found"]
apph -->|"返回 false"| nf
Webview::on_message 只有一百多行(L2005–L2170),做四件事:比对 invoke key、判断来源、决定要不要拦、把请求交给应用或插件的处理函数。第一件在一次 invoke 里已经跑过:key 不对时只打一行日志就 return,不调用应答闭包,请求永远得不到回应。本页讲后三件。
本页用了三个实验 crate,都从模板 base 复制,tauri =2.12.5,--features tauri/custom-protocol,平台 M2 Max、macOS 27、rustc 1.99.0:
ipc2-noacl和ipc2-acl:同一份main.rs和页面,隐藏窗口,区别只在build.rs和能力文件。前者没有应用 ACL 清单,后者有。ipc2-mock:不建真窗口,用tauri::test的MockRuntime和get_ipc_response直接调on_message,伪造远程来源、手工放行插件命令,把所有分支跑一遍。
来源:本地还是远程
on_message 第一行就算出 is_local,依据是 InvokeRequest.url,也就是请求的 Origin 头(一次 invoke 讲了它为什么取 Origin 而不是 webview 的地址)。is_local_url 三个条件满足一个就算本地:
fn is_local_url(&self, current_url: &Url) -> bool {
let uses_https = current_url.scheme() == "https";
// if from `tauri://` custom protocol
({
let protocol_url = self.manager().tauri_protocol_url(uses_https);
current_url.scheme() == protocol_url.scheme()
&& current_url.domain() == protocol_url.domain()
}) ||
// or if relative to `devUrl` or `frontendDist`
self
.manager()
.get_app_url(uses_https)
.make_relative(current_url)
.is_some()
// or from a custom protocol registered by the user
|| ({
let scheme = current_url.scheme();
let protocols = self.manager().webview.uri_scheme_protocols.lock().unwrap();
#[cfg(all(not(windows), not(target_os = "android")))]
let local = protocols.contains_key(scheme);
// ...
local
})
}
| 条件 | macOS 上的例子 |
|---|---|
| scheme 和域名与 tauri 协议的地址相同 | tauri://localhost |
能相对于应用地址(开发模式的 devUrl,否则 frontendDist 对应的地址)表示 | 开发时的 http://localhost:1420/... |
| scheme 是用户自己注册过的 URI scheme 协议 | register_uri_scheme_protocol("app", ..) 之后的 app://... |
第三条在 Windows 和 Android 上换了写法:那里自定义协议的地址形如 http://<协议名>.localhost/...,所以改为看 scheme 是否与 tauri 协议相同、域名去掉 .localhost 后是否是注册过的协议名。这部分只读了源码。
三条都不满足就是远程,Origin::Remote { url }。这个 Origin 和 webview 的标签、所在窗口的标签一起交给 RuntimeAuthority::resolve_access,查出这个命令在这个上下文里被哪些能力允许。查表的规则(窗口和 webview 的 glob、remote.urls 的 URL 模式、拒绝优先)属于运行时检查,这里只关心它的结果:Option<Vec<ResolvedCommand>>,None 表示没有任何能力允许。
先查,再决定拦不拦
on_message 对每一个请求都调 resolve_access,顺手取出 has_app_manifest();查到的结果放进 Invoke.acl,命令参数里的 CommandScope / GlobalScope 从这里取作用域。查完之后才决定要不要用这个结果拦人:
let plugin_command = request.cmd.strip_prefix("plugin:").map(|raw_command| {
let mut tokens = raw_command.split('|');
// safe to unwrap: split always has a least one item
let plugin = tokens.next().unwrap();
let command = tokens.next().map(|c| c.to_string()).unwrap_or_default();
(plugin, command)
});
// Check ACL on plugin commands, when the app defined its ACL manifest,
// or when the request comes from a non-local (remote) origin. This
// ensures remote content can never reach custom commands unless an
// explicit `remote` capability has been configured for them.
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()
{
#[cfg(debug_assertions)]
{
// ...
invoke.resolver.reject(
manager
.runtime_authority
.lock()
.unwrap()
.resolve_access_message(
key,
&command_name,
invoke.message.webview.window().label(),
invoke.message.webview.label(),
&acl_origin,
),
);
}
#[cfg(not(debug_assertions))]
invoke
.resolver
.reject(format!("Command {} not allowed by ACL", request.cmd));
return;
}
拦下的条件是三项相与:
- 这个请求「需要检查」:是插件命令(
plugin:开头),或应用声明了自己的 ACL 清单,或来源是远程; - 命令不是
plugin:__TAURI_CHANNEL__|fetch(下面单独讲); resolve_access没查到允许它的能力。
反过来读第一项:应用自己的命令、本地页面、应用没有 ACL 清单,三者同时成立时根本不看查表结果。has_app_acl_manifest 来自编译期:tauri-build 的 acl::build 只有在应用自己定义了权限时,才往 ACL 清单里加一个键 __app-acl__。应用的权限有两个来源:build.rs 里 Attributes::app_manifest(AppManifest::new().commands(..)) 为每个命令生成的 allow-* / deny-*,以及应用 permissions/ 目录下手写的权限文件。Resolved 的 has_app_acl 就是「清单里有没有这个键」(见 tauri-build)。光写能力文件不算。
实验:同一个页面,有没有应用清单
两个 crate 共用的 main.rs,四个命令都注册了:
#[tauri::command]
fn greet(name: &str) -> String {
format!("hello, {name}")
}
#[tauri::command]
fn secret() -> &'static str {
"the secret"
}
#[tauri::command]
fn hidden() -> &'static str {
"not in the app manifest"
}
#[tauri::command]
fn report(app: tauri::AppHandle, msg: String) {
println!("REPORT {msg}");
app.exit(0);
}
fn main() {
// ... 15 秒看门狗
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet, secret, hidden, report])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
页面依次调五个命令,把结果交回 report:
<script>
(async () => {
const inv = window.__TAURI_INTERNALS__.invoke;
const out = {};
for (const [cmd, args] of [['greet', { name: 'rie' }], ['secret', {}], ['hidden', {}], ['nope', {}], ['plugin:event|nope', {}]]) {
try { out[cmd] = { ok: await inv(cmd, args) }; } catch (e) { out[cmd] = { err: e }; }
}
await inv('report', { msg: JSON.stringify(out, null, 1) });
})();
</script>
ipc2-noacl 的 build.rs 是模板的 tauri_build::build(),能力文件只给 main 窗口 core:default。ipc2-acl 声明了应用清单,清单里有 greet、secret、report,没有 hidden;能力只放行 greet 和 report:
fn main() {
let attributes = tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(&["greet", "secret", "report"]));
tauri_build::try_build(attributes).expect("failed to run tauri-build");
}
{
"identifier": "default",
"windows": ["main"],
"permissions": ["core:default", "allow-greet", "allow-report"]
}
ipc2-noacl,debug 构建:
REPORT {
"greet": {
"ok": "hello, rie"
},
"secret": {
"ok": "the secret"
},
"hidden": {
"ok": "not in the app manifest"
},
"nope": {
"err": "Command nope not found"
},
"plugin:event|nope": {
"err": "event.nope not allowed. Command not found"
}
}
ipc2-acl,debug 构建:
REPORT {
"greet": {
"ok": "hello, rie"
},
"secret": {
"err": "secret not allowed. Permissions associated with this command: allow-secret"
},
"hidden": {
"err": "hidden not allowed. Command not found"
},
"nope": {
"err": "nope not allowed. Command not found"
},
"plugin:event|nope": {
"err": "event.nope not allowed. Command not found"
}
}
ipc2-acl,release 构建:
REPORT {
"greet": {
"ok": "hello, rie"
},
"secret": {
"err": "Command secret not allowed by ACL"
},
"hidden": {
"err": "Command hidden not allowed by ACL"
},
"nope": {
"err": "Command nope not allowed by ACL"
},
"plugin:event|nope": {
"err": "Command plugin:event|nope not allowed by ACL"
}
}
- 没有应用清单时,本地页面能调到
generate_handler!里注册的每一个应用命令,能力文件里写没写它们无关紧要;secret照样返回。 - 有了应用清单,三项条件的第一项对所有应用命令都成立,于是每个应用命令都得在能力里被放行:
secret在清单里但能力没放行,hidden连清单都不在,结果都是拒绝。清单只是让tauri-build生成allow-secret这种权限的来源,放不放行由能力决定。 - 插件命令不管有没有应用清单都要查,所以
plugin:event|nope两边都是拒绝。 - debug 和 release 的拒绝文本不同:debug 走
resolve_access_message,按清单说明「哪些权限关联了这个命令」或者「没有这个命令」;release 只有一句Command <原始命令名> not allowed by ACL。文本怎么拼见运行时检查。
条件里的 || !is_local 是 2.11.1 才加上的:在那之前,没有应用清单的应用里,远程来源也能直接调应用命令,来龙去脉见运行时检查。
ACL 在找命令之前
注意上面 nope 的三种结果:没有应用清单时是 Command nope not found,有了清单就变成「not allowed」。on_message 不知道 generate_handler! 里有哪些命令,它只能把请求交出去、看处理函数返回 true 还是 false;而 ACL 判断在交出去之前,所以只要「需要检查」,一个不存在的命令先被 ACL 拒绝,永远走不到「not found」。
ipc2-mock 把这些分支逐个跑了一遍。测试用 mock_context 造上下文,用 Context::runtime_authority_mut() 改它的 RuntimeAuthority:要模拟「有应用清单」就换成一个 has_app_acl: true 的空 Resolved;要放行某个命令就调隐藏的 __allow_command(对本地来源、所有窗口放行)。应用里注册了一个内联插件 demo,它只有一个命令 ping:
fn demo<R: Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("demo")
.invoke_handler(tauri::generate_handler![ping])
.build()
}
fn app_with(allow: &[&str], app_acl: bool) -> (tauri::App<MockRuntime>, WebviewWindow<MockRuntime>) {
let mut ctx = mock_context(noop_assets());
if app_acl {
*ctx.runtime_authority_mut() = RuntimeAuthority::new(
Default::default(),
Resolved {
has_app_acl: true,
..Default::default()
},
);
}
for c in allow {
ctx
.runtime_authority_mut()
.__allow_command(c.to_string(), ExecutionContext::Local);
}
let app = handlers(mock_builder()).build(ctx).unwrap();
let w = WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.unwrap();
(app, w)
}
每次调用构造一个 InvokeRequest,url 取 tauri://localhost(本地)或 https://evil.example(远程),invoke_key 用 tauri::test::INVOKE_KEY。cargo test --release -- --nocapture 的输出:
== dispatch (release), no app ACL manifest, nothing allowed
greet local => Ok(Json "hello, rie")
greet remote => Err("Command greet not allowed by ACL")
nope local => Err("Command nope not found")
nope remote => Err("Command nope not allowed by ACL")
plugin:demo|ping local => Err("Command plugin:demo|ping not allowed by ACL")
plugin:__TAURI_CHANNEL__|fetch local => Err("missing channel id header")
plugin:__TAURI_CHANNEL__|fetch remote => Err("missing channel id header")
== dispatch (release), plugin commands allowed for local
plugin:demo|ping local => Ok(Json "pong")
plugin:demo|ping remote => Err("Command plugin:demo|ping not allowed by ACL")
plugin:demo|missing local => Err("Command missing not found")
plugin:ghost|x local => Err("plugin ghost not found")
plugin:demo local => Err("Command not found")
== dispatch (release), has_app_acl = true
greet local => Err("Command greet not allowed by ACL")
nope local => Err("Command nope not allowed by ACL")
greet (allowed) local => Ok(Json "hello, rie")
debug 构建下拒绝的文本换成 resolve_access_message 的长格式,其余相同。mock_context 的 ACL 清单是空的,所以 debug 文本在这里是「greet not allowed. Plugin not found」这类,和真实应用里的不一样,以上一节真实应用的输出为准。
把结果按「要不要查」和「查到没有」整理一下:
| 请求 | 要强制检查 | 查到允许 | 结果 |
|---|---|---|---|
| 应用命令,本地,无应用清单 | 否 | — | 直接分发 |
| 应用命令,远程 | 是 | 否 | not allowed |
| 应用命令,本地,有应用清单 | 是 | 看能力 | 放行或 not allowed |
| 不存在的命令,本地,无应用清单 | 否 | — | Command nope not found |
| 不存在的命令,其他情形 | 是 | 否 | not allowed |
| 插件命令,任何来源 | 是 | 看能力 | 放行或 not allowed |
plugin:__TAURI_CHANNEL__|fetch,任何来源 | 不查 | — | 直接分发 |
问: 没有应用清单,就等于应用命令完全不设防?
答: 只对本地来源不设防。2.11.1 起远程来源一律要查,没有 remote 能力就调不到。本地来源指的是 tauri://localhost 上(以及应用地址下、自定义协议下)的页面,也就是你打包进去的前端代码。威胁在于「本地页面里跑了不该跑的 JS」:打包进来的第三方脚本、渲染不可信内容时的 XSS,它们和你自己的代码拿到的是同一个 invoke,能调 generate_handler! 里的每一个命令。声明应用清单之后,每个命令要在能力里按窗口、webview 放行,才算把应用命令也纳入了最小权限。CSP 和隔离模式是另外两道防线,见 CSP、原型冻结与隔离模式。
plugin: 分流
过了 ACL,on_message 看命令名有没有 plugin: 前缀。有前缀时,strip_prefix("plugin:") 之后按 | 切,第一段是插件名,第二段是命令名(没有 | 时是空字符串),然后把 invoke.message.command 改写成不带前缀的命令名,交给 AppManager::run_plugin_invoke_handler:
if let Some((plugin, command_name)) = plugin_command {
invoke.message.command = command_name;
#[cfg(desktop)]
let command = invoke.message.command.clone();
// ...
#[allow(unused_mut)]
let mut handled = manager.run_plugin_invoke_handler(plugin, invoke);
if handled {
return;
}
// ... (mobile: 交给 Android / iOS 插件)
#[cfg(desktop)]
resolver.reject(format!("Command {command} not found"));
} else {
let command = invoke.message.command.clone();
let handled = manager.run_invoke_handler(invoke);
if !handled {
resolver.reject(format!("Command {command} not found"));
}
}
插件表里按名字找插件,找到就调它的 extend_api(plugin::Builder 建的插件就是它的 generate_handler! 闭包),找不到直接 reject("plugin {plugin} not found") 并返回 true。这一段和插件表的那把锁见插件。实验里对应三种结果:
plugin:demo|missing:插件在,闭包的match落到_返回false,拒绝文本是Command missing not found,不带插件名,因为command是改写之后的值。plugin:ghost|x:插件不在,plugin ghost not found。plugin:demo:没有|,命令名是空字符串,得到Command not found(两个空格之间是空的命令名)。
这三种都要先过 ACL,所以在正常配置下很难看到:插件不存在,就不会有它的权限清单,能力里也写不出放行它的权限,请求会先被 ACL 挡下。实验靠 __allow_command 绕过了这一步。
没有前缀的走 AppManager::run_invoke_handler,它只有一行 (self.webview.invoke_handler)(invoke),调的就是 Builder::invoke_handler 存下的闭包。返回 false 时拒绝 Command {command} not found,命令名原样。
移动端在插件没认领命令时还会把请求转给 Android / iOS 那一侧的插件实现(plugin::mobile::run_command,命令名转成 lowerCamelCase),本卷不展开。
特例:plugin:__TAURI_CHANNEL__|fetch
条件的第二项把一个命令整个排除在 ACL 之外:FETCH_CHANNEL_DATA_COMMAND,也就是 plugin:__TAURI_CHANNEL__|fetch。它是 Channel 发大消息时 JS 回头来取数据的命令(见 Channel),由 Builder::build 里注册的内置插件 __TAURI_CHANNEL__ 处理。实验里它在本地、远程两种来源下都没有被 ACL 拦,走到了命令本身,因为请求没带 Tauri-Channel-Id 头,得到 missing channel id header。
这条豁免是 #13032(「fix: channel no longer being always allowed」,首次发布于 tauri 2.4.0)加的,带着「TODO: Remove this special check in v3」;同一个提交删掉了 crates/tauri/build.rs 里给它生成权限的那一项 ("core:__TAURI_CHANNEL__", &[("fetch", true)]),换成一行注释掉的 ("core:channel", ..) 和「TODO: Enable this in v3」:这个命令不再有权限可写,只能硬编码放行。远程页面能调到它并不意味着能拿到别人的数据:队列按 webview 隔开,取数据要同一个 webview、对得上的 id,见 Channel。
ipc2-acl 的 release 构建里,本地页面调一个根本没注册的命令 nope。拒绝文本是 Command nope not found 还是别的什么?换成没有应用清单的 ipc2-noacl 呢?平台 M2 Max、macOS 27,tauri 2.12.5。动脑筋答案
ipc2-acl,release:
"nope": {
"err": "Command nope not allowed by ACL"
},
ipc2-noacl,release:
"nope": {
"err": "Command nope not found"
},
- 有应用清单时,
has_app_acl_manifest为真,应用的每个命令(包括不存在的)都「需要检查」;resolve_access("nope", ..)当然查不到,于是在交给generate_handler!的闭包之前就被拒绝。「not found」这句在on_message的最后,走不到。 - 没有应用清单、来源又是本地时,ACL 不起作用,请求进了闭包,
match落到_返回false,才是Command nope not found。 - 所以调试「not allowed by ACL」时,先确认命令名没有拼错:拼错的命令在有清单的应用里也报「not allowed」。debug 构建的文本更有用,拼错时它说「nope not allowed. Command not found」。
小结
on_message按Origin判断本地还是远程:tauri 协议地址、应用地址(devUrl/frontendDist)、用户注册的 URI scheme 都算本地。- 每个请求都查 ACL,查到的结果放进
Invoke.acl供作用域使用;但只在「插件命令、或有应用 ACL 清单、或远程来源」时才用它拦人,plugin:__TAURI_CHANNEL__|fetch永远不拦。 - 没有应用清单时,本地页面能调
generate_handler!里的每一个应用命令,能力文件管不到它们;远程来源从 2.11.1(#15266)起一律要查。 - ACL 判断在找命令之前:需要检查时,不存在的命令也报「not allowed」,不是「not found」。debug 和 release 的拒绝文本不同。
plugin:<名>|<命令>改写成不带前缀的命令名交给插件,插件不在是plugin X not found,命令不在是Command <命令> not found(不带插件名);其余交给run_invoke_handler。