#[command] 与 generate_handler!
主线位置:调用主线的中段。
Webview::on_message通过 ACL 检查后,把Invoke交给Builder::invoke_handler注册的那个闭包;这个闭包是generate_handler!生成的,按命令名match到某个命令的包装代码;包装代码由#[command]生成,逐个取参数、调用你的函数、把返回值交给InvokeResolver。本页讲这两个宏生成了什么;on_message之前的路见 on_message 与分发,参数怎么从 JSON 里反序列化、结果怎么编码回去、命令在哪个线程上跑,见参数、返回值与 InvokeResolver。
flowchart TB
onmsg["Webview::on_message"] --> handler["generate_handler! 闭包"]
handler -->|"match 命令名"| wrap["__cmd__greet 包装"]
handler -->|"没有匹配"| nf["返回 false"]
wrap --> args["CommandArg::from_command"]
args --> userfn["greet 原函数"]
userfn --> kind{"同步还是 async"}
kind -->|同步| block["kind.block 就地 respond"]
kind -->|async| spawn["respond_async_serialized"]
本页的实验 crate 是 build-cmd:从模板 base 复制,写了七个命令,用 tauri::test 的 MockRuntime 逐个调用(不建真窗口);build-cmd-err 放四个编译不过的 async 命令,每个一个 bin。依赖 tauri =2.12.5,--features tauri/custom-protocol。平台:M2 Max、macOS 27、rustc 1.99.0,cargo-expand 1.0.104。build-cmd 的命令:
use std::sync::atomic::{AtomicU32, Ordering};
#[derive(Default)]
struct Counter(AtomicU32);
#[tauri::command]
fn greet(name: &str) -> String {
format!("hello, {name}")
}
#[tauri::command]
async fn word_count(text: String) -> usize {
text.split_whitespace().count()
}
#[tauri::command]
fn bump<R: tauri::Runtime>(counter: tauri::State<'_, Counter>, app: tauri::AppHandle<R>) -> u32 {
let _ = app.package_info();
counter.0.fetch_add(1, Ordering::SeqCst) + 1
}
#[tauri::command(rename_all = "snake_case")]
fn snake(user_name: String) -> String {
user_name
}
#[tauri::command(rename = "greetUser")]
fn greet_user(user_name: String) -> String {
user_name
}
#[tauri::command]
async fn borrow_ok(name: &str) -> Result<String, String> {
Ok(name.to_uppercase())
}
#[tauri::command]
async fn peek(counter: tauri::State<'_, Counter>) -> Result<u32, String> {
Ok(counter.0.load(Ordering::SeqCst))
}
fn handlers<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
builder.invoke_handler(tauri::generate_handler![greet, word_count, bump, snake, greet_user, borrow_ok, peek])
}
#[command] 产出什么
wrapper 不改你的函数,原样输出,然后在旁边加两个 macro_rules! 宏:
quote!(
#async_command_check
#maybe_allow_unused
#function
// Command name macro used by the handler for pattern matching.
// This macro returns the command name string literal (renamed or original).
#maybe_allow_unused
#maybe_macro_export
#[doc(hidden)]
macro_rules! #command_name_macro_ident {
() => {
#command_name_value
};
}
#maybe_allow_unused
#maybe_macro_export
#[doc(hidden)]
macro_rules! #wrapper {
// double braces because the item is expected to be a block expression
($path:path, $invoke:ident) => {
// The IIFE here is for preventing stack overflow on Windows,
// see https://github.com/tauri-apps/tauri/issues/12488
{
move || {
#[allow(unused_imports)]
use #root::ipc::private::*;
// prevent warnings when the body is a `compile_error!` or if the command has no arguments
#[allow(unused_variables)]
let #root::ipc::Invoke { message: #message, resolver: #resolver, acl: #acl } = $invoke;
#maybe_span
#body
}
}()
};
}
// allow the macro to be resolved with the same path as the command function
#[allow(unused_imports)]
#visibility use {#wrapper, #command_name_macro_ident};
)
__tauri_command_name_greet!()展开成命令名字面量:默认是stringify!(greet),用了rename就是新名字。__cmd__greet!(路径, invoke)展开成真正的包装代码。它收两个参数:原函数的路径$path和Invoke变量名,在generate_handler!的调用处展开,包装代码里的$path(..)就是对原函数的调用。- 最后一行
use {..}把两个宏按函数的可见性重新导出,于是commands::greet旁边就有commands::__cmd__greet。函数是pub或pub(..)时两个宏还带#[macro_export]。
cargo expand 不显示 macro_rules! 定义,#[command] 处只剩原函数和那一行 use:
fn greet(name: &str) -> String {
::alloc::__export::must_use({
::alloc::fmt::format(format_args!("hello, {0}", name))
})
}
#[allow(unused_imports)]
use {__cmd__greet, __tauri_command_name_greet};
包装代码要到 generate_handler! 调用这个宏的地方才展开。
一个参数怎么取
parse_arg 对每个参数生成同一种表达式,不看参数的类型:
Ok(quote!(#root::ipc::CommandArg::from_command(
#root::ipc::CommandItem {
plugin: #plugin_name,
name: #command_name,
key: #key,
message: &#message,
acl: &#acl,
}
)))
key:参数模式里的名字(r#type这类原始标识符会去掉r#),默认转成 lowerCamelCase,rename_all = "snake_case"时转成 snake_case;只接受这两个值,别的写法编译报「expected "camelCase" or "snake_case"」。_得到空字符串。只允许标识符、_、结构体和元组结构体模式,结构体模式取的是类型名的最后一段。plugin:读编译时的CARGO_PKG_NAME,以tauri-plugin-开头就取剩下的部分,否则None。应用里的命令都是None。name:命令名,只在错误信息里用。message、acl:包装代码从Invoke里解构出来的InvokeMessage和 ACL 检查得到的Option<Vec<ResolvedCommand>>。
要什么值,全靠参数类型上的 CommandArg 实现决定。实现了 Deserialize 的类型走通用实现,把 CommandItem 当作 serde 的 Deserializer,从请求体的 JSON 对象里按 key 取值;其余几种不看请求体,从 message 里拿:
| 参数类型 | 实现 | 拿到什么 |
|---|---|---|
T: Deserialize | command.rs | 请求体 JSON 里 key 对应的值 |
State<'_, T> | state.rs | manage 过的状态,没有就报「state not managed for field ...」 |
AppHandle<R> | app.rs | 发起调用的 webview 所属的应用 |
Window<R> / Webview<R> / WebviewWindow<R> | window/mod.rs、webview/mod.rs、webview_window.rs | 发起调用的窗口 / webview |
ipc::Request<'_> | ipc/mod.rs | 原始请求体和请求头 |
ipc::Channel<T> | channel.rs | 先按 key 反序列化出一个字符串,再解析成通道 id |
CommandScope<T> / GlobalScope<T> | authority.rs | 允许这次调用的能力带来的作用域 |
所以宏不需要认识 State:bump 的两个参数照样各生成一个 key("counter"、"app"),只是这两个实现不读它(State 的错误信息里会引用它)。展开出来的 bump 分支:
"bump" => {
({
move || {
#[allow(unused_imports)]
use ::tauri::ipc::private::*;
#[allow(unused_variables)]
let ::tauri::ipc::Invoke {
message: __tauri_message__,
resolver: __tauri_resolver__,
acl: __tauri_acl__,
} = __tauri_invoke__;
let result = bump(
match ::tauri::ipc::CommandArg::from_command(::tauri::ipc::CommandItem {
plugin: ::core::option::Option::None,
name: "bump",
key: "counter",
message: &__tauri_message__,
acl: &__tauri_acl__,
}) {
Ok(arg) => arg,
Err(err) => {
__tauri_resolver__.invoke_error(err);
return true;
}
},
match ::tauri::ipc::CommandArg::from_command(::tauri::ipc::CommandItem {
plugin: ::core::option::Option::None,
name: "bump",
key: "app",
message: &__tauri_message__,
acl: &__tauri_acl__,
}) {
Ok(arg) => arg,
Err(err) => {
__tauri_resolver__.invoke_error(err);
return true;
}
},
);
let kind = (&result).blocking_kind();
kind.block(result, __tauri_resolver__);
return true;
}
})()
}
参数按声明顺序求值,第一个失败的参数直接 invoke_error 并返回,后面的不再尝试。JSON 怎样变成参数值、错误信息怎么拼,见参数、返回值与 InvokeResolver;&str 能借用请求体里的字符串,是 serde 的借用反序列化,见 serde 卷的生命周期 'de 与借用。
问: bump 一开始写的是 app: tauri::AppHandle,cargo run 能编译,加上 MockRuntime 测试就报「the trait serde_core::de::Deserialize<'_> is not implemented for AppHandle」。AppHandle 明明有自己的 CommandArg 实现,怎么扯到 Deserialize 上了?
答: 不写泛型参数的 AppHandle 是 AppHandle<Wry>:AppHandle<R> 上的 #[default_runtime(crate::Wry, wry)] 在开了 wry feature 时给 R 加上默认值。而包装代码是对 R 泛型的,测试里 R 是 MockRuntime,AppHandle<Wry>: CommandArg<'_, MockRuntime> 没有专门的实现,编译器只剩那个 D: Deserialize 的通用实现可试,于是报它不满足。完整的报错:
error[E0277]: the trait bound `AppHandle: CommandArg<'_, R>` is not satisfied
--> src/main.rs:16:1
|
16 | #[tauri::command]
| ^^^^^^^^^^^^^^^^^ the trait `serde_core::de::Deserialize<'_>` is not implemented for `AppHandle`
...
38 | builder.invoke_handler(tauri::generate_handler![greet, word_count, bump, snake, greet_user, borrow_ok])
| ------------------------------------------------------------------------------- in this macro invocation
|
= help: the following other types implement trait `serde_core::de::Deserialize<'de>`:
...
= note: required for `AppHandle` to implement `CommandArg<'_, R>`
改成 fn bump<R: tauri::Runtime>(.., app: tauri::AppHandle<R>) 就过了。State、Window 这类参数的报错如果提到 Deserialize,先查运行时参数对不对得上。
同步和 async 两种生成
#[command] 看函数有没有 async,或者属性里有没有 async(#[command(async)] 用于同步函数),选 body_blocking 还是 body_async。上面 bump 是同步的:参数用 match 取,取完直接调用函数,kind.block(result, resolver) 当场回应。word_count 是 async 的:
"word_count" => {
({
move || {
#[allow(unused_imports)]
use ::tauri::ipc::private::*;
#[allow(unused_variables)]
let ::tauri::ipc::Invoke {
message: __tauri_message__,
resolver: __tauri_resolver__,
acl: __tauri_acl__,
} = __tauri_invoke__;
__tauri_resolver__
.respond_async_serialized(async move {
let result = word_count(
::tauri::ipc::CommandArg::from_command(::tauri::ipc::CommandItem {
plugin: ::core::option::Option::None,
name: "word_count",
key: "text",
message: &__tauri_message__,
acl: &__tauri_acl__,
})?,
);
let kind = (&result).async_kind();
kind.future(result).await
});
return true;
}
})()
}
区别有三处:参数提取挪进了 async move 块,失败用 ? 返回;word_count(..) 返回的 future 由 kind.future(..) 去 .await;整个块交给 respond_async_serialized,它要求 future 是 Send + 'static,在 async_runtime 上运行。message 被 move 进 async 块,参数从块里的这个局部变量借用,所以 &str 参数在 async 命令里也能工作,前提见下一节。
(&result).blocking_kind() 和 (&result).async_kind() 是自动引用特化:ipc::private 里给不同的类型各实现一个同名方法,方法解析先试接收者 &T 本身、再试 &&T,于是更具体的实现先命中:
| 返回值类型 | 同步命中 | async 命中 | 结果 |
|---|---|---|---|
Result<T, E> | ResultKind(Self = Result) | Err 转成 InvokeError | |
其他 T: IpcResponse | ResponseKind(Self = &T) | 总是成功 | |
Future<Output = Result<T, E>> | ResultFutureKind(Self = F) | await 后同上 | |
其他 Future<Output = T> | FutureKind(Self = &F) | await 后总是成功 |
宏不知道返回值的类型(它只看到语法),所以把选择推给类型检查。#[command(async)] 标在同步函数上时,生成的也是 body_async,只是函数立即返回一个值而不是 future:这时命中的是前两行的 async_kind,tracing 的 span 里 kind 记作 sync_threadpool。
async 命令借用参数
wrapper 里专门有一段检查:函数是 async,并且某个参数的类型是引用,或者是带生命周期参数的路径类型(State<'_, T> 就是),就要求返回 Result:
if let Some(span) = ref_argument_span {
if let syn::ReturnType::Type(_, return_type) = &function.sig.output {
// To check if the return type is `Result` we require it to check a trait that is
// only implemented by `Result`. That way we don't exclude renamed result types
// which we wouldn't otherwise be able to detect purely from the token stream.
// The "error message" displayed to the user is simply the trait name.
let diagnostic = quote!(#[diagnostic::on_unimplemented(message = "async commands that contain references as inputs must return a `Result`")]);
async_command_check = quote_spanned! {return_type.span() =>
#[allow(unreachable_code, clippy::diverging_sub_expression, clippy::used_underscore_binding)]
const _: () = if false {
#diagnostic
trait AsyncCommandMustReturnResult {}
impl<A, B> AsyncCommandMustReturnResult for ::std::result::Result<A, B> {}
let _check: #return_type = unreachable!();
let _: &dyn AsyncCommandMustReturnResult = &_check;
};
};
} else {
return quote_spanned! {
span => compile_error!("async commands that contain references as inputs must return a `Result`");
}.into();
}
}
有返回类型时,检查是一个只在 Result 上实现的 trait:返回类型被别名改了名也认得出来,错误信息由 #[diagnostic::on_unimplemented] 换成人话。没写返回类型时直接 compile_error!,并且整个输出就只有这一句,原函数和两个宏都不生成了。build-cmd-err 的四个 bin 各写一个:
#[tauri::command]
async fn bad(name: &str) -> String {
name.to_string()
}
fn main() {
let _ = tauri::Builder::default().invoke_handler(tauri::generate_handler![bad]);
}
e1(上面这个)的报错:
error[E0277]: async commands that contain references as inputs must return a `Result`
--> src/bin/e1.rs:2:29
|
2 | async fn bad(name: &str) -> String {
| ^^^^^^ the trait `AsyncCommandMustReturnResult` is not implemented for `std::string::String`
|
help: the trait `AsyncCommandMustReturnResult` is implemented for `Result<A, B>`
--> src/bin/e1.rs:2:29
|
2 | async fn bad(name: &str) -> String {
| ^^^^^^
= note: required for the cast from `&std::string::String` to `&dyn AsyncCommandMustReturnResult`
error[E0597]: `__tauri_message__` does not live long enough
--> src/bin/e1.rs:1:1
|
1 | #[tauri::command]
| ^^^^^^^^^^^^^^^^-
| | |
| | `__tauri_message__` dropped here while still borrowed
| borrowed value does not live long enough
| argument requires that `__tauri_message__` is borrowed for `'static`
...
7 | let _ = tauri::Builder::default().invoke_handler(tauri::generate_handler![bad]);
| ----------------------------- in this macro invocation
|
note: requirement that the value outlives `'static` introduced here
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.12.5/src/ipc/command.rs:298:38
|
298 | F: Future<Output = T> + Send + 'static,
| ^^^^^^^
= note: this error originates in the macro `__cmd__bad` which comes from the expansion of the macro `tauri::generate_handler` (in Nightly builds, run with -Z macro-backtrace for more info)
两条错误,第一条是宏的检查,指向返回类型;第二条才是真正的原因,指向 tauri 源码里的一行约束。返回 String 的 async 命令命中的是 FutureKind,它的 FutureTag::future 要求 F: Future<Output = T> + Send + 'static;而 bad(name) 返回的 future 借用了 __tauri_message__(async 块里的局部变量),不是 'static。返回 Result 时命中的 ResultFutureTag::future 只要求 F: Future<Output = Result<T, E>> + Send,没有 'static,借用在 async 块内部闭合,外层 future 照样是 'static。宏的检查只是把第二条错误提前翻译成第一条。
其余三个 bin 的函数签名分别是 async fn bad(name: &str)(没有返回类型)、async fn bad(n: tauri::State<'_, u32>) -> u32、async fn bad(name: std::borrow::Cow<str>) -> String,报错(中间有删节):
===== e2
error: async commands that contain references as inputs must return a `Result`
--> src/bin/e2.rs:2:14
|
2 | async fn bad(name: &str) {
| ^^^^
error: cannot find macro `__tauri_command_name_bad` in this scope
--> src/bin/e2.rs:7:52
...
error: cannot find macro `__cmd__bad` in this scope
--> src/bin/e2.rs:7:77
...
===== e3
error[E0277]: async commands that contain references as inputs must return a `Result`
--> src/bin/e3.rs:2:43
|
2 | async fn bad(n: tauri::State<'_, u32>) -> u32 {
| ^^^ the trait `AsyncCommandMustReturnResult` is not implemented for `u32`
...
error[E0597]: `__tauri_message__` does not live long enough
...
===== e4
error[E0726]: implicit elided lifetime not allowed here
--> src/bin/e4.rs:2:20
|
2 | async fn bad(name: std::borrow::Cow<str>) -> String {
| ^^^^^^^^^^^^^^^^^^^^^ expected lifetime parameter
e2没有返回类型,compile_error!指向参数;因为函数和宏都没生成,generate_handler!那里又多出两条「cannot find macro」。e3说明State<'_, T>也在检查范围内:async 命令要State就得返回Result,和&str一样。build-cmd里的peek返回Result<u32, String>,能编译、能调用。e4想用省略生命周期的Cow<str>绕过检查(语法上没有生命周期参数),rustc 先拦下了:async 函数的参数里不允许隐式省略的生命周期,写成Cow<'_, str>又会被宏的检查认出来。
rename_all 与 rename
snake 用了 rename_all = "snake_case",键是 user_name;greet_user 用了 rename = "greetUser",match 分支变成 "greetUser",键仍按默认规则是 userName。展开里 greet_user 的那一段:
"greetUser" => {
({
move || {
// ...
let result = greet_user(
match ::tauri::ipc::CommandArg::from_command(::tauri::ipc::CommandItem {
plugin: ::core::option::Option::None,
name: "\"greetUser\"",
key: "userName",
message: &__tauri_message__,
acl: &__tauri_acl__,
}) {
name 多了一对引号。parse_arg 里写的是 quote!(stringify!(#r)),而 #r 在解析属性时已经是字符串字面量 "greetUser" 了,stringify! 再把它(连引号)变成字符串;没改名时 #command 是标识符,stringify!(greet) 才得到 "greet"。name 只用于错误信息,match 用的是另一个宏 __tauri_command_name_greet_user!(),所以调用本身不受影响,影响的是错误信息(见本页的动脑筋)。
MockRuntime 上逐个调用的结果(build-cmd 的 cargo test -- --nocapture):
greet {"name":"rie"} => Ok(String("hello, rie"))
word_count {"text":"a b c"} => Ok(Number(3))
bump {} => Ok(Number(1))
snake {"user_name":"s"} => Ok(String("s"))
snake {"userName":"s"} => Err(String("invalid args `user_name` for command `snake`: command snake missing required key user_name"))
greetUser {"userName":"u"} => Ok(String("u"))
greet_user {"userName":"u"} => Err(String("Command greet_user not found"))
borrow_ok {"name":"rie"} => Ok(String("RIE"))
greet {"name":42} => Err(String("invalid args `name` for command `greet`: invalid type: integer `42`, expected a borrowed string"))
peek {} => Ok(Number(1))
改名之后旧名字就调不到了:greet_user 落进 match 的 _ 分支。
{"user_name":"u"} 调用 greetUser(它期望 userName),返回的错误字符串里,命令名写成什么样?generate_handler!
Handler 解析逗号分隔的路径列表,每项可以带属性(比如 #[cfg(..)]),列表前可以有 #![plugin(name)]。对每个路径,把最后一段 greet 换成 __cmd__greet 得到包装宏的路径,换成 __tauri_command_name_greet 得到命令名宏的路径,然后生成:
quote::quote!(move |#invoke| {
let #cmd = #invoke.message.command();
match #cmd {
#(#(#attrs)* #command_name_macros => #wrappers!(#paths, #invoke),)*
_ => {
return false;
},
}
})
命令名宏用在模式位置上,展开成字符串字面量,所以这是一个普通的字符串 match。build-cmd 展开后的骨架:
.invoke_handler(move |__tauri_invoke__| {
let __tauri_cmd__ = __tauri_invoke__.message.command();
match __tauri_cmd__ {
"greet" => {
({
move || {
// ...
}
})()
}
// "word_count"、"bump"、"snake"、"greetUser"、"borrow_ok"、"peek"
_ => {
return false;
}
}
})
闭包返回 bool:true 表示命令被认领了(结果会通过 resolver 回去,哪怕是错误),false 表示没有这个命令,on_message 据此 reject 一句「Command {command} not found」。plugin: 前缀的请求不进应用的这个闭包,交给对应插件注册的处理函数(插件里通常也是一个 generate_handler!),见 on_message 与分发。
每个分支是一个立即调用的闭包 { move || { .. } }(),而不是直接把包装代码写在分支里。这和栈有关:
一个 match 有几十个分支、每个分支都内联了参数的反序列化时,整个闭包的栈帧要容纳所有分支的局部变量。#13217(首次发布于 tauri 2.5.0)把每个分支包进一个立即调用的闭包,各分支的局部变量就落在各自的栈帧里;CHANGELOG 原文是「Fix large number of commands with large structs as parameters causing stack overflow on debug build on Windows」。同一个 PR 还给闭包加了 #[cfg_attr(not(debug_assertions), inline(always))](提交说明里的一条是「Format and inline iife in release build」),release 构建里闭包被强制内联回 match,各分支的局部变量又合进同一个栈帧。#14170(首次发布于 tauri 2.9.0,即 tauri-macros 2.5.0)删掉了这个属性,注释里的「on Windows debug build」也改成了「on Windows」,CHANGELOG:「Fix the stack overflow when having too many commands in a single invoke handler in release build」。
generate_handler! 解析完列表后还会调 filter_unused_commands:只有 build script 写了 OUT_DIR/allowed-commands.json(build > removeUnusedCommands,见 tauri-build)才生效,把不在任何能力里的命令从列表里删掉,并在编译时打印「Removed unused commands from ...」。应用自己的命令只在有应用 ACL 清单时才过滤,否则全保留;插件名取 #![plugin(..)],没有就看 CARGO_PKG_NAME 是否以 tauri-plugin- 开头。被删掉的命令函数仍然编译,只是没有分支调用它;#[command] 在同样条件下给函数和两个宏加了 #[allow(unused)],源码注释说这是「for dead code elimination」。
动脑筋答案
命令名带着引号:
greetUser {"user_name":"u"} => Err(String("invalid args `userName` for command `\"greetUser\"`: command \"greetUser\" missing required key userName"))
Debug 打印把字符串里的引号转义成了 \",JS 侧拿到的错误字符串是 invalid args `userName` for command `"greetUser"`: command "greetUser" missing required key userName。两处命令名都来自 CommandItem::name:外层的「invalid args ... for command ...」是 crate::Error::InvalidArgs(name, arg, e) 拼的,内层的「command ... missing required key ...」是 CommandItem 作为 Deserializer 找不到键时拼的。对照没改名的 snake,同样的错误里是 `snake` 和 command snake,没有引号。原因是上一节的 stringify!(#r) 作用在一个字符串字面量上。这是 tauri-macros 2.7.4 的一个小 bug:只影响错误信息,不影响分发和 ACL。
小结
#[command]原样保留函数,另生成__cmd__<名字>和__tauri_command_name_<名字>两个宏,并按函数可见性use导出,generate_handler!只凭函数路径就能找到它们。- 每个参数生成一次
CommandArg::from_command(CommandItem { plugin, name, key, message, acl });宏不认识State、AppHandle这些类型,取什么由类型上的实现决定。键默认 camelCase,rename_all = "snake_case"可改。 - 同步命令就地取参、调用、
kind.block回应;async 命令把这些放进async move块交给respond_async_serialized。返回值是不是Result、是不是 future,靠自动引用特化在类型检查时选。 - async 命令有引用参数(含
State<'_, T>)就必须返回Result:非Result的 future 走FutureTag,那里要求'static;宏的检查把这条借用错误翻译成「async commands that contain references as inputs must return aResult」。 generate_handler!展开成move |invoke| match invoke.message.command() { .. },每个分支一个立即调用的闭包(防栈溢出),没有匹配返回false;开了removeUnusedCommands时在这里删掉未授权的命令。rename之后错误信息里的命令名多一对引号,这是stringify!用在字符串字面量上的结果。