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 接口捕获屏幕帧 // cap_portal.rs — 通过 XDG Desktop Portal 的 ScreenCast 接口捕获屏幕帧
// //
// 整体架构: // 整体架构:
@@ -158,6 +178,10 @@ impl CapPortal {
let (frame_tx, frame_rx) = bounded(1); let (frame_tx, frame_rx) = bounded(1);
let (event_tx, event_rx) = bounded(8); 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) }; let efd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) };
if efd < 0 { if efd < 0 {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
@@ -165,36 +189,54 @@ impl CapPortal {
std::io::Error::last_os_error() 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) }; let write_fd = unsafe { libc::dup(efd) };
if write_fd < 0 { if write_fd < 0 {
let err = std::io::Error::last_os_error(); let err = std::io::Error::last_os_error();
// unsafe: 清理已分配但 dup 失败的 efd,避免 fd 泄漏。
unsafe { libc::close(efd) }; unsafe { libc::close(efd) };
return Err(anyhow::anyhow!("dup eventfd failed: {err}")); 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)); 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 { let ctx = PwThreadCtx {
frame_tx, frame_tx,
event_tx, event_tx,
dropped: pw_dropped.clone(), 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) }, shutdown_read: unsafe { OwnedFd::from_raw_fd(efd) },
pw_fd, pw_fd,
node_id, node_id,
fps: args.fps, fps: args.fps,
}; };
// thread::Builder 模式:name 给线程命名(便于调试/top 显示),spawn 启动。
// move || 闭包获取 ctx 所有权(不捕获引用),保证线程自带所有数据。
let pw_thread = thread::Builder::new() let pw_thread = thread::Builder::new()
.name("pipewire-capture".into()) .name("pipewire-capture".into())
.spawn(move || { .spawn(move || {
pipewire_thread(ctx); pipewire_thread(ctx);
}) })
.map_err(|e| { .map_err(|e| {
// unsafe: spawn 失败时清理 write_fd 防止泄漏。
unsafe { libc::close(write_fd) }; unsafe { libc::close(write_fd) };
anyhow::anyhow!("thread spawn failed: {e}") anyhow::anyhow!("thread spawn failed: {e}")
})?; })?;
Ok(Self { Ok(Self {
// unsafe: from_raw_fd 接管 write_fd 的所有权,由 CapPortal::Drop 关闭。
shutdown_fd: unsafe { OwnedFd::from_raw_fd(write_fd) }, shutdown_fd: unsafe { OwnedFd::from_raw_fd(write_fd) },
frame_rx, frame_rx,
event_rx, event_rx,
@@ -239,17 +281,25 @@ impl CapPortal {
/// false`, clears the cached restore token and retries once with /// false`, clears the cached restore token and retries once with
/// `no_persist = true`. /// `no_persist = true`.
async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> { 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 { match Self::_setup_portal_inner(no_persist, false).await {
Ok(result) => Ok(result), Ok(result) => Ok(result),
// 通过 anyhow::Error 的 downcast 机制判断内层错误是否为 PortalPhaseTimeout。
// anyhow 包装动态类型错误,e.is::<T>() 检查,downcast_ref::<T>() 取引用。
Err(e) if e.is::<PortalPhaseTimeout>() => { Err(e) if e.is::<PortalPhaseTimeout>() => {
let inner_err = e.downcast_ref::<PortalPhaseTimeout>().unwrap(); let inner_err = e.downcast_ref::<PortalPhaseTimeout>().unwrap();
match inner_err { match inner_err {
// 仅当 token-dependent phase 超时且原本允许 persist 时才重试。
// 重试策略:删除缓存的 token,强制 fresh authorization。
PortalPhaseTimeout::TokenDependent if !no_persist => { PortalPhaseTimeout::TokenDependent if !no_persist => {
tracing::warn!( tracing::warn!(
"Portal timed out during token-using phase. \ "Portal timed out during token-using phase. \
Clearing cached restore token and retrying with fresh authorization." Clearing cached restore token and retrying with fresh authorization."
); );
delete_restore_token(); delete_restore_token();
// is_retry=true 阻止 _setup_portal_inner 再次进入重试分支
// (最多重试一次,避免无限循环)。
Self::_setup_portal_inner(true, true).await Self::_setup_portal_inner(true, true).await
} }
_ => Err(e), _ => Err(e),
@@ -267,22 +317,31 @@ impl CapPortal {
no_persist: bool, no_persist: bool,
is_retry: bool, is_retry: bool,
) -> Result<(OwnedFd, u32)> { ) -> Result<(OwnedFd, u32)> {
// 函数内部 use:把 ashpd 子模块导入局部作用域(限制作用域避免污染整个文件)。
// CursorMode / SourceType / PersistMode 是 ashpd 提供的枚举,对应 Portal 协议字段。
use ashpd::desktop::screencast::{ use ashpd::desktop::screencast::{
CursorMode, Screencast, SelectSourcesOptions, SourceType, CursorMode, Screencast, SelectSourcesOptions, SourceType,
}; };
use ashpd::desktop::PersistMode; use ashpd::desktop::PersistMode;
// Phase 1: Screencast proxy (no user interaction). // 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 { let proxy = match tokio::time::timeout(PORTAL_SERVICE_TIMEOUT, Screencast::new()).await {
Ok(Ok(p)) => p, Ok(Ok(p)) => p,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create Screencast proxy: {e}")), Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create Screencast proxy: {e}")),
Err(_) => { Err(_) => {
log_portal_phase_timeout("creating Screencast proxy", false); log_portal_phase_timeout("creating Screencast proxy", false);
// .into() 把 PortalPhaseTimeout 转换为 anyhow::Errordyn Error trait object)。
return Err(PortalPhaseTimeout::Service.into()); return Err(PortalPhaseTimeout::Service.into());
} }
}; };
// Phase 2: create_session (no user interaction). // Phase 2: create_session (no user interaction).
// 建立 Portal 会话令牌(不是 PipeWire 会话),用于后续 select_sources 引用。
// Default::default() 揆 SessionOptions 是空 struct(用 trait 接口设置非默认值时显式构造)。
let session = match tokio::time::timeout( let session = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT, PORTAL_SERVICE_TIMEOUT,
proxy.create_session(Default::default()), 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; 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 (persist_mode, saved_token) = if !no_persist && version_supported {
let token = load_restore_token(); let token = load_restore_token();
if token.is_some() { if token.is_some() {
@@ -313,18 +377,26 @@ impl CapPortal {
(PersistMode::DoNot, None) (PersistMode::DoNot, None)
}; };
// Builder 模式链式调用:每个 set_X 返回新的 SelectSourcesOptions(按值消费 self)。
// CursorMode::Embedded:光标烧录进帧(不是单独的鼠标位置流)。
// BitFlags::from(SourceType::Monitor):仅捕获整个显示器(不捕获窗口)。
// set_multiple(false):单流(不开启多显示器拼接)。
let mut options = SelectSourcesOptions::default() let mut options = SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded) .set_cursor_mode(CursorMode::Embedded)
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor)) .set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
.set_multiple(false) .set_multiple(false)
.set_persist_mode(persist_mode); .set_persist_mode(persist_mode);
// 若有缓存的 token,附加到 options 实现免对话框恢复。
// if let Some(ref token) 模式:ref 关键字避免 move token(仅借用字符串引用)。
if let Some(ref token) = saved_token { if let Some(ref token) = saved_token {
options = options.set_restore_token(token.as_str()); options = options.set_restore_token(token.as_str());
} }
// Phase 3: select_sources — token path is fast (no dialog); fresh // Phase 3: select_sources — token path is fast (no dialog); fresh
// authorization may pop a dialog. // 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 token_in_use = saved_token.is_some();
let phase3_timeout = if token_in_use { let phase3_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT PORTAL_SERVICE_TIMEOUT
@@ -336,6 +408,7 @@ impl CapPortal {
Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")), Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")),
Err(_) => { Err(_) => {
log_portal_phase_timeout("selecting sources", token_in_use); log_portal_phase_timeout("selecting sources", token_in_use);
// 按 token_in_use 分流错误类型,setup_portal 仅对 TokenDependent 重试。
return Err( return Err(
if token_in_use { if token_in_use {
PortalPhaseTimeout::TokenDependent PortalPhaseTimeout::TokenDependent
@@ -348,11 +421,15 @@ impl CapPortal {
} }
// Phase 4: start + response — same dialog-vs-token reasoning as phase 3. // 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 { let phase4_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT PORTAL_SERVICE_TIMEOUT
} else { } else {
PORTAL_USER_DIALOG_TIMEOUT PORTAL_USER_DIALOG_TIMEOUT
}; };
// 内部 async 块:把 start + response 组成单一 future,便于 timeout 包装。
// ? 在 async 块里传播 ashpd::Error,外层 match 处理。
let start_fut = async { let start_fut = async {
proxy proxy
.start(&session, None, Default::default()) .start(&session, None, Default::default())
@@ -375,12 +452,15 @@ impl CapPortal {
} }
}; };
// 持久化新颁发的 restore tokenPortal 可能返回与之前不同的 token)。
if !no_persist && version_supported { if !no_persist && version_supported {
if let Some(new_token) = response.restore_token() { if let Some(new_token) = response.restore_token() {
save_restore_token(new_token); save_restore_token(new_token);
} }
} }
// 假设单流(set_multiple(false)):first().ok_or_else 把 None 转 Error。
// ok_or_else 闭包延迟构造错误字符串,比 ok_or 节省开销。
let stream = response let stream = response
.streams() .streams()
.first() .first()
@@ -389,6 +469,8 @@ impl CapPortal {
let node_id = stream.pipe_wire_node_id(); let node_id = stream.pipe_wire_node_id();
// Phase 5: open_pipe_wire_remote (no user interaction). // 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( let fd = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT, PORTAL_SERVICE_TIMEOUT,
proxy.open_pipe_wire_remote(&session, Default::default()), 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> { 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")) 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. /// 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). /// 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 { 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}; use std::os::unix::fs::{MetadataExt, PermissionsExt};
// symlink_metadata 不跟随符号链接(lstat),暴露链接本身的信息。
// 这是安全关键:若用 metadata()(跟随 symlink),攻击者可挂个 symlink 到任意目录
// 让我们以为权限正确(实际指向 /etc 之类)。
match std::fs::symlink_metadata(path) { match std::fs::symlink_metadata(path) {
Ok(meta) => { Ok(meta) => {
// 第一道防线:拒绝任何 symlink,即使权限看起来正确。
if meta.file_type().is_symlink() { if meta.file_type().is_symlink() {
tracing::warn!( tracing::warn!(
"Token parent dir is a symlink, rejecting: {}", "Token parent dir is a symlink, rejecting: {}",
@@ -433,6 +530,8 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
return false; return false;
} }
// Must be owned by current user // Must be owned by current user
// unsafe: libc::getuid 是 C FFI;它实际是安全操作(无失败模式),
// 标 unsafe 仅因 Rust 未对其建模。返回当前进程的 real UID。
if meta.uid() != unsafe { libc::getuid() } { if meta.uid() != unsafe { libc::getuid() } {
tracing::warn!( tracing::warn!(
"Token parent dir not owned by current user: {}", "Token parent dir not owned by current user: {}",
@@ -441,6 +540,8 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
return false; return false;
} }
// No group or other permissions (mode must be 0o700 exactly within the 0o777 mask) // 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; let mode = meta.permissions().mode() & 0o777;
if mode != 0o700 { if mode != 0o700 {
tracing::warn!( tracing::warn!(
@@ -462,12 +563,14 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
/// Ensure the parent directory exists with restrictive permissions (0o700). /// Ensure the parent directory exists with restrictive permissions (0o700).
/// Returns false if the directory could not be created or is insecure. /// Returns false if the directory could not be created or is insecure.
fn ensure_secure_parent(parent: &std::path::Path) -> bool { fn ensure_secure_parent(parent: &std::path::Path) -> bool {
// DirBuilderExt 扩展 DirBuilder::mode()Unix-only),OpenOptionsExt 用于后续步骤。
use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
if parent.exists() { if parent.exists() {
// Directory exists — try to tighten permissions, then verify. // Directory exists — try to tighten permissions, then verify.
// set_permissions follows symlinks, which is fine here since // set_permissions follows symlinks, which is fine here since
// we verify with symlink_metadata in verify_secure_dir. // 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)) { if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
tracing::warn!("Failed to set directory permissions: {e}"); tracing::warn!("Failed to set directory permissions: {e}");
return false; return false;
@@ -476,6 +579,8 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
} }
// Create with restrictive mode — DirBuilderExt::mode bypasses umask. // 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(); let mut builder = std::fs::DirBuilder::new();
builder.recursive(true); builder.recursive(true);
builder.mode(0o700); builder.mode(0o700);
@@ -485,18 +590,35 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
} }
// Verify after creation (belt-and-suspenders) // Verify after creation (belt-and-suspenders)
// 双保险:再 verify 一次,防止 create 与 set_mode 之间被 TOCTOU 篡改。
verify_secure_dir(parent) verify_secure_dir(parent)
} }
/// 加载已缓存的 Portal restore token(默认路径)。
///
/// 无 token 文件、文件不可读、权限不合规等情况均返回 None(不报错)。
/// 失败原因由 tracing::warn! 记录,便于排查。
fn load_restore_token() -> Option<String> { fn load_restore_token() -> Option<String> {
// ? 在 Option 上传播:token_path() 返回 None 时直接 return None。
load_restore_token_from(token_path()?) 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> { fn load_restore_token_from(path: PathBuf) -> Option<String> {
use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::os::unix::fs::{MetadataExt, PermissionsExt};
// symlink_metadatalstat,不跟随 symlink(防攻击者指向 /etc/shadow 等敏感文件)。
let meta = match std::fs::symlink_metadata(&path) { let meta = match std::fs::symlink_metadata(&path) {
Ok(m) => m, Ok(m) => m,
// 文件不存在或不可访问:静默 None(首次启动无 token 是正常情况)。
Err(_) => return None, 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()); tracing::warn!("Token path is not a regular file: {}", path.display());
return None; return None;
} }
// unsafe: libc::getuid 标 unsafe 仅因 Rust 未建模;实际无失败模式。
// 比较 st_uid 与当前 real UID,防止其他用户写入的 token 被误用。
if meta.uid() != unsafe { libc::getuid() } { if meta.uid() != unsafe { libc::getuid() } {
tracing::warn!("Token file not owned by current user: {}", path.display()); tracing::warn!("Token file not owned by current user: {}", path.display());
return None; return None;
} }
// 检查 group/other 任何 r/w/x 位(mode & 0o077 != 0)→ 拒绝。
// 允许 owner 任意位(0o700 / 0o600 / 0o400 等都 OK)。
let mode = meta.permissions().mode() & 0o777; let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 { if mode & 0o077 != 0 {
tracing::warn!( tracing::warn!(
@@ -525,6 +651,9 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
return None; 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 token = std::fs::read_to_string(&path).ok()?;
let trimmed = token.trim().to_string(); let trimmed = token.trim().to_string();
if trimmed.is_empty() { 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) { fn save_restore_token(token: &str) {
// let-else 模式(Rust 1.65+):let Some(x) = ... else { return; }。
// 无 cache 目录时早退,避免后续无谓 IO。
let Some(path) = token_path() else { let Some(path) = token_path() else {
tracing::warn!("No secure cache directory available, skipping token save"); tracing::warn!("No secure cache directory available, skipping token save");
return; return;
@@ -542,10 +677,16 @@ fn save_restore_token(token: &str) {
save_restore_token_to(token, &path); save_restore_token_to(token, &path);
} }
/// 删除已缓存的 restore token(用于 token 失效或用户重新授权)。
///
/// 文件不存在视为已删除(幂等),其他错误仅 warn 不传播。
fn delete_restore_token() { fn delete_restore_token() {
// let-else 早退模式(与 save_restore_token 一致)。
let Some(path) = token_path() else { let Some(path) = token_path() else {
return; return;
}; };
// match std::io::ErrorKind::NotFound 是 Rust 错误分类的常用模式。
// 幂等:文件已删除也视为成功,不报警告(避免日志噪音)。
match std::fs::remove_file(&path) { match std::fs::remove_file(&path) {
Ok(()) => tracing::info!("Deleted stale portal restore token at {}", path.display()), Ok(()) => tracing::info!("Deleted stale portal restore token at {}", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} 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) { fn save_restore_token_to(token: &str, path: &std::path::Path) {
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::io::Write; use std::io::Write;
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
// path.parent() 返回 Option<&Path>root 路径无 parent)。
let Some(parent) = path.parent() else { let Some(parent) = path.parent() else {
tracing::warn!("Token path has no parent directory"); tracing::warn!("Token path has no parent directory");
return; 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. // Use a unique temp file to prevent symlink attacks.
// create_new(true) guarantees exclusive creation — fails if file already exists, // create_new(true) guarantees exclusive creation — fails if file already exists,
// and does NOT follow existing symlinks. // and does NOT follow existing symlinks.
// temp 文件名带 PID 防并发:多个 wl-webrtc 实例同时运行不会互相覆盖 temp。
let tmp_path = path.with_extension(format!("{}.tmp", std::process::id())); 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<()> { 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() let mut f = OpenOptions::new()
.write(true) .write(true)
.create_new(true) .create_new(true)
.mode(0o600) .mode(0o600)
.open(&tmp_path)?; .open(&tmp_path)?;
f.write_all(token.as_bytes())?; f.write_all(token.as_bytes())?;
// sync_allfsync(2),把数据 flush 到磁盘(防系统崩溃丢数据)。
// 必须 fsync 之后 rename,否则崩溃后可能 token 文件存在但内容为空。
f.sync_all()?; f.sync_all()?;
// rename(2):原子替换。Linux 同 filesystem 下原子保证。
std::fs::rename(&tmp_path, path)?; std::fs::rename(&tmp_path, path)?;
Ok(()) Ok(())
})(); })();
match result { match result {
Ok(()) => tracing::info!("Saved portal restore token"), Ok(()) => tracing::info!("Saved portal restore token"),
Err(e) => { Err(e) => {
// 失败时清理 temp(避免遗留垃圾文件)。
// let _ = 显式忽略 remove_file 的错误(temp 可能已不存在)。
let _ = std::fs::remove_file(&tmp_path); let _ = std::fs::remove_file(&tmp_path);
tracing::warn!("Failed to save restore token: {e}"); tracing::warn!("Failed to save restore token: {e}");
} }
@@ -603,7 +764,14 @@ impl Drop for CapPortal {
fn drop(&mut self) { fn drop(&mut self) {
// Signal the PipeWire loop to quit via eventfd. // Signal the PipeWire loop to quit via eventfd.
// eventfd write is a kernel syscall — thread-safe and lock-free. // eventfd write is a kernel syscall — thread-safe and lock-free.
// 写入 8 字节(u64)到 eventfdPipeWire 线程 epoll_wait 立即返回。
// val=1 是任意非零值(PipeWire 线程只关心"可读"事件,不读具体值)。
let val: u64 = 1u64; 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 { let _ = unsafe {
libc::write( libc::write(
self.shutdown_fd.as_raw_fd(), self.shutdown_fd.as_raw_fd(),
@@ -614,6 +782,10 @@ impl Drop for CapPortal {
// 等待 PipeWire 线程完全退出 // 等待 PipeWire 线程完全退出
// 这确保 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() { if let Some(handle) = self.pw_thread.take() {
let _ = handle.join(); let _ = handle.join();
} }