diff --git a/src/backend_detect.rs b/src/backend_detect.rs index 93186bc..7aa7d91 100644 --- a/src/backend_detect.rs +++ b/src/backend_detect.rs @@ -1,3 +1,45 @@ +//! # Wayland 截屏后端自动检测(`src/backend_detect.rs`) +//! +//! 本文件负责检测当前 Wayland 桌面支持哪种屏幕捕获后端,由 [`detect_backend`] +//! 返回 [`CaptureBackend::WlrScreencopy`](wlroots 合成器:Sway/Hyprland 等, +//! 通过 `zwlr_screencopy_manager_v1` 协议直接交付 dmabuf,性能最好)或 +//! [`CaptureBackend::PortalPipeWire`](XDG Portal + PipeWire:KDE/GNOME 等, +//! 通过 D-Bus 调用 `org.freedesktop.portal.ScreenCast` 接口)。 +//! +//! ## 检测优先级(见 [`detect_backend`]) +//! +//! 1. 用户显式 `--backend portal|screencopy` 命令行参数覆盖; +//! 2. 自动检测:wlr-screencopy 优先(通过 Wayland globals 列表),否则回退到 Portal +//! (通过 D-Bus 查询 ScreenCast 接口的 `version` 属性 >=1 即视为可用)。 +//! +//! ## 为什么用 raw `zbus` 而不是 `ashpd`(**AGENTS.md 强约束**) +//! +//! AGENTS.md 明确禁止在此文件使用 `ashpd` crate,原因是: +//! `ashpd` 内部把 `zbus::Connection` 缓存在一个全局 `OnceLock`。 +//! 如果拥有该 connection 的 Tokio runtime 被 drop(例如本文件 +//! [`check_portal_available`] 自建的临时 runtime 在函数返回时被 drop), +//! 缓存的 connection 会变成"僵尸"——后续 `setup_portal()` 复用时会永远 hang, +//! 因为底层 `tokio::mpsc` 通道对端已死、但缓存仍报告"已初始化"。 +//! +//! 因此本文件用 `zbus::connection::Builder::session()...build().await` 直接构造 +//! 一条全新的、生命周期受当前 runtime 控制的连接,每次检测都重建。 +//! +//! ## Go ↔ Rust 概念对照 +//! +//! - `async fn` + `.await`:Rust async 是**惰性的**(async fn 返回 `impl Future`, +//! 必须被 `.await` 或 `block_on` 才会真正执行),不同于 Go 的 `go f()` 立即并发。 +//! - `tokio::runtime::Runtime::new()` + `rt.block_on(fut)`:从同步代码驱动 async, +//! 类比 Go `runtime.GOMAXPROCS(1)` + `select { case <-done: }`。 +//! - `tokio::time::timeout(d, fut).await` ≈ Go `context.WithTimeout(ctx, d)`, +//! 返回 `Result`,超时返回 `Err(Elapsed)`。 +//! - `Result` + `?` 操作符 ≈ Go `if err != nil { return err }` 的语法糖。 +//! - `Option` ≈ Go `*T`(指针可空),但 Rust 强制 `match`/`if let` 才能解引用。 +//! - `tracing::info!("...{e}")` ≈ Go `log.Printf`,支持 Rust 1.58+ 的内联捕获格式化。 +//! - `match { ... }` ≈ Go `switch`,但 Rust 强制穷尽所有分支(编译期检查)。 +//! - `&mut T`(可变引用)≈ Go `*T`,但 Rust 编译期保证无别名(只有一个 mut 引用)。 +//! - `move || { ... }` 闭包用 `move` 关键字显式捕获变量所有权(按值转移)。 +//! - `'static` 生命周期约束 ≈ Go"对象不能持有栈指针"的隐式约定,但 Rust 编译期检查。 + use std::time::Duration; use anyhow::Result; @@ -26,8 +68,15 @@ pub enum CaptureBackend { /// 用于后端检测期间列举 Wayland 全局对象的最小化分发类型(无需实际处理事件) struct RegistryLs; +// trait 分发:`Dispatch for RegistryLs` 表示 +// "用 RegistryLs 作为状态对象、GlobalListContents 作为上下文数据来处理 WlRegistry 事件"。 +// 类比 Go interface 的隐式满足,但 Rust trait 在编译期静态分发(generic 单态化), +// 即编译器为每个 (State, Event) 组合生成一份专属代码——零运行时开销。 // 为 RegistryLs 实现 Wayland 注册表事件分发(空实现,仅需类型满足 trait 约束) impl Dispatch for RegistryLs { + // `fn event` 是 Dispatch trait 必须实现的方法:每收到一个 Wayland 事件触发一次。 + // 下划线前缀参数(`_state`、`_registry` 等):Rust 编译器允许声明但不使用, + // 类比 Go 中 `_ = ctx` 显式忽略变量;这里我们只关心类型满足 trait、不处理事件。 fn event( _state: &mut Self, _registry: &WlRegistry, @@ -47,6 +96,10 @@ impl Dispatch for RegistryLs { /// Portal 后端检测期间每个 D-Bus 操作的超时时间。 const PORTAL_DBUS_TIMEOUT: Duration = Duration::from_secs(5); +/// 当 Portal 在超时时间内无响应时,记录详细的错误日志(含 systemctl 重启建议)。 +/// +/// 这是一个辅助函数——调用方已经在超时路径上返回了 `false`,本函数仅负责打印提示。 +/// 不返回 `Result`:日志写入失败本身不应该影响后端检测逻辑。 fn log_portal_unresponsive(operation: &str) { tracing::error!( "Portal service did not respond within 5s while {operation}. \ @@ -56,20 +109,54 @@ fn log_portal_unresponsive(operation: &str) { ); } +/// 通过 D-Bus 检测 XDG Portal ScreenCast 接口是否可用。 +/// +/// 检测流程(每一步都有 5 秒超时保护,见 [`PORTAL_DBUS_TIMEOUT`]): +/// 1. 连接到 D-Bus session bus; +/// 2. 构造 `org.freedesktop.portal.Desktop` 的 ScreenCast proxy; +/// 3. 查询 ScreenCast 接口的 `version` 属性(>=1 即视为可用)。 +/// +/// 任何一步超时或失败都返回 `false`——上层 [`detect_backend`] 据此决定回退策略。 +/// +/// # 同步外壳 + 异步内核 +/// +/// `check_portal_available` 本身是同步 `fn`(被同步的 [`detect_backend`] 调用), +/// 但内部通过 `tokio::runtime::Runtime::new()` + `rt.block_on(async { ... })` +/// 桥接到 async `zbus` API。类比 Go:`func check() bool { rt := NewRuntime(); defer rt.Close(); return rt.BlockOn(asyncFn()) }`。 fn check_portal_available() -> bool { + // 创建独立的 Tokio runtime:外层 `detect_backend` 是同步 `fn`,没有 async runtime + // 上下文,需要自建一个来驱动 `.await`。 + // 类比 Go:每次调用 `runtime.GOMAXPROCS(1)` 启动一个临时调度器。 + // **关键**:这个 runtime 在函数结束时 drop——这也是为什么不能用 ashpd + // (ashpd 缓存 connection 到全局,runtime drop 后 connection 变僵尸,见文件头注释)。 let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(e) => { + // `tracing::warn!` 宏:结构化日志,类比 Go `log.Printf`, + // 但支持 Rust 1.58+ 的 `{e}` 内联捕获格式化(变量名直接作占位符)。 tracing::warn!("Failed to create tokio runtime for portal check: {e}"); return false; } }; + // `rt.block_on(future)`:在当前同步线程上驱动 future 到完成。 + // 类比 Go:`select { case <-done: }` 阻塞等待 goroutine 结束。 + // 但 Rust 的 `block_on` 是单线程内 cooperatively 调度 future(除非 runtime 配 multi-thread)。 rt.block_on(async { + // `async { ... }` 块构造一个匿名 Future(类比 Go `func() {}` 闭包)。 + // 注意:async 块是惰性的——只有 `.await` 或 `block_on` 才会真正执行体内代码。 // Set method_timeout on the connection (bounds method replies) and wrap // the build itself in tokio::time::timeout (bounds connection setup). // 同时设置 method_timeout 与外层 tokio::time::timeout 双重保护。 + // `tokio::time::timeout(d, fut)` ≈ Go `context.WithTimeout(ctx, d)`, + // 返回 `Result`——超时返回 `Err(Elapsed)`。 let conn = match tokio::time::timeout(PORTAL_DBUS_TIMEOUT, async { + // `zbus::connection::Builder::session()` 是 Builder 模式: + // 类比 Go `&http.Client{Timeout: ...}` 用链式方法配置参数。 + // `.expect("...")`:失败时 panic(类比 Go `log.Panic`), + // 只用于"不可能失败"的构造——这里 session bus builder 几乎不会失败。 + // `.method_timeout(...)` 设置单个 D-Bus 方法调用的超时上限。 + // `.build().await` 异步构造 Connection(涉及 D-Bus 握手)。 zbus::connection::Builder::session() .expect("D-Bus session bus builder failed") .method_timeout(PORTAL_DBUS_TIMEOUT) @@ -78,17 +165,29 @@ fn check_portal_available() -> bool { }) .await { + // 嵌套 Result 解构:外层 `Result`(来自 timeout), + // 内层 `Result`(来自 build)。 + // `Ok(Ok(c)) => c` 是模式匹配的多层解构(destructuring)—— + // 类比 Go `if err == nil && inner_err == nil { c := value }`。 Ok(Ok(c)) => c, Ok(Err(e)) => { tracing::info!("D-Bus session bus unavailable: {e}"); return false; } Err(_) => { + // `Err(_)` 中的 `_` 是通配符模式:匹配任意值并丢弃。 + // 这里我们关心的是"超时了",不关心 `Elapsed` 的具体值。 log_portal_unresponsive("connecting to D-Bus session bus"); return false; } }; + // `zbus::Proxy`:D-Bus proxy 是远程对象的强类型句柄,封装 destination+path+interface。 + // 类比 Go 中的 `dbus.ObjectProxy`:调用 `proxy.get_property(...)` 时 + // 自动 marshal 成 D-Bus 消息发到目标对象。 + // `Builder::new(&conn).destination(...).and_then(|b| b.path(...))` 链式构造: + // `and_then` 来自 `Result`,把 `Result` 解开再继续链—— + // 类比 Go `if b, err := b.X(); err != nil { return err } else { b.Y() }`。 let inner: zbus::Proxy = match zbus::proxy::Builder::new(&conn) .destination("org.freedesktop.portal.Desktop") .and_then(|b| b.path("/org/freedesktop/portal/desktop")) @@ -111,6 +210,10 @@ fn check_portal_available() -> bool { } }; + // 查询 ScreenCast 接口的 `version` 属性——这是最可能卡住的操作, + // 因为前两步只是本地构造 proxy,而 get_property 需要 Portal 端实际处理请求。 + // `.get_property::("version")`:泛型方法,turbofish `::` 指定返回类型, + // 类比 Go `GetVersion() (uint32, error)`——但 Rust 用泛型 + 编译期单态化。 // The most likely operation to hang — requires actual Portal-side work. // 最可能卡住的操作,需要 Portal 端实际处理。 let version = match tokio::time::timeout( @@ -137,10 +240,26 @@ fn check_portal_available() -> bool { } // 通过 Wayland globals 检测 wlr-screencopy 协议是否可用 +// +// Wayland globals 是合成器在连接建立时广播的"已支持协议"列表—— +// 类比 Go 中的 HTTP OPTIONS:客户端连上服务器后先查询能力,再决定怎么说话。 +// 我们只需检查列表里是否有 `zwlr_screencopy_manager_v1` 这个接口名即可。 fn check_screencopy_available() -> Result { + // `Connection::connect_to_env()?`:从 WAYLAND_DISPLAY 环境变量读取 socket 路径并连接。 + // `?` 操作符:如果 `connect_to_env` 返回 `Err(e)`,立即把 `e` 转换为函数返回类型 + // (`anyhow::Result`),并 return 之。类比 Go `if err != nil { return err }`。 let conn = Connection::connect_to_env()?; + // `registry_queue_init::(&conn)?`:turbofish `::` 指定 + // 用我们刚定义的空 Dispatch 实现来接收 registry 事件。函数内部会 roundtrip + // 一次拿到所有 globals,返回 `(GlobalList, Queue)` 元组。 + // `let (globals, _queue) = ...`:元组解构(tuple destructuring), + // 类比 Go `globals, queue := ...`,但 Rust 用 `_queue` 表示"我接收但不会用到"。 let (globals, _queue) = registry_queue_init::(&conn)?; + // 迭代器链式调用(zero-cost,编译期单态化): + // `.contents()` → `GlobalList`;`.clone_list()` → `Vec`; + // `.iter()` → `Iterator<&Global>`;`.any(|g| ...)` → `bool`(短路求值)。 + // `|g| g.interface == "..."` 是闭包(closure),类比 Go `func(g Global) bool { ... }`。 let has_screencopy = globals .contents() .clone_list() @@ -171,7 +290,14 @@ fn check_screencopy_available() -> Result { pub fn detect_backend(args: &Args) -> Result { // 1. Check explicit override // 步骤 1:检查用户是否通过命令行参数显式指定了后端 + // `if let Some(ref backend) = args.backend`:模式匹配 + `ref` 关键字。 + // `args.backend` 类型是 `Option`,`Some(ref backend)` 表示 + // "如果是 Some,则把内部 String 的**引用**绑定到 backend"(不获取所有权)。 + // 类比 Go `if args.Backend != nil { backend := args.Backend }`。 if let Some(ref backend) = args.backend { + // `backend.as_str()`:把 `&String` 转 `&str`(类比 Go string → []byte view)。 + // `match backend.as_str() { ... }`:Rust 的 match 对 `&str` 强制穷尽所有分支, + // 类比 Go `switch backend { case "portal": ...; default: ... }`,但没有隐式 fallthrough。 return match backend.as_str() { "portal" => { tracing::info!("Backend override: Portal/PipeWire"); @@ -182,7 +308,10 @@ pub fn detect_backend(args: &Args) -> Result { Ok(CaptureBackend::WlrScreencopy) } other => { + // `other` 是匹配模式变量:绑定未被前面 arm 命中的任意值(类比 Go `default`)。 // 未知后端名称,返回错误 + // `anyhow::bail!("...", args)` 是宏(注意 `!`):立即构造 `anyhow::Error` + // 并从当前函数 return `Err`。类比 Go `return fmt.Errorf("...", ...)`。 anyhow::bail!("Unknown backend '{}'. Use 'screencopy' or 'portal'.", other); } }; @@ -193,11 +322,18 @@ pub fn detect_backend(args: &Args) -> Result { tracing::info!("Auto-detecting capture backend..."); // 检测 wlr-screencopy(通过 Wayland globals) + // `check_screencopy_available()?` 末尾的 `?`:把 `Result` 解开—— + // 成功取 bool,失败则立即 return `Err`(错误向上传播)。 let has_screencopy = check_screencopy_available()?; // 检测 Portal(通过 D-Bus) + // `check_portal_available()` 无 `?`:因为它返回的是 `bool` 而不是 `Result`, + // 内部已经把所有错误吞掉并转为 `false`。 let has_portal = check_portal_available(); // 根据检测结果选择后端,screencopy 优先(性能更好、延迟更低) + // `match (has_screencopy, has_portal) { ... }`:元组匹配——同时匹配两个 bool。 + // `(true, _)` 中的 `_` 是通配符:表示"任意值都匹配"。类比 Go `switch { case hasSC: ... }`。 + // Rust 强制穷尽所有 (bool, bool) 组合,编译期检查,不能漏掉一个分支。 match (has_screencopy, has_portal) { (true, _) => { tracing::info!("Detected wlr-screencopy support → using WlrScreencopy backend"); @@ -217,11 +353,18 @@ pub fn detect_backend(args: &Args) -> Result { } } +// `#[cfg(test)]` 属性:条件编译——`cargo build` 时这个 mod 不会被编译进二进制, +// 只有 `cargo test` 时才参与编译。这样发布产物零运行时开销。 +// 类比 Go 中 `_test.go` 后缀的约定:测试代码与生产代码物理分离。 #[cfg(test)] mod tests { + // `use super::*;`:glob 导入(wildcard import),把父模块的所有 pub item 引入当前作用域。 + // 类比 Go 中的 dot-import(`. "pkg"`),但 Rust 限定在 `super::` 即父模块内。 + // 这里用来在测试中直接访问 `detect_backend`、`CaptureBackend` 等。 use super::*; // 测试辅助函数:构造指定后端参数的 Args 实例 + // 注意:辅助函数不需要 `#[test]` 属性——它只是被测试函数调用的普通函数。 fn make_args(backend: Option<&str>) -> Args { Args { output: Some("test.mp4".to_string()), @@ -242,11 +385,17 @@ mod tests { } // 测试:显式指定 portal 后端 + // `#[test]` 属性:标记此函数为测试用例,`cargo test` 自动发现并执行。 + // 测试函数约定:`fn name() {}` 无参数无返回值;panic 即测试失败。 #[test] fn explicit_portal_backend() { let args = make_args(Some("portal")); let result = detect_backend(&args); + // `assert!(cond)` 宏:条件为 false 时 panic,类比 Go `if !cond { t.Fatal() }`。 assert!(result.is_ok()); + // `assert_eq!(a, b)` 宏:断言相等,失败时打印两边内容,类比 Go `if a != b { t.Errorf() }`。 + // `.unwrap()`:解开 Result——成功取内部值,失败 panic。 + // 测试代码中常用 `unwrap()` 简化错误处理;生产代码应避免(用 `?` 替代)。 assert_eq!(result.unwrap(), CaptureBackend::PortalPipeWire); } @@ -265,6 +414,8 @@ mod tests { let args = make_args(Some("magic")); let result = detect_backend(&args); assert!(result.is_err()); + // `.unwrap_err()`:与 `unwrap()` 相反——解开 Err 中的错误值(如果 Ok 则 panic)。 + // `.to_string()`:把 `anyhow::Error` 转为 `String`(用 Display 格式化)。 let err = result.unwrap_err().to_string(); assert!( err.contains("Unknown backend 'magic'"),