docs(cap_portal): [1/2] 中文注释 XDG Portal 授权与 CapPortal 初始化

This commit is contained in:
dailz
2026-06-22 17:18:10 +08:00
parent fffa440e68
commit e96af51ee1
+172
View File
@@ -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` 到全局 OnceLockruntime 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<AtomicU64> 跨线程共享的丢弃计数器(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::<T>() 检查,downcast_ref::<T>() 取引用。
Err(e) if e.is::<PortalPhaseTimeout>() => {
let inner_err = e.downcast_ref::<PortalPhaseTimeout>().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<Result<T, ashpd::Error>, Elapsed>,外层是 timeout,内层是 Portal 调用。
// 三路 matchOk(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::Errordyn 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 时用户需要点 Allow30s 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 返回一个 futureresponse 解析 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 tokenPortal 可能返回与之前不同的 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 socketPipeWire 线程用它连接到 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<PathBuf>` 因为某些系统无合法 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<PathBuf> {
// dirs::cache_dir() 返回 Option<PathBuf>(无 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 权限位。
// 要求严格 0o700owner rwxgroup 与 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<String> {
// ? 在 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<String> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
// symlink_metadatalstat,不跟随 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<String> {
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<String> {
return None;
}
// .ok()? :把 std::io::Result<String> 转 Option<String>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<String> {
}
}
/// 保存 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 userensure_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 builderwrite + create_new = O_WRONLY | O_CREAT | O_EXCL。
// mode(0o600)owner rwgroup/other 无权限(绕过 umask)。
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)?;
f.write_all(token.as_bytes())?;
// sync_allfsync(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)到 eventfdPipeWire 线程 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::<u64>()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<JoinHandle> 里的值 move 出来,留下 None。
// 之后 CapPortal 自身的字段访问(如 Drop 结束)不会重复 join。
// handle.join():阻塞当前线程直到目标线程退出。返回 Result(线程 panic 时 Err)。
// let _ = 忽略 panic 错误(Drop 中无法恢复)。
if let Some(handle) = self.pw_thread.take() {
let _ = handle.join();
}