use std::{collections::HashMap, os::fd::OwnedFd};
use zbus::{
blocking::{Connection, Proxy},
zvariant::{OwnedFd as ZbusOwnedFd, OwnedObjectPath, OwnedValue, Value},
};
use pinray_core::{PinrayError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorMode {
Hidden = 1,
Embedded = 2,
}
impl CursorMode {
fn bits(self) -> u32 {
self as u32
}
}
#[derive(Debug, Clone)]
pub struct ScreenCastStream {
pub node_id: u32,
#[allow(dead_code)]
pub width: Option<u32>,
#[allow(dead_code)]
pub height: Option<u32>,
}
#[derive(Debug)]
pub struct ActiveScreenCast {
fd: OwnedFd,
pub streams: Vec<ScreenCastStream>,
pub restore_token: Option<String>,
}
impl ActiveScreenCast {
pub fn into_parts(self) -> (OwnedFd, Vec<ScreenCastStream>, Option<String>) {
(self.fd, self.streams, self.restore_token)
}
}
pub struct PortalClient {
connection: Connection,
}
impl PortalClient {
pub fn new() -> Result<Self> {
let connection =
Connection::session().map_err(|error| PinrayError::Platform(error.to_string()))?;
Ok(Self { connection })
}
pub fn start_screen_cast(
&self,
include_cursor: bool,
restore_token: Option<&str>,
) -> Result<ActiveScreenCast> {
let desktop = Proxy::new(
&self.connection,
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.ScreenCast",
)
.map_err(|error| PinrayError::Platform(error.to_string()))?;
tracing::debug!("portal: creating session...");
let session = self.create_session(&desktop)?;
tracing::debug!(?session, "portal: session created");
tracing::debug!("portal: selecting sources...");
self.select_sources(&desktop, &session, include_cursor, restore_token)?;
tracing::debug!("portal: sources selected");
tracing::debug!("portal: starting capture...");
let response = self.start(&desktop, &session)?;
tracing::debug!(streams = ?response.streams, "portal: capture started");
tracing::debug!("portal: opening PipeWire remote...");
let fd = self.open_pipewire_remote(&desktop, &session)?;
tracing::debug!("portal: PipeWire remote opened");
Ok(ActiveScreenCast {
fd,
streams: response
.streams
.unwrap_or_default()
.into_iter()
.map(|(node_id, props)| ScreenCastStream {
node_id,
width: extract_size(&props).map(|value| value.0),
height: extract_size(&props).map(|value| value.1),
})
.collect(),
restore_token: response.restore_token,
})
}
fn create_session(&self, desktop: &Proxy<'_>) -> Result<OwnedObjectPath> {
let handle_token = next_token();
let request = self.request_proxy(&handle_token)?;
let session_handle_token = next_token();
let mut signal = request
.receive_signal("Response")
.map_err(|error| PinrayError::Platform(error.to_string()))?;
let options = HashMap::from([
("handle_token", Value::from(handle_token.as_str())),
(
"session_handle_token",
Value::from(session_handle_token.as_str()),
),
]);
desktop
.call_method("CreateSession", &(options))
.map_err(|error| PinrayError::Platform(error.to_string()))?;
let response = wait_response(&mut signal)?;
let session_handle = response.get("session_handle").ok_or_else(|| {
PinrayError::Platform("portal response missing session_handle".into())
})?;
let session_handle = String::try_from(session_handle.clone())
.map_err(|error| PinrayError::Platform(error.to_string()))?;
OwnedObjectPath::try_from(session_handle)
.map_err(|error| PinrayError::Platform(error.to_string()))
}
fn select_sources(
&self,
desktop: &Proxy<'_>,
session: &OwnedObjectPath,
include_cursor: bool,
restore_token: Option<&str>,
) -> Result<()> {
let handle_token = next_token();
let request = self.request_proxy(&handle_token)?;
let mut signal = request
.receive_signal("Response")
.map_err(|error| PinrayError::Platform(error.to_string()))?;
let mut options = HashMap::from([
("handle_token", Value::from(handle_token.as_str())),
("types", Value::from(3_u32)),
("multiple", Value::from(false)),
(
"cursor_mode",
Value::from(
if include_cursor {
CursorMode::Embedded
} else {
CursorMode::Hidden
}
.bits(),
),
),
("persist_mode", Value::from(2_u32)),
]);
if let Some(restore_token) = restore_token {
options.insert("restore_token", Value::from(restore_token));
}
desktop
.call_method("SelectSources", &(session, options))
.map_err(|error| PinrayError::Platform(error.to_string()))?;
let _ = wait_response(&mut signal)?;
Ok(())
}
fn start(&self, desktop: &Proxy<'_>, session: &OwnedObjectPath) -> Result<StartResponse> {
let handle_token = next_token();
let request = self.request_proxy(&handle_token)?;
let mut signal = request
.receive_signal("Response")
.map_err(|error| PinrayError::Platform(error.to_string()))?;
let options = HashMap::from([("handle_token", Value::from(handle_token.as_str()))]);
desktop
.call_method("Start", &(session, "", options))
.map_err(|error| PinrayError::Platform(error.to_string()))?;
let response = wait_response(&mut signal)?;
let streams = response
.get("streams")
.cloned()
.map(parse_streams)
.transpose()?;
let restore_token = response
.get("restore_token")
.cloned()
.map(String::try_from)
.transpose()
.map_err(|error| PinrayError::Platform(error.to_string()))?;
Ok(StartResponse {
streams,
restore_token,
})
}
fn open_pipewire_remote(
&self,
desktop: &Proxy<'_>,
session: &OwnedObjectPath,
) -> Result<OwnedFd> {
let options: HashMap<&str, Value<'_>> = HashMap::new();
let fd: ZbusOwnedFd = desktop
.call("OpenPipeWireRemote", &(session, options))
.map_err(|error| PinrayError::Platform(error.to_string()))?;
Ok(fd.into())
}
fn request_proxy(&self, handle_token: &str) -> Result<Proxy<'_>> {
let unique_identifier = self
.connection
.unique_name()
.ok_or_else(|| PinrayError::Platform("missing dbus unique name".into()))?
.trim_start_matches(':')
.replace('.', "_");
let path =
format!("/org/freedesktop/portal/desktop/request/{unique_identifier}/{handle_token}");
Proxy::new(
&self.connection,
"org.freedesktop.portal.Desktop",
path,
"org.freedesktop.portal.Request",
)
.map_err(|error| PinrayError::Platform(error.to_string()))
}
}
fn wait_response(
signal: &mut zbus::blocking::proxy::SignalIterator<'_>,
) -> Result<HashMap<String, OwnedValue>> {
let message = signal
.next()
.ok_or_else(|| PinrayError::Platform("portal response signal missing".into()))?;
let (code, body): (u32, HashMap<String, OwnedValue>) = message
.body()
.deserialize()
.map_err(|error| PinrayError::Platform(error.to_string()))?;
match code {
0 => Ok(body),
1 => Err(PinrayError::Unsupported(
"portal request cancelled by user".into(),
)),
other => Err(PinrayError::Platform(format!(
"portal request failed with response code {other}"
))),
}
}
fn parse_streams(value: OwnedValue) -> Result<Vec<(u32, HashMap<String, OwnedValue>)>> {
let entries = Vec::<(u32, HashMap<String, OwnedValue>)>::try_from(value)
.map_err(|error| PinrayError::Platform(error.to_string()))?;
Ok(entries)
}
fn extract_size(props: &HashMap<String, OwnedValue>) -> Option<(u32, u32)> {
let size = props.get("size")?;
let (width, height) = <(i32, i32)>::try_from(size.clone()).ok()?;
Some((width.max(0) as u32, height.max(0) as u32))
}
fn next_token() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(1);
format!("pinray_{}", COUNTER.fetch_add(1, Ordering::Relaxed))
}
#[derive(Debug)]
struct StartResponse {
streams: Option<Vec<(u32, HashMap<String, OwnedValue>)>>,
restore_token: Option<String>,
}