// cap_portal.rs — 通过 XDG Desktop Portal 的 ScreenCast 接口捕获屏幕帧 // // 整体架构: // 1. CapPortal::new() 在主线程创建,内部启动一个专用的 PipeWire 捕获线程 // 2. PipeWire 线程通过 Portal 获取的 fd 和 node_id 连接到 PipeWire,接收 DMA-BUF 帧 // 3. 帧数据通过 crossbeam channel 从 PipeWire 线程传递给消费者 // 4. 关闭时通过 eventfd 通知 PipeWire 线程退出,避免 UAF (Use-After-Free) // // 关键依赖: // - ashpd: XDG Desktop Portal 的 Rust 绑定,用于请求屏幕录制权限 // - pipewire / libspa: PipeWire 的 Rust 绑定,用于接收视频流 // - crossbeam-channel: 高性能有界通道,用于线程间帧传递 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; use anyhow::Result; use crossbeam_channel::{bounded, Receiver, Sender}; use tokio::runtime::Runtime; use crate::args::Args; /// Portal phase timeout when no user interaction is expected (proxy/session /// creation, token-path select/start, PipeWire fd). 5s is generous for /// healthy xdg-desktop-portal (<500ms typical) but bounded for fast failure. const PORTAL_SERVICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); /// Portal phase timeout when user must click "Allow" in desktop dialog /// (select/start without restore token). 30s gives time to find the dialog. const PORTAL_USER_DIALOG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); /// Classification of Portal phase timeouts to drive retry behavior. #[derive(Debug)] enum PortalPhaseTimeout { /// Portal service unresponsive; not retried (user should restart service). Service, /// Timed out in token-dependent phase; retried once after clearing token. TokenDependent, } impl std::fmt::Display for PortalPhaseTimeout { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Service => write!(f, "Portal phase timed out (service)"), Self::TokenDependent => write!(f, "Portal phase timed out (token-dependent)"), } } } impl std::error::Error for PortalPhaseTimeout {} /// Log an actionable diagnostic when a Portal phase times out. /// /// Mirrors the message format from `backend_detect.rs::log_portal_unresponsive` /// but additionally suggests `--no-persist` when the timeout occurred in a /// phase that was using a restore token. fn log_portal_phase_timeout(phase: &str, used_restore_token: bool) { let persist_hint = if used_restore_token { " If this recurs, try: wl-webrtc --no-persist" } else { "" }; tracing::error!( "Portal service did not respond within timeout while {phase}. \ This usually means xdg-desktop-portal or xdg-desktop-portal-kde is stuck. \ Try: systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde, \ then re-run wl-webrtc.{persist_hint}" ); } /// PipeWire DMA-BUF 帧数据 /// /// 表示从 PipeWire 流中接收到的一帧视频数据。 /// 帧的像素数据存储在 DMA-BUF(Linux 的零拷贝 buffer 共享机制)中, /// 通过文件描述符 (fd) 引用,消费者通过 mmap 或 DRM 导入来访问像素数据。 pub struct PwDmaBufFrame { /// DMA-BUF 文件描述符,指向 GPU 显存中的帧缓冲区 pub fd: OwnedFd, /// 帧数据在 DMA-BUF 中的字节偏移量 pub offset: u64, /// 每行像素的字节跨度(可能大于 width * bpp,因为可能有对齐填充) pub stride: u32, /// DRM 格式修饰符,描述 buffer 的内存布局(如线性布局、tiling 等) pub modifier: u64, /// 帧宽度(像素) pub width: u32, /// 帧高度(像素) pub height: u32, /// DRM FourCC 格式标识符(如 BGRA、RGBA 等) pub format: u32, /// 显示时间戳 (PTS, Presentation Time Stamp),单位为纳秒 pub pts: i64, } /// PipeWire 控制事件枚举 /// /// 从 PipeWire 捕获线程发送给消费者的控制事件。 /// 与帧数据分离,通过独立的 channel 传输,确保控制事件不被帧数据淹没。 pub enum PwCtrlEvent { /// 流已结束(PipeWire 流断开连接或进入错误状态) StreamEnded, /// Format/dimensions changed mid-stream FormatChanged { width: u32, height: u32 }, /// 发生错误,包含错误描述信息 Error(String), } /// 屏幕捕获门户(Portal)封装 /// /// 通过 XDG Desktop Portal 的 ScreenCast 接口实现屏幕捕获。 /// 内部管理一个 PipeWire 捕获线程,通过 channel 异步提供帧数据。 /// /// 生命周期: /// 1. new() — 建立 Portal 会话,启动 PipeWire 线程 /// 2. frame_receiver() — 获取帧接收端,供消费者轮询 /// 3. Drop — 通过 eventfd 通知 PipeWire 线程安全退出 pub struct CapPortal { shutdown_fd: OwnedFd, frame_rx: Receiver, event_rx: Receiver, pw_thread: Option>, rt: Runtime, pw_dropped: Arc, } /// PipeWire 捕获线程的上下文数据 /// /// 从主线程传递给 PipeWire 捕获线程的所有必要资源。 /// 该结构体在线程创建时一次性 move 到线程中使用。 struct PwThreadCtx { frame_tx: Sender, event_tx: Sender, dropped: Arc, shutdown_read: OwnedFd, pw_fd: OwnedFd, node_id: u32, fps: u32, } impl CapPortal { /// 创建屏幕捕获实例 /// /// 执行流程: /// 1. 创建 Tokio 运行时(用于异步 Portal 调用) /// 2. 通过 XDG Desktop Portal 请求屏幕录制权限,获取 PipeWire fd 和 node_id /// 3. 创建有界通道(容量 1)用于帧传递(最新帧优先,避免队列积压延迟) /// 4. 创建 eventfd 对,用于线程安全的关闭信号传递 /// 5. 启动 PipeWire 捕获线程 pub fn new(args: &Args) -> Result { let rt = Runtime::new()?; let no_persist = args.no_persist; let (pw_fd, node_id) = rt.block_on(async { Self::setup_portal(no_persist).await })?; let (frame_tx, frame_rx) = bounded(1); let (event_tx, event_rx) = bounded(8); // SAFETY: eventfd(2) is a POSIX syscall with no preconditions; the init value // and flags (CLOEXEC + NONBLOCK) are valid. Returns either a fresh fd (>= 0) // or -1 on error, which we check immediately below. let efd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) }; if efd < 0 { return Err(anyhow::anyhow!( "eventfd failed: {}", std::io::Error::last_os_error() )); } // SAFETY: `efd` is the open eventfd we just created (>= 0 checked above) and // own. dup(2) returns either a fresh fd or -1. let write_fd = unsafe { libc::dup(efd) }; if write_fd < 0 { let err = std::io::Error::last_os_error(); // SAFETY: `efd` is still the open eventfd we own; closing on the error // path before returning to avoid fd leak. unsafe { libc::close(efd) }; return Err(anyhow::anyhow!("dup eventfd failed: {err}")); } let pw_dropped = Arc::new(AtomicU64::new(0)); let ctx = PwThreadCtx { frame_tx, event_tx, dropped: pw_dropped.clone(), // SAFETY: `efd` is the freshly-created eventfd (>= 0 checked above) and we // are its sole owner. OwnedFd::from_raw_fd takes ownership and will close() // it on Drop. Ownership transfers into PwThreadCtx and then into the // PipeWire thread via pipewire_thread. shutdown_read: unsafe { OwnedFd::from_raw_fd(efd) }, pw_fd, node_id, fps: args.fps, }; let pw_thread = thread::Builder::new() .name("pipewire-capture".into()) .spawn(move || { pipewire_thread(ctx); }) .map_err(|e| { // SAFETY: `write_fd` is the open dup'd eventfd we own (>= 0 checked // above); closing on thread-spawn failure to avoid fd leak. unsafe { libc::close(write_fd) }; anyhow::anyhow!("thread spawn failed: {e}") })?; Ok(Self { // SAFETY: `write_fd` is the freshly-dup'd eventfd (>= 0 checked above) and // we are its sole owner. OwnedFd::from_raw_fd takes ownership and will // close() it on Drop (which fires when CapPortal is dropped). shutdown_fd: unsafe { OwnedFd::from_raw_fd(write_fd) }, frame_rx, event_rx, pw_thread: Some(pw_thread), rt, pw_dropped, }) } pub fn frame_receiver(&self) -> &Receiver { &self.frame_rx } pub fn event_receiver(&self) -> &Receiver { &self.event_rx } /// Returns the total number of PipeWire frames dropped due to channel backlog. pub fn dropped_count(&self) -> u64 { self.pw_dropped.load(Ordering::Relaxed) } /// Returns the number of frames currently waiting in the capture channel. pub fn capture_queue_depth(&self) -> usize { self.frame_rx.len() } /// 通过 XDG Desktop Portal 建立屏幕录制会话 /// /// 与桌面环境的 D-Bus 服务交互,请求用户授权屏幕录制。 /// 流程: /// 1. 创建 Screencast 代理(D-Bus 代理) /// 2. 创建 ScreenCast 会话 /// 3. 配置源选择参数(光标模式、显示器源、不持久化会话) /// 4. 启动录制,获取流信息(包含 PipeWire node_id) /// 5. 打开 PipeWire 远程连接,获取文件描述符 /// /// 返回 (PipeWire fd, node_id),供 PipeWire 线程连接使用 /// /// Wraps `_setup_portal_inner` with token-aware retry: on a `TokenDependent` /// timeout (phases 3 or 4 with a restore token in use) AND `no_persist == /// false`, clears the cached restore token and retries once with /// `no_persist = true`. async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> { match Self::_setup_portal_inner(no_persist, false).await { Ok(result) => Ok(result), Err(e) if e.is::() => { let inner_err = e.downcast_ref::().unwrap(); match inner_err { 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(); Self::_setup_portal_inner(true, true).await } _ => Err(e), } } Err(e) => Err(e), } } /// Inner Portal setup with phased timeouts. See `setup_portal` for the /// retry wrapper. /// /// `is_retry == true` disables further retry attempts (max 1 retry). async fn _setup_portal_inner( no_persist: bool, is_retry: bool, ) -> Result<(OwnedFd, u32)> { use ashpd::desktop::screencast::{ CursorMode, Screencast, SelectSourcesOptions, SourceType, }; use ashpd::desktop::PersistMode; // Phase 1: Screencast proxy (no user interaction). 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); return Err(PortalPhaseTimeout::Service.into()); } }; // Phase 2: create_session (no user interaction). let session = match tokio::time::timeout( PORTAL_SERVICE_TIMEOUT, proxy.create_session(Default::default()), ) .await { Ok(Ok(s)) => s, Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create ScreenCast session: {e}")), Err(_) => { log_portal_phase_timeout("creating session", false); return Err(PortalPhaseTimeout::Service.into()); } }; let version_supported = proxy.version() >= 4; let (persist_mode, saved_token) = if !no_persist && version_supported { let token = load_restore_token(); if token.is_some() { if is_retry { tracing::info!("Re-attempting portal session after token clear"); } else { tracing::info!("Attempting to restore portal session with saved token"); } } (PersistMode::ExplicitlyRevoked, token) } else { (PersistMode::DoNot, None) }; 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); 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. let token_in_use = saved_token.is_some(); let phase3_timeout = if token_in_use { PORTAL_SERVICE_TIMEOUT } else { PORTAL_USER_DIALOG_TIMEOUT }; match tokio::time::timeout(phase3_timeout, proxy.select_sources(&session, options)).await { Ok(Ok(_)) => {} Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")), Err(_) => { log_portal_phase_timeout("selecting sources", token_in_use); return Err( if token_in_use { PortalPhaseTimeout::TokenDependent } else { PortalPhaseTimeout::Service } .into(), ); } } // Phase 4: start + response — same dialog-vs-token reasoning as phase 3. let phase4_timeout = if token_in_use { PORTAL_SERVICE_TIMEOUT } else { PORTAL_USER_DIALOG_TIMEOUT }; let start_fut = async { proxy .start(&session, None, Default::default()) .await? .response() }; let response = match tokio::time::timeout(phase4_timeout, start_fut).await { Ok(Ok(r)) => r, Ok(Err(e)) => return Err(anyhow::anyhow!("ScreenCast start/response error: {e}")), Err(_) => { log_portal_phase_timeout("starting session", token_in_use); return Err( if token_in_use { PortalPhaseTimeout::TokenDependent } else { PortalPhaseTimeout::Service } .into(), ); } }; if !no_persist && version_supported { if let Some(new_token) = response.restore_token() { save_restore_token(new_token); } } let stream = response .streams() .first() .ok_or_else(|| anyhow::anyhow!("No streams returned from ScreenCast"))?; let node_id = stream.pipe_wire_node_id(); // Phase 5: open_pipe_wire_remote (no user interaction). let fd = match tokio::time::timeout( PORTAL_SERVICE_TIMEOUT, proxy.open_pipe_wire_remote(&session, Default::default()), ) .await { Ok(Ok(f)) => f, Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to open PipeWire remote: {e}")), Err(_) => { log_portal_phase_timeout("opening PipeWire remote", false); return Err(PortalPhaseTimeout::Service.into()); } }; tracing::info!("Portal session established: node_id={node_id}"); Ok((fd, node_id)) } } fn token_path() -> Option { 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 std::os::unix::fs::{MetadataExt, PermissionsExt}; match std::fs::symlink_metadata(path) { Ok(meta) => { if meta.file_type().is_symlink() { tracing::warn!( "Token parent dir is a symlink, rejecting: {}", path.display() ); return false; } // Must be a directory if !meta.is_dir() { tracing::warn!("Token parent path is not a directory: {}", path.display()); return false; } // Must be owned by current user // SAFETY: libc::getuid has no preconditions and cannot fail; it simply // returns the calling process's real user ID. // SAFETY: libc::getuid has no preconditions and cannot fail. if meta.uid() != unsafe { libc::getuid() } { tracing::warn!( "Token parent dir not owned by current user: {}", path.display() ); return false; } // No group or other permissions (mode must be 0o700 exactly within the 0o777 mask) let mode = meta.permissions().mode() & 0o777; if mode != 0o700 { tracing::warn!( "Token parent dir has insecure permissions {:o}, expected 0700: {}", mode, path.display() ); return false; } true } Err(e) => { tracing::warn!("Failed to stat token parent dir: {e}"); false } } } /// 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 { 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. 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; } return verify_secure_dir(parent); } // Create with restrictive mode — DirBuilderExt::mode bypasses umask. let mut builder = std::fs::DirBuilder::new(); builder.recursive(true); builder.mode(0o700); if let Err(e) = builder.create(parent) { tracing::warn!("Failed to create token directory: {e}"); return false; } // Verify after creation (belt-and-suspenders) verify_secure_dir(parent) } fn load_restore_token() -> Option { load_restore_token_from(token_path()?) } fn load_restore_token_from(path: PathBuf) -> Option { use std::os::unix::fs::{MetadataExt, PermissionsExt}; let meta = match std::fs::symlink_metadata(&path) { Ok(m) => m, Err(_) => return None, }; if meta.file_type().is_symlink() { tracing::warn!( "Token file is a symlink, refusing to read: {}", path.display() ); return None; } if !meta.is_file() { tracing::warn!("Token path is not a regular file: {}", path.display()); return None; } // SAFETY: libc::getuid has no preconditions and cannot fail. if meta.uid() != unsafe { libc::getuid() } { tracing::warn!("Token file not owned by current user: {}", path.display()); return None; } let mode = meta.permissions().mode() & 0o777; if mode & 0o077 != 0 { tracing::warn!( "Token file has insecure permissions {:o}, refusing to read: {}", mode, path.display() ); return None; } let token = std::fs::read_to_string(&path).ok()?; let trimmed = token.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } } fn save_restore_token(token: &str) { let Some(path) = token_path() else { tracing::warn!("No secure cache directory available, skipping token save"); return; }; save_restore_token_to(token, &path); } fn delete_restore_token() { let Some(path) = token_path() else { return; }; 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 => {} Err(e) => tracing::warn!("Failed to delete stale restore token at {}: {e}", path.display()), } } 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; let Some(parent) = path.parent() else { tracing::warn!("Token path has no parent directory"); return; }; if !ensure_secure_parent(parent) { tracing::warn!("Parent directory is insecure, refusing to save token"); return; } // 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. let tmp_path = path.with_extension(format!("{}.tmp", std::process::id())); let result = (|| -> std::io::Result<()> { let mut f = OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&tmp_path)?; f.write_all(token.as_bytes())?; f.sync_all()?; std::fs::rename(&tmp_path, path)?; Ok(()) })(); match result { Ok(()) => tracing::info!("Saved portal restore token"), Err(e) => { let _ = std::fs::remove_file(&tmp_path); tracing::warn!("Failed to save restore token: {e}"); } } } impl Drop for CapPortal { /// 析构时安全关闭 PipeWire 线程 /// /// 通过向 eventfd 写入值来唤醒 PipeWire 事件循环,触发其退出。 /// 然后等待 PipeWire 线程的 JoinHandle,确保线程完全退出后才返回。 /// 这种基于 eventfd 的关闭机制避免了以下竞态条件: /// - 直接调用 mainloop.quit() 可能在 mainloop 已经销毁后触发(UAF) /// - eventfd 回调在 mainloop.run() 的上下文中执行,保证 mainloop 存活 fn drop(&mut self) { // Signal the PipeWire loop to quit via eventfd. // eventfd write is a kernel syscall — thread-safe and lock-free. let val: u64 = 1u64; // SAFETY: shutdown_fd is a valid open eventfd (owned by Self); the buffer is // a stack u64 of size 8 bytes which matches the count argument. POSIX write(2) // is the standard fd-write syscall; eventfd writes must be exactly 8 bytes. let _ = unsafe { libc::write( self.shutdown_fd.as_raw_fd(), &val as *const u64 as *const _, std::mem::size_of::(), ) }; // 等待 PipeWire 线程完全退出 // 这确保 PipeWire 资源在线程中被正确清理后,主线程才继续 if let Some(handle) = self.pw_thread.take() { let _ = handle.join(); } } } /// PipeWire 捕获线程主函数 /// /// 在独立线程中运行 PipeWire 事件循环,接收来自 Portal 的屏幕捕获帧。 /// 整体流程: /// 1. 初始化 PipeWire 库 (pw::init) /// 2. 创建 MainLoop(事件循环)、Context、Core(连接) /// 3. 使用 Portal 提供的 fd 和 node_id 创建并连接视频流 /// 4. 注册事件监听器(状态变化、格式协商、帧处理) /// 5. 将 shutdown eventfd 注册到事件循环,实现安全退出 /// 6. 运行事件循环,直到收到关闭信号 /// 7. 清理资源,调用 pw::deinit() /// /// 注意: 此函数使用 Rc> 而非 Arc>,因为 PipeWire 的回调 /// 都在同一个线程中执行,无需跨线程同步。 fn pipewire_thread(ctx: PwThreadCtx) { use pipewire as pw; use pw::properties::properties; use pw::spa::param::video::VideoInfoRaw; use pw::stream::{StreamBox, StreamFlags}; use std::cell::Cell; use std::rc::Rc; // 初始化 PipeWire 进程全局库。 // // pipewire-rs 内部使用 OnceCell 保护 pw::init(),确保只调用一次。 // pw::deinit() 是 unsafe 且要求"进程生命周期内仅调用一次,且所有 // PipeWire 使用已停止"。由于 CapPortal 可被多次创建销毁,此函数 // 不调用 pw::deinit()——进程退出时全局状态由 OS 回收。 pw::init(); let PwThreadCtx { frame_tx, event_tx, dropped, shutdown_read, pw_fd, node_id, fps: _, } = ctx; let mainloop = match pw::main_loop::MainLoopBox::new(None) { Ok(ml) => ml, Err(e) => { if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("MainLoop::new failed: {e}"))) { tracing::error!("MainLoop::new failed and error channel also failed: {e}"); } return; } }; let context = match pw::context::ContextBox::new(mainloop.loop_(), None) { Ok(c) => c, Err(e) => { if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("Context::new failed: {e}"))) { tracing::error!("Context::new failed and error channel also failed: {e}"); } return; } }; let core = match context.connect_fd(pw_fd, None) { Ok(c) => c, Err(e) => { if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("connect_fd failed: {e}"))) { tracing::error!("connect_fd failed and error channel also failed: {e}"); } return; } }; // 创建 PipeWire 视频流 // 属性配置: // - MEDIA_TYPE = "Video": 媒体类型为视频 // - MEDIA_CATEGORY = "Capture": 类别为捕获(而非回放) // - MEDIA_ROLE = "Screen": 角色为屏幕(用于策略管理) let stream = match StreamBox::new( &core, "wl-webrtc", properties! { *pw::keys::MEDIA_TYPE => "Video", *pw::keys::MEDIA_CATEGORY => "Capture", *pw::keys::MEDIA_ROLE => "Screen", *pw::keys::NODE_FORCE_QUANTUM => "512", }, ) { Ok(s) => s, Err(e) => { if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("Stream::new failed: {e}"))) { tracing::error!("Stream::new failed and error channel also failed: {e}"); } return; } }; let format_info: Rc>> = Rc::new(Cell::new(None)); let event_tx_state = event_tx.clone(); let _listener = stream .add_local_listener::<()>() .state_changed(move |_, _, old, new| { tracing::info!("PipeWire stream state: {old:?} -> {new:?}"); match new { pw::stream::StreamState::Error(e) => { tracing::error!("PipeWire stream error: {e}"); let _ = event_tx_state.try_send(PwCtrlEvent::StreamEnded); } pw::stream::StreamState::Unconnected => { let _ = event_tx_state.try_send(PwCtrlEvent::StreamEnded); } pw::stream::StreamState::Paused => { tracing::warn!("PipeWire stream paused (compositor may be switching content)"); } pw::stream::StreamState::Streaming => { tracing::info!("PipeWire stream (re)started"); } pw::stream::StreamState::Connecting => {} } }) // 参数变化回调(格式协商) // PipeWire 在流格式协商完成后触发此回调 // id 为参数类型,param 包含具体的格式参数(分辨率、像素格式等) .param_changed({ let format_info = format_info.clone(); let event_tx = event_tx.clone(); move |_, _, id, param| { // 仅处理 Format 类型的参数变化 let Some(param) = param else { return }; if id != pw::spa::param::ParamType::Format.as_raw() { return; } // 解析视频格式信息(分辨率、像素格式、修饰符等) let mut info = VideoInfoRaw::new(); if let Err(e) = info.parse(param) { tracing::warn!("Failed to parse video format: {e}"); return; } let width = info.size().width; let height = info.size().height; // 将 SPA 视频格式转换为 DRM FourCC 格式标识符 let drm_format = spa_to_drm_fourcc(info.format()); // 获取 DRM 修饰符,描述 GPU buffer 的内存布局(如 tiling 模式) let modifier = info.modifier(); let framerate = info.framerate(); let max_framerate = info.max_framerate(); // 保存协商后的格式信息,供 process 回调读取 let previous_format = format_info.get(); format_info.set(Some((width, height, drm_format, modifier))); if let Some((previous_width, previous_height, _, _)) = previous_format { if width != previous_width || height != previous_height { tracing::warn!( "PipeWire dimensions changed: {}x{} (format renegotiation)", width, height ); let _ = event_tx.try_send(PwCtrlEvent::FormatChanged { width, height }); } } tracing::info!( "PipeWire format negotiated: {width}x{height}, \ drm_format={drm_format:#010x}, modifier={modifier:#x}, \ framerate={}/{}, max_framerate={}/{}", framerate.num, framerate.denom, max_framerate.num, max_framerate.denom, ); } }) // 帧处理回调 —— 这是核心的数据路径 // 每当 PipeWire 有新的帧数据可用时触发 // 关键操作: 从 buffer 中提取 DMA-BUF fd,dup 后通过 channel 发送给消费者 .process({ let format_info = format_info.clone(); let frame_tx = frame_tx.clone(); let dropped = dropped; move |stream, _| { // SAFETY: raw_buf ownership invariant — PipeWire's process callback // contract requires that every buffer acquired via `dequeue_raw_buffer` // is returned to the queue EXACTLY ONCE via `queue_raw_buffer` before // the callback returns — on every exit path, success or error. Failure // to requeue leaks the buffer slot and eventually stalls the stream. // // Audit map of this closure (verified 2026-06-28): // - null raw_buf (dequeue returned NULL) → nothing to requeue, return. // - null spa_buf / no data / bad fd / null chunk / no format_info / // invalid dims / dup_fd < 0 → all requeue before early-return. // - success (try_send Ok / Full / Disconnected) → final requeue at end. // The fd ownership is independent: dup() creates a fresh fd that lives // inside PwDmaBufFrame; on try_send error the frame Drops and closes it. let raw_buf = unsafe { stream.dequeue_raw_buffer() }; if raw_buf.is_null() { tracing::trace!("process: null raw_buf"); return; } // 获取 SPA buffer 结构体,包含数据数组、元数据等 // SAFETY: raw_buf was checked non-null above. `pw_buffer.buffer` is a // valid raw pointer for the lifetime of raw_buf (PipeWire keeps the // buffer alive until we queue it back). let spa_buf = unsafe { (*raw_buf).buffer }; if spa_buf.is_null() { tracing::trace!("process: null spa_buf"); // SAFETY: raw_buf is the non-null buffer we still own; returning it. unsafe { stream.queue_raw_buffer(raw_buf) }; return; } // 获取 buffer 中的数据项数量和数据指针 // 对于 DMA-BUF 帧,通常只有 1 个数据项(包含 fd) // SAFETY: spa_buf checked non-null above; `n_datas` is a plain u32 field. let n_datas = unsafe { (*spa_buf).n_datas }; // SAFETY: same as above; `datas` is a raw pointer field, may be null. let datas_ptr = unsafe { (*spa_buf).datas }; if n_datas == 0 || datas_ptr.is_null() { tracing::trace!("process: no data (n_datas={n_datas})"); // SAFETY: raw_buf still owned, returning it. unsafe { stream.queue_raw_buffer(raw_buf) }; return; } // 从第一个数据项中获取 DMA-BUF 文件描述符 // 通过 libspa 的 Data 包装类型安全地访问 SPA 数据结构 // SAFETY: datas_ptr is non-null and n_datas > 0 (checked above). We cast // to pw::spa::buffer::Data and take a shared borrow; PipeWire does not // mutate the data array during a process cycle, so a shared reference // for the duration of this callback is sound. let data_ref: &pw::spa::buffer::Data = unsafe { &*(datas_ptr as *const pw::spa::buffer::Data) }; let fd = data_ref.fd(); if fd < 0 { tracing::trace!("process: invalid fd={fd}"); // SAFETY: raw_buf still owned, returning it. unsafe { stream.queue_raw_buffer(raw_buf) }; return; } if data_ref.as_raw().chunk.is_null() { tracing::trace!("process: null chunk"); // SAFETY: raw_buf still owned, returning it. unsafe { stream.queue_raw_buffer(raw_buf) }; return; } let chunk = data_ref.chunk(); let offset = chunk.offset() as u64; let stride = chunk.stride() as u32; // 从 SPA_META_Header 元数据中提取 PTS (显示时间戳) // 遍历 buffer 的所有元数据项,查找 Header 类型的元数据 // PTS 可用于音视频同步和帧率控制 // SAFETY: spa_buf is non-null. `metas` is checked for null before // iteration. We iterate `i in 0..n_metas` reading shared POD fields // (type_, size, data) — PipeWire keeps the meta array immutable during // a process cycle. The size guard (`meta.size >= size_of::()`) // and null-data check before reading ensure we never read past the // meta's actual extent. let pts: i64 = unsafe { let mut pts_val: i64 = 0; let n_metas = (*spa_buf).n_metas; let metas = (*spa_buf).metas; if !metas.is_null() { for i in 0..n_metas { let meta = &*metas.add(i as usize); if meta.type_ == libspa::sys::SPA_META_Header && meta.size as usize >= std::mem::size_of::() && !meta.data.is_null() { let header = &*(meta.data as *const libspa::sys::spa_meta_header); pts_val = header.pts; break; } } } pts_val }; // 验证格式信息已协商完成,且分辨率和格式有效 let Some((width, height, format, modifier)) = format_info.get() else { // SAFETY: raw_buf still owned, returning it. unsafe { stream.queue_raw_buffer(raw_buf) }; return; }; if width == 0 || height == 0 || format == 0 { tracing::trace!("process: invalid dimensions {width}x{height} format={format}"); // SAFETY: raw_buf still owned, returning it. unsafe { stream.queue_raw_buffer(raw_buf) }; return; } // 复制 DMA-BUF 文件描述符 // 必须 dup,因为原始 fd 由 PipeWire 管理,我们不能持有它 // dup 后的 fd 由 PwDmaBufFrame 持有,生命周期独立于 PipeWire buffer // SAFETY: `fd` is the open DMA-BUF fd reported by PipeWire (>= 0 checked // above). libc::dup is the standard POSIX fd duplication call. The // original `fd` remains owned by PipeWire (returned with raw_buf later). let dup_fd = unsafe { libc::dup(fd) }; if dup_fd < 0 { // SAFETY: raw_buf still owned, returning it. No fd cleanup needed // because dup() failed and never returned a new fd. unsafe { stream.queue_raw_buffer(raw_buf) }; return; } // 构建帧数据对象,所有必要的帧信息已收集完毕 // SAFETY: `dup_fd` is a freshly-dup'd open file descriptor (>= 0 checked // above) and we are its sole owner. OwnedFd::from_raw_fd takes ownership // and will close() it on Drop. The fd's lifecycle is independent of // raw_buf: whether try_send succeeds (frame moves into the channel) or // fails (Full/Disconnected — the error payload owns the frame and drops // it at the end of the match arm), exactly one close() occurs per dup(). let frame_fd = unsafe { OwnedFd::from_raw_fd(dup_fd) }; let frame = PwDmaBufFrame { fd: frame_fd, offset, stride, modifier, width, height, format, pts, }; match frame_tx.try_send(frame) { Ok(()) => {} Err(crossbeam_channel::TrySendError::Full(_)) => { dropped.fetch_add(1, Ordering::Relaxed); } Err(crossbeam_channel::TrySendError::Disconnected(_)) => {} } // SAFETY: final exactly-once requeue of raw_buf. Every path above // either returned early with its own requeue, or falls through to here. unsafe { stream.queue_raw_buffer(raw_buf) }; } }) .register(); let mut params: [&pw::spa::pod::Pod; 0] = []; if let Err(e) = stream.connect( pw::spa::utils::Direction::Input, Some(node_id), StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS, &mut params, ) { if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("stream.connect failed: {e}"))) { tracing::error!("stream.connect failed and error channel also failed: {e}"); } return; } let loop_ = mainloop.loop_(); // Register the shutdown eventfd on the PipeWire loop. // // When CapPortal::drop writes to the eventfd, the loop wakes up and // dispatches this callback on the loop thread. Because the callback // only fires while mainloop.run() is blocking this thread, mainloop // is guaranteed alive — eliminating the UAF that existed with the // previous detached helper thread approach. // 保存 mainloop 的原始指针,用于在 shutdown 回调中调用 pw_main_loop_quit // 这是安全的,因为回调只在 mainloop.run() 阻塞期间执行 let mainloop_ptr = mainloop.as_raw_ptr(); let _shutdown_source = loop_.add_io( shutdown_read, libspa::support::system::IoFlags::IN, move |fd| { // Drain the eventfd so it doesn't re-trigger let mut buf: u64 = 0; // SAFETY: `fd` is the registered eventfd owned by the mainloop source; the // buffer is a stack u64 of 8 bytes matching the count argument. POSIX // read(2) is the standard fd-read syscall; eventfd semantics require the // 8-byte buffer. let _ = unsafe { libc::read( fd.as_raw_fd(), &mut buf as *mut u64 as *mut _, std::mem::size_of::(), ) }; // SAFETY: This callback only executes while mainloop.run() is // blocking this thread, so mainloop is guaranteed alive. unsafe { pipewire::sys::pw_main_loop_quit(mainloop_ptr) }; }, ); // 启动 PipeWire 主事件循环 // 此调用会阻塞当前线程,直到 mainloop.quit() 被调用 // quit() 由 shutdown eventfd 的 IO 回调触发 mainloop.run(); // run() returned — _shutdown_source drops first (reverse declaration order), // which unregisters the callback from the loop. Then mainloop drops. // No dangling raw pointers are possible. // PipeWire global state is intentionally not deinitialized here — see pw::init() comment above. } /// 将四个 ASCII 字符编码为 32 位 FourCC (Four Character Code) 标识符 /// /// FourCC 是多媒体领域中广泛使用的像素格式标识方式。 /// 编码规则: 第一个字符在最低 8 位,依次向高位排列。 /// 例如: "BGRA" → 0x41524742 (小端序存储为 'B','G','R','A') const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 { (a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24) } /// 将 PipeWire SPA 视频格式转换为 DRM FourCC 格式 /// /// PipeWire 使用自己的 VideoFormat 枚举,而 DRM/KMS 使用 FourCC 格式标识。 /// 此函数建立了两者之间的映射关系。 /// /// 支持的格式: /// 不支持的格式返回 0 /// DRM 格式名描述像素值位布局(大端序),而非内存字节序。 /// 例如 DRM_FORMAT_ARGB8888 在小端 x86 上内存为 [B,G,R,A] = PipeWire BGRA。 fn spa_to_drm_fourcc(format: libspa::param::video::VideoFormat) -> u32 { use drm_fourcc::DrmFourcc; use libspa::param::video::VideoFormat; match format { VideoFormat::BGRA => DrmFourcc::Argb8888 as u32, VideoFormat::BGRx => DrmFourcc::Xrgb8888 as u32, VideoFormat::RGBA => DrmFourcc::Abgr8888 as u32, VideoFormat::RGBx => DrmFourcc::Xbgr8888 as u32, VideoFormat::ARGB => DrmFourcc::Bgra8888 as u32, VideoFormat::xRGB => DrmFourcc::Bgrx8888 as u32, VideoFormat::ABGR => DrmFourcc::Rgba8888 as u32, VideoFormat::xBGR => DrmFourcc::Rgbx8888 as u32, _ => 0, } } #[cfg(test)] mod tests { use super::*; use drm_fourcc::DrmFourcc; use std::os::unix::fs::PermissionsExt; #[test] fn spa_to_drm_fourcc_all_32bit() { use libspa::param::video::VideoFormat; assert_eq!( spa_to_drm_fourcc(VideoFormat::BGRA), DrmFourcc::Argb8888 as u32 ); assert_eq!( spa_to_drm_fourcc(VideoFormat::BGRx), DrmFourcc::Xrgb8888 as u32 ); assert_eq!( spa_to_drm_fourcc(VideoFormat::RGBA), DrmFourcc::Abgr8888 as u32 ); assert_eq!( spa_to_drm_fourcc(VideoFormat::RGBx), DrmFourcc::Xbgr8888 as u32 ); assert_eq!( spa_to_drm_fourcc(VideoFormat::ARGB), DrmFourcc::Bgra8888 as u32 ); assert_eq!( spa_to_drm_fourcc(VideoFormat::xRGB), DrmFourcc::Bgrx8888 as u32 ); assert_eq!( spa_to_drm_fourcc(VideoFormat::ABGR), DrmFourcc::Rgba8888 as u32 ); assert_eq!( spa_to_drm_fourcc(VideoFormat::xBGR), DrmFourcc::Rgbx8888 as u32 ); } #[test] fn spa_to_drm_fourcc_unsupported() { use libspa::param::video::VideoFormat; assert_eq!(spa_to_drm_fourcc(VideoFormat::NV12), 0); } #[test] fn token_path_never_uses_tmp() { assert!(token_path().is_some(), "token_path should resolve on Linux"); let path = token_path().unwrap(); assert!(!path.starts_with("/tmp"), "must not fallback to /tmp"); } #[test] fn verify_secure_dir_rejects_wrong_permissions() { let dir = tempfile::tempdir().unwrap(); let path = dir.path(); // 0o700 should pass std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); assert!(verify_secure_dir(path)); // 0o755 should fail std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); assert!(!verify_secure_dir(path)); // 0o777 should fail std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777)).unwrap(); assert!(!verify_secure_dir(path)); } #[test] fn verify_secure_dir_rejects_non_directory() { let dir = tempfile::tempdir().unwrap(); let file_path = dir.path().join("not-a-dir"); std::fs::write(&file_path, b"test").unwrap(); assert!(!verify_secure_dir(&file_path)); } #[test] fn ensure_secure_parent_creates_with_0700() { let base = tempfile::tempdir().unwrap(); let new_dir = base.path().join("wl-test-new-dir"); assert!(!new_dir.exists()); assert!(ensure_secure_parent(&new_dir)); assert!(new_dir.is_dir()); let meta = std::fs::symlink_metadata(&new_dir).unwrap(); let mode = meta.permissions().mode() & 0o777; assert_eq!( mode, 0o700, "created directory should be 0700, got {mode:o}" ); } #[test] fn ensure_secure_parent_tightens_existing_dir() { let dir = tempfile::tempdir().unwrap(); let path = dir.path(); // Simulate an existing directory with loose permissions std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); assert!(ensure_secure_parent(path)); let meta = std::fs::symlink_metadata(path).unwrap(); let mode = meta.permissions().mode() & 0o777; assert_eq!( mode, 0o700, "tightened directory should be 0700, got {mode:o}" ); } #[test] fn save_creates_file_with_0600() { let dir = tempfile::tempdir().unwrap(); let token_path = dir.path().join("portal-restore-token"); save_restore_token_to("secret-token-123", &token_path); assert!(token_path.exists()); let meta = std::fs::symlink_metadata(&token_path).unwrap(); let mode = meta.permissions().mode() & 0o777; assert_eq!(mode, 0o600, "token file should be 0600, got {mode:o}"); assert_eq!( std::fs::read_to_string(&token_path).unwrap(), "secret-token-123" ); } #[test] fn load_reads_secure_file() { let dir = tempfile::tempdir().unwrap(); let token_path = dir.path().join("portal-restore-token"); // Write a valid 0o600 token file use std::os::unix::fs::OpenOptionsExt; let mut f = std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&token_path) .unwrap(); std::io::Write::write_all(&mut f, b"my-secret\n").unwrap(); let result = load_restore_token_from(token_path); assert_eq!(result, Some("my-secret".to_string())); } #[test] fn load_rejects_group_readable_file() { let dir = tempfile::tempdir().unwrap(); let token_path = dir.path().join("portal-restore-token"); // Write with 0o640 (group readable) — should be rejected use std::os::unix::fs::OpenOptionsExt; let mut f = std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o640) .open(&token_path) .unwrap(); std::io::Write::write_all(&mut f, b"leaked-token\n").unwrap(); let result = load_restore_token_from(token_path); assert!(result.is_none(), "should reject group-readable token file"); } #[test] fn load_rejects_world_readable_file() { let dir = tempfile::tempdir().unwrap(); let token_path = dir.path().join("portal-restore-token"); use std::os::unix::fs::OpenOptionsExt; let mut f = std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o604) .open(&token_path) .unwrap(); std::io::Write::write_all(&mut f, b"leaked-token\n").unwrap(); let result = load_restore_token_from(token_path); assert!(result.is_none(), "should reject world-readable token file"); } #[test] fn load_rejects_symlink() { let dir = tempfile::tempdir().unwrap(); let real_path = dir.path().join("real-file"); let link_path = dir.path().join("portal-restore-token"); std::fs::write(&real_path, b"target-content\n").unwrap(); std::os::unix::fs::symlink(&real_path, &link_path).unwrap(); let result = load_restore_token_from(link_path); assert!(result.is_none(), "should reject symlinked token file"); } #[test] fn save_then_load_roundtrip() { let dir = tempfile::tempdir().unwrap(); let token_path = dir.path().join("portal-restore-token"); save_restore_token_to("roundtrip-token", &token_path); let loaded = load_restore_token_from(token_path); assert_eq!(loaded, Some("roundtrip-token".to_string())); } }