编译期解析
主线位置:启动主线第一步
generate_context!里的一小段。宏读到 tauri-build 写好的清单表和能力文件后调用Resolved::resolve,结果作为Context的一部分编进二进制;调用主线上on_message查的就是这张表。本页讲这张表怎么算出来、长什么样,以及运行时add_capability怎么往里追加。
flowchart TB
caps["能力 BTreeMap"] --> active["按平台过滤"]
active --> entry["逐个 PermissionEntry"]
manifests["清单表"] --> expand["get_permissions 展开集合"]
entry --> expand
expand --> merge["合并作用域"]
merge --> check{"有命令吗"}
check -->|没有| gscope["global_scope 按插件"]
check -->|有| sid["分配 scope_id"]
sid --> rc["resolve_command 每个来源一条"]
rc --> allowed["allowed_commands"]
rc --> denied["denied_commands"]
输入从哪来、宏怎么读文件,在 tauri-build 和 generate_context! 已经讲过:清单表 BTreeMap<String, Manifest> 来自 OUT_DIR/acl-manifests.json,能力表来自 capabilities.json 再由 get_capabilities 合并配置。本页从 Resolved::resolve 开始。实验沿用上一页的 sec-acl,平台 M2 Max、macOS 27、rustc 1.99.0。
结果的形状
Resolved 有五个字段:
pub struct Resolved {
/// If we should check the ACL for the app commands
pub has_app_acl: bool,
/// The commands that are allowed. Map each command with its context to a [`ResolvedCommand`].
pub allowed_commands: BTreeMap<String, Vec<ResolvedCommand>>,
/// The commands that are denied. Map each command with its context to a [`ResolvedCommand`].
pub denied_commands: BTreeMap<String, Vec<ResolvedCommand>>,
/// The store of scopes referenced by a [`ResolvedCommand`].
pub command_scope: BTreeMap<ScopeKey, ResolvedScope>,
/// The global scope.
pub global_scope: BTreeMap<String, ResolvedScope>,
}
两张命令表的键是 IPC 里的命令名(greet、plugin:files|read),值是一列 ResolvedCommand:一个执行上下文(Local 或 Remote { url })、一列窗口 glob、一列 webview glob、一个可选的作用域编号;debug 构建里还有 referenced_by,记下是哪份能力的哪条权限带来的。作用域不直接挂在命令上,而是存进 command_scope,用 u64 编号引用:同一份作用域可能被一条权限里的多个命令共享,存一次就够了。has_app_acl 不是解析出来的,是看清单表里有没有 __app-acl__ 这个键,它的作用见运行时检查。
ResolvedCommandReference 的字段带 #[cfg(debug_assertions)],结构体本身不带。源码注释说这是为了 #13865,避免「different debug_assertions settings」下的编译错误:生成这段代码的过程宏(以及它依赖的 tauri-utils)和使用它的 crate 不一定用同一套编译设置,所以宏生成的是 ResolvedCommandReference::new(capability, permission),release 下这个构造函数把参数丢掉。
一步一步
外层循环只看当前平台生效的能力(capability.is_active(&target)),对每个能力调 with_resolved_permissions,它对能力里的每个 PermissionEntry 做三件事:
get_permissions按前缀找清单(没有前缀就是__app-acl__),把default或权限集递归展开成一列具体的Permission,找不到报UnknownManifest/UnknownPermission,再按权限自己的platforms过滤。- 对展开出的每条权限,把能力里追加的作用域和权限自带的作用域并到一起。所以能力给一个权限集追加作用域时,集合里每条权限都会带上这份作用域。
- 交给回调。
回调里分两路:
if commands.allow.is_empty() && commands.deny.is_empty() {
// global scope
global_scope.entry(key).or_default().push(scope);
} else {
let scope_id = if scope.allow.is_some() || scope.deny.is_some() {
current_scope_id += 1;
command_scope.insert(
current_scope_id,
ResolvedScope {
allow: scope.allow.unwrap_or_default(),
deny: scope.deny.unwrap_or_default(),
},
);
Some(current_scope_id)
} else {
None
};
for allowed_command in &commands.allow {
resolve_command(
&mut allowed_commands,
if key == APP_ACL_KEY {
allowed_command.to_string()
} else if let Some(core_plugin_name) = key.strip_prefix("core:") {
format!("plugin:{core_plugin_name}|{allowed_command}")
} else {
format!("plugin:{key}|{allowed_command}")
},
// ...
命令名在这里从「清单 + 命令」变成 IPC 里用的名字:应用清单的命令原样,core:window 的 close 变成 plugin:window|close(去掉 core:,所以 JS 侧调的还是 plugin:window|...),其余插件是 plugin:<清单名>|<命令>。拒绝列表走同样的路,进 denied_commands。
resolve_command 按能力的来源展开:local 为真加一条 Local,remote.urls 每个模式加一条 Remote。每条都带上这份能力的 windows、webviews(排序后编译成 glob::Pattern)和刚才的作用域编号。远程模式解析失败直接 panic!("invalid URL pattern for remote URL ...");编译期这一步在 generate_context! 的展开过程中执行。
所有能力处理完,global_scope 里同一个键下的多份作用域合并成一份 ResolvedScope。
这里没有「拒绝优先」的逻辑。同一个命令可以同时出现在 allowed_commands 和 denied_commands 里,两份都原样保留,裁决留给运行时的 resolve_access。目录里说的「拒绝优先」发生在运行时,而且规则和直觉不一样,见运行时检查。
一个小应用的 Resolved
sec-acl 的测试读出 OUT_DIR 里的两份 JSON,自己调一次 Resolved::resolve(和 generate_context! 里那一次输入相同),逐条打印:
let r = Resolved::resolve(&acl, caps, tauri::utils::platform::Target::current()).unwrap();
println!("has_app_acl: {}", r.has_app_acl);
for (name, map) in [("allowed_commands", &r.allowed_commands), ("denied_commands", &r.denied_commands)] {
println!("{name}:");
for (cmd, list) in map {
for c in list {
#[cfg(debug_assertions)]
let by = format!(" by {}/{}", c.referenced_by.capability, c.referenced_by.permission);
#[cfg(not(debug_assertions))]
let by = String::new();
println!(
" {cmd:<18} {:?} windows={:?} webviews={:?} scope_id={:?}{by}",
c.context,
c.windows.iter().map(|p| p.as_str()).collect::<Vec<_>>(),
c.webviews.iter().map(|p| p.as_str()).collect::<Vec<_>>(),
c.scope_id
);
}
}
}
println!("command_scope: {:?}", r.command_scope);
println!("global_scope: {:?}", r.global_scope);
main 能力里和 plugin:files|read 有关的有三项:files:allow-read-tmp、带追加作用域的 files:allow-read、files:global-deny-secret。allowed_commands["plugin:files|read"] 这个列表有几个元素?能力 main 一共引用了十项,audit 一项。allowed_commands 的键不会比引用的权限多,作用域编号从 1 开始连续分配,只有带作用域、又带命令的权限才拿编号。main 能力没有引用 core:default,所以表里没有任何 plugin:window|... 这类命令;真实应用一般都会引用它,表会长出几百行。
运行时追加:add_capability
Resolved 编进二进制以后,由 runtime_authority! 宏变成 RuntimeAuthority(宏的两个版本见 generate_context!)。开了 dynamic-acl feature(默认开)时,它留着完整的清单表,于是运行时可以再解析能力。入口是 Manager 上的 add_capability,参数是任何实现了 RuntimeCapability 的东西:一个 JSON 字符串(impl<T: AsRef<str>>,解析失败 expect("invalid capability")),或者 CapabilityBuilder。
add_capability_inner 对新能力做一次完整的解析,再把五张表合并进已有的:
let resolved = Resolved::resolve_with_base_scope_id(
&self.acl,
capabilities,
tauri_utils::platform::Target::current(),
self.scope_manager.last_scope_id(),
)
.unwrap();
resolve_with_base_scope_id 和 resolve 是同一个函数,只是作用域编号从已有的最大编号之后开始分,新旧编号不会撞。全局作用域按插件名追加,并把全局作用域的缓存清空;命令表按命令名追加到已有的 Vec 后面。
resolve_with_base_scope_id 是 #15291(首次发布于 tauri 2.12.0)加的。在此之前 add_capability 直接调 resolve,新能力的作用域编号又从 1 开始,和编译期那张表里的编号重叠,合并时把新作用域并进了别的命令的作用域。CHANGELOG 原文:「Fix capabilities added at runtime through add_capability (feature dynamic-acl) being merged into the command scopes of the build time ACL, which polluted unrelated plugin scopes and made their deserialization fail.」resolve 上方留了一条注释,说 v3 要把 base_scope_id 直接做成 resolve 的参数,「so that callers merging into an already resolved ACL cannot forget to offset the scope ids」。
sec-acl 在 main 窗口里模拟一个来自 http://127.0.0.1:18970/page.html 的请求,追加能力前后各发一次:
call(&main, "greet", remote, serde_json::json!({ "name": "rie" }));
app
.add_capability(
tauri::ipc::CapabilityBuilder::new("remote-greet")
.remote("http://127.0.0.1:18970/*".into())
.local(false)
.window("main")
.permission("allow-greet"),
)
.unwrap();
call(&main, "greet", remote, serde_json::json!({ "name": "rie" }));
call(&main, "greet", "http://127.0.0.1:18971/page.html", serde_json::json!({ "name": "rie" }));
[main @ http://127.0.0.1:18970/page.html] greet -> Err:
greet not allowed on window "main", webview "main", URL: http://127.0.0.1:18970/page.html
allowed on: [windows: "main", URL: local]
referenced by: capability: main, permission: allow-greet
[main @ http://127.0.0.1:18970/page.html] greet -> Ok("hello, rie")
[main @ http://127.0.0.1:18971/page.html] greet -> Err:
greet not allowed on window "main", webview "main", URL: http://127.0.0.1:18971/page.html
allowed on: [windows: "main", URL: local], [windows: "main", URL: http://127.0.0.1:18970/*]
referenced by: capability: main, permission: allow-greet || capability: remote-greet, permission: allow-greet
第三次的拒绝信息里,greet 的允许列表已经有两条,第二条就是刚追加的。--release 构建里第二次同样成功(dynamic-acl 不依赖 debug_assertions),拒绝信息变成一句 Command greet not allowed by ACL。在真实 webview 里用同样的能力放行一个本地 HTTP 服务上的页面,见运行时检查。
add_capability 的签名返回 Result,但解析失败走的是上面那个 .unwrap()。权限名写错时:
app.add_capability(
tauri::ipc::CapabilityBuilder::new("typo").window("main").permission("allow-gret"),
)
thread 'tests::add_capability_typo' (2609264) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.12.5/src/ipc/authority.rs:180:6:
called `Result::unwrap()` on an `Err` value: UnknownPermission { key: "app manifest", permission: "allow-gret" }
调用方拿不到 Err,进程 panic,而且是在持有 runtime_authority 的 Mutex 时 panic 的,锁会中毒。这和编译期的行为差得很远:同样的拼写错误在构建时会得到「did you mean "allow-greet"?」(见 tauri-build)。在 setup 里调用它的应用,等于把一个构建期错误推迟成了启动时崩溃。
动脑筋答案
两个。dump_resolved 测试的输出(debug 构建):
manifests: ["__app-acl__", "core", "core:app", "core:event", "core:image", "core:menu", "core:path", "core:resources", "core:tray", "core:webview", "core:window", "files", "inline", "notes"]
has_app_acl: true
allowed_commands:
greet Local windows=["main"] webviews=[] scope_id=None by main/allow-greet
open_doc Local windows=["main"] webviews=[] scope_id=Some(1) by main/allow-open-docs
plugin:files|read Local windows=["main"] webviews=[] scope_id=Some(2) by main/allow-read-tmp
plugin:files|read Local windows=["main"] webviews=[] scope_id=Some(3) by main/allow-read
plugin:inline|read Local windows=["main"] webviews=[] scope_id=None by main/allow-read
plugin:notes|read Local windows=["main"] webviews=[] scope_id=None by main/allow-read
wipe Local windows=["main"] webviews=[] scope_id=None by main/allow-wipe
denied_commands:
wipe Local windows=["audit"] webviews=[] scope_id=None by audit/deny-wipe
command_scope: {1: ResolvedScope { allow: [Map({"path": String("/docs/*")})], deny: [Map({"path": String("/docs/private/*")})] }, 2: ResolvedScope { allow: [Map({"path": String("/tmp/*")})], deny: [] }, 3: ResolvedScope { allow: [Map({"path": String("/home/*")})], deny: [] }}
global_scope: {"__app-acl__": ResolvedScope { allow: [Map({"path": String("/app/*")})], deny: [] }, "files": ResolvedScope { allow: [], deny: [Map({"path": String("*secret*")})] }, "notes": ResolvedScope { allow: [Map({"path": String("/notes/*")})], deny: [] }}
逐项看:
files:allow-read-tmp和files:allow-read都允许read,各生成一条ResolvedCommand,作用域编号分别是 2(权限自带的/tmp/*)和 3(能力追加的/home/*)。两条不合并,运行时CommandScope会把所有命中条目的作用域拼在一起,上一页看到的allow=["/tmp/*", "/home/*"]就是这么来的。files:global-deny-secret没有命令,不在任何命令表里,只出现在global_scope["files"]。- 应用的
global-app也没有命令,进了global_scope["__app-acl__"]。 wipe同时在两张表里,denied_commands那条的窗口是["audit"]。解析不管冲突。- 清单表的键里有
core,它只有一个默认权限集,见 tauri-build。 --release构建的输出相同,只是每行少了by .../...:referenced_by在 release 里不存在。
小结
Resolved::resolve对每份生效的能力展开权限(default、权限集递归展开、按平台过滤),把能力追加的作用域并进每条权限。- 有命令的权限按来源(
local一条、每个remote.urls一条)生成ResolvedCommand,带窗口和 webview 的 glob、作用域编号;作用域另存在command_scope。 - 没有命令的权限只贡献作用域,按清单名进
global_scope;应用的键是__app-acl__。 - 允许和拒绝分开存,解析阶段不裁决冲突。
dynamic-acl下add_capability用同一个解析函数,作用域编号接着已有的分;解析失败时它 panic 而不是返回Err。