wo/src/state/portal.rs
2026-10-25 23:44:14 -04:00

706 lines
23 KiB
Rust

/// xdg-desktop-portal implementation for Wo compositor
use anyhow::{Context, Result};
use pipewire as pw;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{debug, info, warn};
/// Portal session state
#[derive(Debug, Clone)]
pub struct PortalSession {
pub session_id: String,
pub source_type: SourceType,
pub target: Option<String>, // "screen" or window name
pub active: bool,
}
/// Portal source types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SourceType {
Monitor,
Window,
Virtual,
}
/// Portal capture request
#[derive(Debug, Clone, Deserialize)]
pub struct CaptureRequest {
pub source_type: SourceType,
pub target: Option<String>,
pub cursor_mode: Option<u32>,
pub restore_token: Option<String>,
}
/// Portal capture response
#[derive(Debug, Clone, Serialize)]
pub struct CaptureResponse {
pub session_id: String,
pub streams: Vec<StreamInfo>,
}
/// Stream information for PipeWire
#[derive(Debug, Clone, Serialize)]
pub struct StreamInfo {
pub node_id: u32,
pub width: u32,
pub height: u32,
pub source_type: SourceType,
}
/// xdg-desktop-portal handler
pub struct WoPortal {
sessions: Arc<Mutex<HashMap<String, PortalSession>>>,
next_session_id: Arc<Mutex<u64>>,
streams: Arc<Mutex<HashMap<u32, Arc<ScreenCaptureStream>>>>,
}
impl WoPortal {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
next_session_id: Arc::new(Mutex::new(1)),
streams: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Create a new portal session
pub fn create_session(&self) -> String {
let mut next_id = self.next_session_id.lock().unwrap();
let session_id = format!("wo_portal_session_{}", *next_id);
*next_id += 1;
let session = PortalSession {
session_id: session_id.clone(),
source_type: SourceType::Monitor,
target: None,
active: false,
};
self.sessions
.lock()
.unwrap()
.insert(session_id.clone(), session);
info!("Created portal session: {}", session_id);
session_id
}
/// Select sources for a session
pub fn select_sources(&self, session_id: &str, request: CaptureRequest) -> Result<()> {
let mut sessions = self.sessions.lock().unwrap();
let session = sessions.get_mut(session_id).context("session not found")?;
session.source_type = request.source_type;
session.target = request.target.clone();
debug!(
"Selected sources for session {}: source_type={:?}, target={:?}",
session_id, request.source_type, request.target
);
Ok(())
}
/// Start a capture session
pub fn start_session(
&self,
session_id: &str,
width: u32,
height: u32,
) -> Result<CaptureResponse> {
let mut sessions = self.sessions.lock().unwrap();
let session = sessions.get_mut(session_id).context("session not found")?;
session.active = true;
// Generate a PipeWire node ID based on session
let node_id = session_id
.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u32>()
.unwrap_or(1)
+ 100; // Offset to avoid conflicts
// Create the actual PipeWire stream
let stream = Arc::new(ScreenCaptureStream::new(
node_id,
width,
height,
session.target.clone(),
)?);
// Connect the stream — returns the real PipeWire-assigned node ID
let real_node_id = stream.connect()?;
// Store the stream keyed by its real PipeWire node ID
let mut streams = self.streams.lock().unwrap();
streams.insert(real_node_id, stream);
let stream_info = StreamInfo {
node_id: real_node_id,
width,
height,
source_type: session.source_type,
};
info!(
"Started portal session {}: {}x{} (node {})",
session_id, width, height, real_node_id
);
Ok(CaptureResponse {
session_id: session_id.to_string(),
streams: vec![stream_info],
})
}
/// Stop a capture session
pub fn stop_session(&self, session_id: &str) -> Result<()> {
let mut sessions = self.sessions.lock().unwrap();
if let Some(session) = sessions.get_mut(session_id) {
session.active = false;
info!("Stopped portal session: {}", session_id);
}
Ok(())
}
/// Close a portal session
pub fn close_session(&self, session_id: &str) {
let mut sessions = self.sessions.lock().unwrap();
sessions.remove(session_id);
info!("Closed portal session: {}", session_id);
}
/// Get active sessions
pub fn get_active_sessions(&self) -> Vec<PortalSession> {
self.sessions
.lock()
.unwrap()
.values()
.filter(|s| s.active)
.cloned()
.collect()
}
/// Get session by ID
pub fn get_session(&self, session_id: &str) -> Option<PortalSession> {
self.sessions.lock().unwrap().get(session_id).cloned()
}
/// Push a DMABUF frame to appropriate streams
pub fn push_frame_to_streams(
&self,
window_name: Option<&str>,
dmabuf_fd: i32,
_width: u32,
_height: u32,
stride: u32,
offset: u32,
modifier: u64,
) -> Result<()> {
let streams = self.streams.lock().unwrap();
for stream in streams.values() {
// Check if this stream is for this window
let matches = match (&stream.window_name, window_name) {
(Some(stream_win), Some(win)) => stream_win == win,
(None, None) => true, // Monitor/screen capture
_ => false,
};
if matches && stream.is_active() {
stream.push_frame(dmabuf_fd, stride, offset, modifier)?;
}
}
Ok(())
}
/// Get the stream for a given node ID
pub fn get_stream(&self, node_id: u32) -> Option<Arc<ScreenCaptureStream>> {
self.streams.lock().unwrap().get(&node_id).cloned()
}
/// Push raw pixel data (ARGB8888/BGRx) to matching portal streams.
/// Called from the compositor after offscreen capture or full-screen compositing.
pub fn push_pixels_to_streams(&self, window_name: Option<&str>, pixels: &[u8]) {
let streams = self.streams.lock().unwrap();
for stream in streams.values() {
let matches = match (&stream.window_name, window_name) {
(Some(stream_win), Some(win)) => stream_win == win,
(None, None) => true, // Monitor capture gets full screen
(None, Some(_)) => true, // Monitor capture sees all windows
_ => false,
};
if matches && stream.is_active() {
stream.push_pixels(pixels);
}
}
}
/// Publish a window crop from the already-composited desktop frame.
/// The output is scaled to the negotiated PipeWire size so captures remain
/// valid when a window changes size after portal Start.
pub fn push_window_capture(
&self,
window_name: &str,
pixels: &[u8],
screen_width: u32,
screen_height: u32,
bounds: (i32, i32, u32, u32),
) {
let streams = self.streams.lock().unwrap();
let (x, y, width, height) = bounds;
if screen_width == 0 || screen_height == 0 || width == 0 || height == 0 {
return;
}
for stream in streams.values().filter(|stream| {
stream.window_name.as_deref() == Some(window_name) && stream.is_active()
}) {
let mut output = vec![0; stream.width as usize * stream.height as usize * 4];
for dy in 0..stream.height {
let sy = y + (dy as u64 * height as u64 / stream.height.max(1) as u64) as i32;
if sy < 0 || sy >= screen_height as i32 {
continue;
}
for dx in 0..stream.width {
let sx = x + (dx as u64 * width as u64 / stream.width.max(1) as u64) as i32;
if sx < 0 || sx >= screen_width as i32 {
continue;
}
let src = (sy as usize * screen_width as usize + sx as usize) * 4;
let dst = (dy as usize * stream.width as usize + dx as usize) * 4;
if src + 4 <= pixels.len() {
output[dst..dst + 4].copy_from_slice(&pixels[src..src + 4]);
}
}
}
stream.push_pixels(&output);
}
}
}
impl Default for WoPortal {
fn default() -> Self {
Self::new()
}
}
/// D-Bus handler for xdg-desktop-portal
pub struct PortalDBusHandler {
portal: Arc<WoPortal>,
}
impl PortalDBusHandler {
pub fn new(portal: Arc<WoPortal>) -> Self {
Self { portal }
}
/// Handle D-Bus method calls
pub fn handle_method_call(
&self,
interface: &str,
method: &str,
args: serde_json::Value,
) -> Result<serde_json::Value> {
debug!("Portal method call: {}.{}", interface, method);
match (interface, method) {
("org.freedesktop.portal.ScreenCast", "CreateSession") => {
let session_id = self.portal.create_session();
Ok(serde_json::json!({
"session_handle": format!("/org/freedesktop/portal/desktop/session/{}", session_id),
}))
}
("org.freedesktop.portal.ScreenCast", "SelectSources") => {
let request: CaptureRequest = serde_json::from_value(args)?;
let session_id = request
.restore_token
.clone()
.unwrap_or_else(|| "default".to_string());
self.portal.select_sources(&session_id, request)?;
Ok(serde_json::json!({ "success": true }))
}
("org.freedesktop.portal.ScreenCast", "Start") => {
let session_id = args
.get("session_handle")
.and_then(|v| v.as_str())
.unwrap_or("default");
let width = args.get("width").and_then(|v| v.as_u64()).unwrap_or(1920) as u32;
let height = args.get("height").and_then(|v| v.as_u64()).unwrap_or(1080) as u32;
let response = self.portal.start_session(session_id, width, height)?;
Ok(serde_json::to_value(response)?)
}
_ => {
warn!("Unhandled portal method: {}.{}", interface, method);
Ok(serde_json::json!({ "error": "not implemented" }))
}
}
}
}
/// PipeWire stream implementation for screen capture with real frame streaming.
/// Spawns a dedicated PipeWire thread that produces video frames sourced from
/// the compositor's offscreen capture (raw ARGB8888/BGRx pixels).
pub struct ScreenCaptureStream {
pub node_id: u32,
pub width: u32,
pub height: u32,
pub window_name: Option<String>,
active: Arc<Mutex<bool>>,
frame_counter: Arc<Mutex<u64>>,
frame_queue: Arc<Mutex<Vec<PipeWireFrame>>>,
/// Latest raw pixel buffer (ARGB8888/BGRx) for PipeWire consumption.
/// The PipeWire process callback copies from this into PipeWire-allocated buffers.
latest_pixels: Arc<Mutex<Option<Vec<u8>>>>,
}
/// Frame data for PipeWire streaming
#[derive(Clone, Debug)]
pub struct PipeWireFrame {
pub pts: u64, // Presentation timestamp
pub dmabuf_fd: i32,
pub stride: u32,
pub offset: u32,
pub modifier: u64,
pub width: u32,
pub height: u32,
}
impl ScreenCaptureStream {
pub fn new(node_id: u32, width: u32, height: u32, window_name: Option<String>) -> Result<Self> {
info!(
"Created PipeWire stream {} for {}x{} capture (window: {:?})",
node_id, width, height, window_name
);
Ok(Self {
node_id,
width,
height,
window_name,
active: Arc::new(Mutex::new(false)),
frame_counter: Arc::new(Mutex::new(0)),
frame_queue: Arc::new(Mutex::new(Vec::new())),
latest_pixels: Arc::new(Mutex::new(None)),
})
}
/// Push a frame to the PipeWire stream with DMABUF
pub fn push_frame(
&self,
dmabuf_fd: i32,
stride: u32,
offset: u32,
modifier: u64,
) -> Result<()> {
if !*self.active.lock().unwrap() {
return Ok(()); // Stream not active
}
let mut counter = self.frame_counter.lock().unwrap();
let pts = *counter;
*counter += 1;
// Queue frame for consumption by PipeWire
let frame = PipeWireFrame {
pts,
dmabuf_fd,
stride,
offset,
modifier,
width: self.width,
height: self.height,
};
let mut queue = self.frame_queue.lock().unwrap();
queue.push(frame.clone());
// Keep queue bounded to prevent memory issues
if queue.len() > 30 {
// 30 frame buffer
queue.remove(0);
}
debug!(
"Stream {} frame {}: DMABUF fd={}, stride={}, offset={}, modifier={:#x}",
self.node_id, pts, dmabuf_fd, stride, offset, modifier
);
Ok(())
}
/// Get the next frame from the queue (for PipeWire processing)
pub fn dequeue_frame(&self) -> Option<PipeWireFrame> {
let mut queue = self.frame_queue.lock().unwrap();
if queue.is_empty() {
None
} else {
Some(queue.remove(0))
}
}
/// Push raw pixel data (ARGB8888/BGRx) for PipeWire consumption.
/// The PipeWire process callback will copy from this buffer into PipeWire-allocated buffers.
pub fn push_pixels(&self, pixels: &[u8]) {
if !self.is_active() {
return;
}
let mut counter = self.frame_counter.lock().unwrap();
*counter += 1;
let mut latest = self.latest_pixels.lock().unwrap();
*latest = Some(pixels.to_vec());
}
/// Connect and activate the stream.
/// Spawns a PipeWire thread that creates a Video/Source node.
/// Returns the real PipeWire-assigned node ID (which consumers use to subscribe).
pub fn connect(&self) -> Result<u32> {
*self.active.lock().unwrap() = true;
let width = self.width;
let height = self.height;
let fallback_node_id = self.node_id;
let active = self.active.clone();
let latest_pixels = self.latest_pixels.clone();
let (node_tx, node_rx) = std::sync::mpsc::sync_channel::<u32>(1);
std::thread::Builder::new()
.name(format!("pw-capture-{}", self.node_id))
.spawn(move || {
if let Err(e) =
run_pw_output_stream(width, height, active.clone(), latest_pixels, node_tx)
{
warn!("PipeWire stream thread failed: {e:#}");
// Mark inactive on failure so the stream reports itself as dead.
*active.lock().unwrap() = false;
}
})
.context("Failed to spawn PipeWire thread")?;
// Wait for PipeWire to assign the real node ID.
match node_rx.recv_timeout(Duration::from_secs(5)) {
Ok(real_node_id) => {
info!(
"Stream {}: PipeWire ready with node_id {} ({}x{}, 60fps)",
fallback_node_id, real_node_id, width, height
);
Ok(real_node_id)
}
Err(_) => {
warn!(
"Stream {}: PipeWire did not become ready within 5s, using fallback node_id",
fallback_node_id
);
Ok(fallback_node_id)
}
}
}
pub fn is_active(&self) -> bool {
*self.active.lock().unwrap()
}
pub fn disconnect(&self) {
*self.active.lock().unwrap() = false;
debug!("Stream {}: disconnected", self.node_id);
}
pub fn frame_count(&self) -> u64 {
*self.frame_counter.lock().unwrap()
}
pub fn queue_size(&self) -> usize {
self.frame_queue.lock().unwrap().len()
}
}
impl Drop for ScreenCaptureStream {
fn drop(&mut self) {
self.disconnect();
debug!(
"Stream {}: dropped (pushed {} frames)",
self.node_id,
self.frame_count()
);
}
}
// ── PipeWire output stream implementation ───────────────────────────────────
/// User data passed into PipeWire stream callbacks.
struct PwStreamUserData {
active: Arc<Mutex<bool>>,
latest_pixels: Arc<Mutex<Option<Vec<u8>>>>,
width: u32,
_height: u32,
/// One-shot channel to report the assigned PipeWire node ID back to the
/// caller of `connect()`. Consumed (taken) on the first state transition
/// to `Paused`.
node_tx: Option<std::sync::mpsc::SyncSender<u32>>,
}
/// Run a PipeWire output (Video/Source) stream on a dedicated thread.
///
/// The stream advertises BGRx video at the requested resolution and frame rate.
/// When a consumer (OBS, xdg-desktop-portal client, etc.) connects, the
/// `process` callback copies the latest pixel buffer from `latest_pixels` into
/// PipeWire-allocated mapped buffers.
fn run_pw_output_stream(
width: u32,
height: u32,
active: Arc<Mutex<bool>>,
latest_pixels: Arc<Mutex<Option<Vec<u8>>>>,
node_tx: std::sync::mpsc::SyncSender<u32>,
) -> Result<()> {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| anyhow::anyhow!("PipeWire MainLoop creation failed: {e}"))?;
let context = pw::context::ContextRc::new(&mainloop, None)
.map_err(|e| anyhow::anyhow!("PipeWire Context creation failed: {e}"))?;
let core = context
.connect_rc(None)
.map_err(|e| anyhow::anyhow!("PipeWire daemon connection failed: {e}"))?;
let stream = pw::stream::StreamBox::new(
&core,
"wo-screen-capture",
pw::properties::properties! {
*pw::keys::MEDIA_TYPE => "Video",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_CLASS => "Video/Source",
*pw::keys::MEDIA_ROLE => "Screen",
},
)
.map_err(|e| anyhow::anyhow!("PipeWire stream creation failed: {e}"))?;
let user_data = PwStreamUserData {
active,
latest_pixels,
width,
_height: height,
node_tx: Some(node_tx),
};
let _listener = stream
.add_local_listener_with_user_data(user_data)
.state_changed(|stream_ref, ud, old, new| {
info!("PipeWire stream state: {:?} -> {:?}", old, new);
// Report the assigned node ID once the stream reaches Paused
// (the first stable state after connection where node_id is valid).
if matches!(new, pw::stream::StreamState::Paused) {
if let Some(tx) = ud.node_tx.take() {
let node_id = stream_ref.node_id();
info!("PipeWire assigned node_id: {}", node_id);
let _ = tx.send(node_id);
}
}
})
.param_changed(|_, _, _id, _param| {
// Format negotiation is handled automatically by PipeWire.
})
.process(|stream_ref, ud| {
if !*ud.active.lock().unwrap() {
return;
}
let pixels_guard = ud.latest_pixels.lock().unwrap();
if let Some(ref pixels) = *pixels_guard {
if let Some(mut buffer) = stream_ref.dequeue_buffer() {
let datas = buffer.datas_mut();
if !datas.is_empty() {
let data = &mut datas[0];
if let Some(slice) = data.data() {
let copy_len = slice.len().min(pixels.len());
slice[..copy_len].copy_from_slice(&pixels[..copy_len]);
}
let chunk = data.chunk_mut();
*chunk.size_mut() = pixels.len() as u32;
*chunk.stride_mut() = (ud.width * 4) as i32;
*chunk.offset_mut() = 0;
}
}
}
})
.register()
.map_err(|e| anyhow::anyhow!("PipeWire listener registration failed: {e}"))?;
// Build SPA pod describing the video format we produce: BGRx at (width x height), 60 fps.
let obj = pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaType,
Id,
pw::spa::param::format::MediaType::Video
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaSubtype,
Id,
pw::spa::param::format::MediaSubtype::Raw
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFormat,
Choice,
Enum,
Id,
pw::spa::param::video::VideoFormat::BGRx,
pw::spa::param::video::VideoFormat::BGRx,
pw::spa::param::video::VideoFormat::RGBA
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoSize,
Choice,
Range,
Rectangle,
pw::spa::utils::Rectangle { width, height },
pw::spa::utils::Rectangle {
width: 1,
height: 1
},
pw::spa::utils::Rectangle {
width: 4096,
height: 4096
}
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
pw::spa::utils::Fraction { num: 60, denom: 1 },
pw::spa::utils::Fraction { num: 1, denom: 1 },
pw::spa::utils::Fraction { num: 120, denom: 1 }
),
);
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.map_err(|e| anyhow::anyhow!("SPA pod serialization failed: {e:?}"))?
.0
.into_inner();
let mut params = [pw::spa::pod::Pod::from_bytes(&values)
.ok_or_else(|| anyhow::anyhow!("Failed to parse SPA pod from serialized bytes"))?];
stream
.connect(
pw::spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.map_err(|e| anyhow::anyhow!("PipeWire stream connect failed: {e}"))?;
info!("PipeWire output stream connected, entering main loop");
mainloop.run();
info!("PipeWire output stream main loop exited");
Ok(())
}