From e96af51ee1a9c4716566e76cdeb7b735de12bf7b Mon Sep 17 00:00:00 2001 From: dailz Date: Mon, 22 Jun 2026 17:18:10 +0800 Subject: [PATCH] =?UTF-8?q?docs(cap=5Fportal):=20[1/2]=20=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=B3=A8=E9=87=8A=20XDG=20Portal=20=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E4=B8=8E=20CapPortal=20=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cap_portal.rs | 172 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/src/cap_portal.rs b/src/cap_portal.rs index f35456a..3ab5cbc 100644 --- a/src/cap_portal.rs +++ b/src/cap_portal.rs @@ -1,3 +1,23 @@ +//! XDG Desktop Portal + PipeWire 截屏后端。 +//! +//! 本模块实现 `CaptureBackend::PortalPipeWire` 路径:通过 XDG Portal 的 +//! ScreenCast 接口请求用户授权,拿到 PipeWire 远程 fd 与 node_id 后,在专用 +//! 线程里跑 PipeWire 事件循环接收 DMA-BUF 帧。 +//! +//! 关键设计: +//! - 使用 `ashpd` crate 走 XDG Portal 协议(高层 Rust 绑定,封装 D-Bus 调用)。 +//! - `CapPortal` 在用户 cache 目录(`wl-webrtc/portal-restore-token`)缓存 Portal +//! restore token,下次启动可跳过用户授权对话框(token 有效时)。 +//! - `--no-persist` 标志:跳过 restore token 读写,每次启动都弹授权对话框;测试 +//! fresh authorization 时使用。 +//! - 与 `backend_detect.rs` 的差异:检测阶段刻意用 raw `zbus` 避免 `ashpd` 缓存 +//! `zbus::Connection` 到全局 OnceLock(runtime drop 后变僵尸 connection)。本 +//! 模块只在 Portal 路径使用 `ashpd`,且 Tokio runtime 由 `CapPortal` 自己拥有 +//! (`rt` 字段),生命周期与 `CapPortal` 一致,无跨实例复用问题。 +//! +//! 分阶段超时(git 68a6eec):`Service`(无用户交互,5s)与 `TokenDependent` +//! (可能弹对话框,30s)两类,前者直接失败、后者清 token 后重试一次。 + // cap_portal.rs — 通过 XDG Desktop Portal 的 ScreenCast 接口捕获屏幕帧 // // 整体架构: @@ -158,6 +178,10 @@ impl CapPortal { let (frame_tx, frame_rx) = bounded(1); let (event_tx, event_rx) = bounded(8); + // 创建 eventfd 对(Linux 特有的进程内事件通知机制)。 + // EFD_CLOEXEC: exec() 时自动关闭 fd,避免泄露给子进程。 + // EFD_NONBLOCK: 读取时非阻塞,配合 epoll/poll 使用。 + // unsafe: libc::eventfd 是 C FFI,返回值 < 0 表示 errno 错误。 let efd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) }; if efd < 0 { return Err(anyhow::anyhow!( @@ -165,36 +189,54 @@ impl CapPortal { std::io::Error::last_os_error() )); } + // 复制 fd 得到独立的两端(读端 efd 给 PipeWire 线程,写端 write_fd 留给 Drop)。 + // dup 返回的是新的 fd(最小可用整数),与原 fd 共享同一打开文件描述。 + // unsafe: libc::dup 是 C FFI,< 0 表示失败;失败时必须 close 原来的 efd 防止泄露。 let write_fd = unsafe { libc::dup(efd) }; if write_fd < 0 { let err = std::io::Error::last_os_error(); + // unsafe: 清理已分配但 dup 失败的 efd,避免 fd 泄漏。 unsafe { libc::close(efd) }; return Err(anyhow::anyhow!("dup eventfd failed: {err}")); } + // Arc 跨线程共享的丢弃计数器(Arc 提供线程安全引用计数, + // 类似 Go 的 sync/atomic.Value 但带引用语义)。PipeWire 线程在 channel + // 满导致丢帧时原子递增它,主线程通过 dropped_count() 读取统计。 + // Ordering::Relaxed:仅用于统计,不需要跨线程内存顺序保证。 let pw_dropped = Arc::new(AtomicU64::new(0)); + // PwThreadCtx 聚合所有要 move 进 PipeWire 线程的资源。 + // shutdown_read / pw_fd 用 OwnedFd 包装(Drop 时自动 close), + // 这避免手动管理 fd 生命周期。frame_tx / event_tx 是 crossbeam + // channel 的发送端(多生产者单消费者,Clone + Send)。 let ctx = PwThreadCtx { frame_tx, event_tx, dropped: pw_dropped.clone(), + // unsafe: OwnedFd::from_raw_fd 接管 efd 的所有权(保证 RAII 关闭)。 + // 之前 libc::eventfd 返回的 efd 没有 Owner,必须用 from_raw_fd 包一下。 shutdown_read: unsafe { OwnedFd::from_raw_fd(efd) }, pw_fd, node_id, fps: args.fps, }; + // thread::Builder 模式:name 给线程命名(便于调试/top 显示),spawn 启动。 + // move || 闭包获取 ctx 所有权(不捕获引用),保证线程自带所有数据。 let pw_thread = thread::Builder::new() .name("pipewire-capture".into()) .spawn(move || { pipewire_thread(ctx); }) .map_err(|e| { + // unsafe: spawn 失败时清理 write_fd 防止泄漏。 unsafe { libc::close(write_fd) }; anyhow::anyhow!("thread spawn failed: {e}") })?; Ok(Self { + // unsafe: from_raw_fd 接管 write_fd 的所有权,由 CapPortal::Drop 关闭。 shutdown_fd: unsafe { OwnedFd::from_raw_fd(write_fd) }, frame_rx, event_rx, @@ -239,17 +281,25 @@ impl CapPortal { /// false`, clears the cached restore token and retries once with /// `no_persist = true`. async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> { + // 首次尝试:使用缓存的 restore token(若存在且 no_persist=false)。 + // _setup_portal_inner 内部根据 phase 失败分类返回 PortalPhaseTimeout。 match Self::_setup_portal_inner(no_persist, false).await { Ok(result) => Ok(result), + // 通过 anyhow::Error 的 downcast 机制判断内层错误是否为 PortalPhaseTimeout。 + // anyhow 包装动态类型错误,e.is::() 检查,downcast_ref::() 取引用。 Err(e) if e.is::() => { let inner_err = e.downcast_ref::().unwrap(); match inner_err { + // 仅当 token-dependent phase 超时且原本允许 persist 时才重试。 + // 重试策略:删除缓存的 token,强制 fresh authorization。 PortalPhaseTimeout::TokenDependent if !no_persist => { tracing::warn!( "Portal timed out during token-using phase. \ Clearing cached restore token and retrying with fresh authorization." ); delete_restore_token(); + // is_retry=true 阻止 _setup_portal_inner 再次进入重试分支 + // (最多重试一次,避免无限循环)。 Self::_setup_portal_inner(true, true).await } _ => Err(e), @@ -267,22 +317,31 @@ impl CapPortal { no_persist: bool, is_retry: bool, ) -> Result<(OwnedFd, u32)> { + // 函数内部 use:把 ashpd 子模块导入局部作用域(限制作用域避免污染整个文件)。 + // CursorMode / SourceType / PersistMode 是 ashpd 提供的枚举,对应 Portal 协议字段。 use ashpd::desktop::screencast::{ CursorMode, Screencast, SelectSourcesOptions, SourceType, }; use ashpd::desktop::PersistMode; // Phase 1: Screencast proxy (no user interaction). + // D-Bus 代理对象,对应 XDG Portal ScreenCast 接口。 + // tokio::time::timeout(dur, fut) 包装一个 future,超过 dur 返回 Err(Elapsed)。 + // 返回 Result, Elapsed>,外层是 timeout,内层是 Portal 调用。 + // 三路 match:Ok(Ok) 成功 / Ok(Err) Portal 报错 / Err(_) 超时。 let proxy = match tokio::time::timeout(PORTAL_SERVICE_TIMEOUT, Screencast::new()).await { Ok(Ok(p)) => p, Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create Screencast proxy: {e}")), Err(_) => { log_portal_phase_timeout("creating Screencast proxy", false); + // .into() 把 PortalPhaseTimeout 转换为 anyhow::Error(dyn Error trait object)。 return Err(PortalPhaseTimeout::Service.into()); } }; // Phase 2: create_session (no user interaction). + // 建立 Portal 会话令牌(不是 PipeWire 会话),用于后续 select_sources 引用。 + // Default::default() 揆 SessionOptions 是空 struct(用 trait 接口设置非默认值时显式构造)。 let session = match tokio::time::timeout( PORTAL_SERVICE_TIMEOUT, proxy.create_session(Default::default()), @@ -297,8 +356,13 @@ impl CapPortal { } }; + // Portal 协议版本 ≥4 才支持 persist_mode 与 restore_token。 + // version 由 Screencast proxy 在 D-Bus 属性中暴露。 let version_supported = proxy.version() >= 4; + // 决定 persist_mode 与已缓存的 token: + // - no_persist=true 或版本不支持 → PersistMode::DoNot,不读 token。 + // - 否则 → PersistMode::ExplicitlyRevoked(显式可撤销,配合 token 重用)。 let (persist_mode, saved_token) = if !no_persist && version_supported { let token = load_restore_token(); if token.is_some() { @@ -313,18 +377,26 @@ impl CapPortal { (PersistMode::DoNot, None) }; + // Builder 模式链式调用:每个 set_X 返回新的 SelectSourcesOptions(按值消费 self)。 + // CursorMode::Embedded:光标烧录进帧(不是单独的鼠标位置流)。 + // BitFlags::from(SourceType::Monitor):仅捕获整个显示器(不捕获窗口)。 + // set_multiple(false):单流(不开启多显示器拼接)。 let mut options = SelectSourcesOptions::default() .set_cursor_mode(CursorMode::Embedded) .set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor)) .set_multiple(false) .set_persist_mode(persist_mode); + // 若有缓存的 token,附加到 options 实现免对话框恢复。 + // if let Some(ref token) 模式:ref 关键字避免 move token(仅借用字符串引用)。 if let Some(ref token) = saved_token { options = options.set_restore_token(token.as_str()); } // Phase 3: select_sources — token path is fast (no dialog); fresh // authorization may pop a dialog. + // 双超时策略:token_in_use=true 时无对话框(5s service timeout), + // false 时用户需要点 Allow(30s user-dialog timeout)。 let token_in_use = saved_token.is_some(); let phase3_timeout = if token_in_use { PORTAL_SERVICE_TIMEOUT @@ -336,6 +408,7 @@ impl CapPortal { Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")), Err(_) => { log_portal_phase_timeout("selecting sources", token_in_use); + // 按 token_in_use 分流错误类型,setup_portal 仅对 TokenDependent 重试。 return Err( if token_in_use { PortalPhaseTimeout::TokenDependent @@ -348,11 +421,15 @@ impl CapPortal { } // Phase 4: start + response — same dialog-vs-token reasoning as phase 3. + // start 返回一个 future,response 解析 PortalDbus 返回值。 + // 这里把两个 await 串起来放进 async 块,整体受 phase4_timeout 包裹。 let phase4_timeout = if token_in_use { PORTAL_SERVICE_TIMEOUT } else { PORTAL_USER_DIALOG_TIMEOUT }; + // 内部 async 块:把 start + response 组成单一 future,便于 timeout 包装。 + // ? 在 async 块里传播 ashpd::Error,外层 match 处理。 let start_fut = async { proxy .start(&session, None, Default::default()) @@ -375,12 +452,15 @@ impl CapPortal { } }; + // 持久化新颁发的 restore token(Portal 可能返回与之前不同的 token)。 if !no_persist && version_supported { if let Some(new_token) = response.restore_token() { save_restore_token(new_token); } } + // 假设单流(set_multiple(false)):first().ok_or_else 把 None 转 Error。 + // ok_or_else 闭包延迟构造错误字符串,比 ok_or 节省开销。 let stream = response .streams() .first() @@ -389,6 +469,8 @@ impl CapPortal { let node_id = stream.pipe_wire_node_id(); // Phase 5: open_pipe_wire_remote (no user interaction). + // 请求 PipeWire 服务端 fd。返回的 OwnedFd 是 Portal 通过 D-Bus fd-passing + // 传过来的 PipeWire socket,PipeWire 线程用它连接到 compositor 的 PipeWire 实例。 let fd = match tokio::time::timeout( PORTAL_SERVICE_TIMEOUT, proxy.open_pipe_wire_remote(&session, Default::default()), @@ -409,17 +491,32 @@ impl CapPortal { } } +/// 计算 Portal restore token 的持久化路径(用户 cache 目录下 `wl-webrtc/portal-restore-token`)。 +/// +/// 返回 `Option` 因为某些系统无合法 cache 目录(如 `$XDG_CACHE_HOME` 未设置 +/// 且无 HOME),此时返回 None,调用方应跳过 token 持久化。 +/// +/// 路径布局:`$XDG_CACHE_HOME/wl-webrtc/portal-restore-token` 或 `~/.cache/wl-webrtc/portal-restore-token`。 fn token_path() -> Option { + // dirs::cache_dir() 返回 Option(无 cache 目录时为 None)。 + // .map(|base| base.join("wl-webrtc").join("portal-restore-token")): + // 类似 Go 的 filepath.Join,跨平台路径拼接。 dirs::cache_dir().map(|base| base.join("wl-webrtc").join("portal-restore-token")) } /// Verify that `path` is a directory owned by the current user with no group/other permissions. /// Rejects symlinks at the path itself (but allows the resolved target to be a real dir). fn verify_secure_dir(path: &std::path::Path) -> bool { + // use 内导入 unix-only trait 扩展(Linux 特有的 stat/mode 字段)。 + // 这些 trait 让 std::fs::Metadata 暴露 .uid()/.gid()/.mode() 等 Unix 字段。 use std::os::unix::fs::{MetadataExt, PermissionsExt}; + // symlink_metadata 不跟随符号链接(lstat),暴露链接本身的信息。 + // 这是安全关键:若用 metadata()(跟随 symlink),攻击者可挂个 symlink 到任意目录 + // 让我们以为权限正确(实际指向 /etc 之类)。 match std::fs::symlink_metadata(path) { Ok(meta) => { + // 第一道防线:拒绝任何 symlink,即使权限看起来正确。 if meta.file_type().is_symlink() { tracing::warn!( "Token parent dir is a symlink, rejecting: {}", @@ -433,6 +530,8 @@ fn verify_secure_dir(path: &std::path::Path) -> bool { return false; } // Must be owned by current user + // unsafe: libc::getuid 是 C FFI;它实际是安全操作(无失败模式), + // 标 unsafe 仅因 Rust 未对其建模。返回当前进程的 real UID。 if meta.uid() != unsafe { libc::getuid() } { tracing::warn!( "Token parent dir not owned by current user: {}", @@ -441,6 +540,8 @@ fn verify_secure_dir(path: &std::path::Path) -> bool { return false; } // No group or other permissions (mode must be 0o700 exactly within the 0o777 mask) + // mode & 0o777:剥离文件类型位(st_mode 高位),只保留 rwx 权限位。 + // 要求严格 0o700:owner rwx,group 与 other 全无(防止其他用户读 token)。 let mode = meta.permissions().mode() & 0o777; if mode != 0o700 { tracing::warn!( @@ -462,12 +563,14 @@ fn verify_secure_dir(path: &std::path::Path) -> bool { /// Ensure the parent directory exists with restrictive permissions (0o700). /// Returns false if the directory could not be created or is insecure. fn ensure_secure_parent(parent: &std::path::Path) -> bool { + // DirBuilderExt 扩展 DirBuilder::mode()(Unix-only),OpenOptionsExt 用于后续步骤。 use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; if parent.exists() { // Directory exists — try to tighten permissions, then verify. // set_permissions follows symlinks, which is fine here since // we verify with symlink_metadata in verify_secure_dir. + // 收紧模式:把已存在目录强行改为 0700,然后 verify_secure_dir 校验最终状态。 if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) { tracing::warn!("Failed to set directory permissions: {e}"); return false; @@ -476,6 +579,8 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool { } // Create with restrictive mode — DirBuilderExt::mode bypasses umask. + // 关键:标准 create_dir 受 umask 影响(如 022 → 实际 0755)。 + // DirBuilderExt::mode(0o700) 直接设置 inode mode,绕过 umask,保证 0700。 let mut builder = std::fs::DirBuilder::new(); builder.recursive(true); builder.mode(0o700); @@ -485,18 +590,35 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool { } // Verify after creation (belt-and-suspenders) + // 双保险:再 verify 一次,防止 create 与 set_mode 之间被 TOCTOU 篡改。 verify_secure_dir(parent) } +/// 加载已缓存的 Portal restore token(默认路径)。 +/// +/// 无 token 文件、文件不可读、权限不合规等情况均返回 None(不报错)。 +/// 失败原因由 tracing::warn! 记录,便于排查。 fn load_restore_token() -> Option { + // ? 在 Option 上传播:token_path() 返回 None 时直接 return None。 load_restore_token_from(token_path()?) } +/// 从指定路径加载 token,附带严格的安全校验。 +/// +/// 校验规则(任一不满足返回 None): +/// 1. 必须是 regular file(拒绝 directory / fifo / socket) +/// 2. 不能是 symlink(防 symlink attack) +/// 3. owner 必须是当前用户 +/// 4. group/other 不可读写(mode & 0o077 == 0) +/// +/// 这些校验防止攻击者通过预创建文件或符号链接窃取 token。 fn load_restore_token_from(path: PathBuf) -> Option { use std::os::unix::fs::{MetadataExt, PermissionsExt}; + // symlink_metadata:lstat,不跟随 symlink(防攻击者指向 /etc/shadow 等敏感文件)。 let meta = match std::fs::symlink_metadata(&path) { Ok(m) => m, + // 文件不存在或不可访问:静默 None(首次启动无 token 是正常情况)。 Err(_) => return None, }; @@ -511,10 +633,14 @@ fn load_restore_token_from(path: PathBuf) -> Option { tracing::warn!("Token path is not a regular file: {}", path.display()); return None; } + // unsafe: libc::getuid 标 unsafe 仅因 Rust 未建模;实际无失败模式。 + // 比较 st_uid 与当前 real UID,防止其他用户写入的 token 被误用。 if meta.uid() != unsafe { libc::getuid() } { tracing::warn!("Token file not owned by current user: {}", path.display()); return None; } + // 检查 group/other 任何 r/w/x 位(mode & 0o077 != 0)→ 拒绝。 + // 允许 owner 任意位(0o700 / 0o600 / 0o400 等都 OK)。 let mode = meta.permissions().mode() & 0o777; if mode & 0o077 != 0 { tracing::warn!( @@ -525,6 +651,9 @@ fn load_restore_token_from(path: PathBuf) -> Option { return None; } + // .ok()? :把 std::io::Result 转 Option,Err 变 None。 + // 然后 trim 去掉首尾空白(Portal 返回的 token 可能带换行)。 + // 若 trim 后为空字符串,返回 None(视为无 token)。 let token = std::fs::read_to_string(&path).ok()?; let trimmed = token.trim().to_string(); if trimmed.is_empty() { @@ -534,7 +663,13 @@ fn load_restore_token_from(path: PathBuf) -> Option { } } +/// 保存 Portal 颁发的 restore token 到默认 cache 路径。 +/// +/// 失败(无 cache 目录、目录权限不合规、磁盘满等)不返回错误, +/// 仅 tracing::warn!,下次启动会重新走对话框授权流程。 fn save_restore_token(token: &str) { + // let-else 模式(Rust 1.65+):let Some(x) = ... else { return; }。 + // 无 cache 目录时早退,避免后续无谓 IO。 let Some(path) = token_path() else { tracing::warn!("No secure cache directory available, skipping token save"); return; @@ -542,10 +677,16 @@ fn save_restore_token(token: &str) { save_restore_token_to(token, &path); } +/// 删除已缓存的 restore token(用于 token 失效或用户重新授权)。 +/// +/// 文件不存在视为已删除(幂等),其他错误仅 warn 不传播。 fn delete_restore_token() { + // let-else 早退模式(与 save_restore_token 一致)。 let Some(path) = token_path() else { return; }; + // match std::io::ErrorKind::NotFound 是 Rust 错误分类的常用模式。 + // 幂等:文件已删除也视为成功,不报警告(避免日志噪音)。 match std::fs::remove_file(&path) { Ok(()) => tracing::info!("Deleted stale portal restore token at {}", path.display()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} @@ -553,11 +694,21 @@ fn delete_restore_token() { } } +/// 把 token 原子写入指定路径(temp file + rename 模式)。 +/// +/// 原子性:通过临时文件 + rename(2) 实现,确保读到完整 token 或读到旧 token, +/// 永远不会读到部分写入。这是 Linux/Unix 文件系统 rename 的保证。 +/// +/// 安全性: +/// - 父目录必须 0o700 且 owner = current user(ensure_secure_parent 校验) +/// - temp file 用 create_new + mode 0o600(不覆盖现有文件,不跟随 symlink) +/// - rename 是原子操作,但仅在同 filesystem 下保证 fn save_restore_token_to(token: &str, path: &std::path::Path) { use std::fs::OpenOptions; use std::io::Write; use std::os::unix::fs::OpenOptionsExt; + // path.parent() 返回 Option<&Path>(root 路径无 parent)。 let Some(parent) = path.parent() else { tracing::warn!("Token path has no parent directory"); return; @@ -571,21 +722,31 @@ fn save_restore_token_to(token: &str, path: &std::path::Path) { // Use a unique temp file to prevent symlink attacks. // create_new(true) guarantees exclusive creation — fails if file already exists, // and does NOT follow existing symlinks. + // temp 文件名带 PID 防并发:多个 wl-webrtc 实例同时运行不会互相覆盖 temp。 let tmp_path = path.with_extension(format!("{}.tmp", std::process::id())); + // IIFE (immediately-invoked closure) 把多步 IO 组合成单一 Result。 + // ? 在闭包内传播 std::io::Error,外层统一 match 处理。 let result = (|| -> std::io::Result<()> { + // OpenOptions builder:write + create_new = O_WRONLY | O_CREAT | O_EXCL。 + // mode(0o600):owner rw,group/other 无权限(绕过 umask)。 let mut f = OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&tmp_path)?; f.write_all(token.as_bytes())?; + // sync_all:fsync(2),把数据 flush 到磁盘(防系统崩溃丢数据)。 + // 必须 fsync 之后 rename,否则崩溃后可能 token 文件存在但内容为空。 f.sync_all()?; + // rename(2):原子替换。Linux 同 filesystem 下原子保证。 std::fs::rename(&tmp_path, path)?; Ok(()) })(); match result { Ok(()) => tracing::info!("Saved portal restore token"), Err(e) => { + // 失败时清理 temp(避免遗留垃圾文件)。 + // let _ = 显式忽略 remove_file 的错误(temp 可能已不存在)。 let _ = std::fs::remove_file(&tmp_path); tracing::warn!("Failed to save restore token: {e}"); } @@ -603,7 +764,14 @@ impl Drop for CapPortal { fn drop(&mut self) { // Signal the PipeWire loop to quit via eventfd. // eventfd write is a kernel syscall — thread-safe and lock-free. + // 写入 8 字节(u64)到 eventfd,PipeWire 线程 epoll_wait 立即返回。 + // val=1 是任意非零值(PipeWire 线程只关心"可读"事件,不读具体值)。 let val: u64 = 1u64; + // unsafe: libc::write 是 C FFI。签名:write(fd, buf, count) → ssize_t。 + // - self.shutdown_fd.as_raw_fd():取出 OwnedFd 内部的 raw int fd。 + // - &val as *const u64 as *const _:把 Rust 引用强转成 *const c_void。 + // - std::mem::size_of::():8 字节(eventfd 必须写 8 字节)。 + // 返回值是写入字节数或 -1(错误),用 let _ = 忽略(Drop 不能 panic)。 let _ = unsafe { libc::write( self.shutdown_fd.as_raw_fd(), @@ -614,6 +782,10 @@ impl Drop for CapPortal { // 等待 PipeWire 线程完全退出 // 这确保 PipeWire 资源在线程中被正确清理后,主线程才继续 + // Option::take():把 Option 里的值 move 出来,留下 None。 + // 之后 CapPortal 自身的字段访问(如 Drop 结束)不会重复 join。 + // handle.join():阻塞当前线程直到目标线程退出。返回 Result(线程 panic 时 Err)。 + // let _ = 忽略 panic 错误(Drop 中无法恢复)。 if let Some(handle) = self.pw_thread.take() { let _ = handle.join(); }