docs(cap_portal): [1/2] 中文注释 XDG Portal 授权与 CapPortal 初始化
This commit is contained in:
@@ -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<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 调用。
|
||||
// 三路 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<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 权限位。
|
||||
// 要求严格 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<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_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<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 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::<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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user