portal_screencast/
lib.rs

1//! # XDG ScreenCast Portal utilities
2//!
3//! This module defines an interface for interacting with the ScreenCast portal.
4//!
5//! The general interaction pattern with the `ScreenCast` portal is to open a
6//! session, set which source types are of interest, and call `start()`.
7//!
8//! ```no_run
9//! # use portal_screencast::{ScreenCast, PortalError};
10//! # fn test() -> Result<(), PortalError> {
11//! let screen_cast = ScreenCast::new()?.start(None)?;
12//! # Ok(())
13//! # }
14//! ```
15//!
16//! In more complex cases you can modify the `ScreenCast` before starting it:
17//!
18//! ```no_run
19//! # use portal_screencast::{ScreenCast, PortalError, SourceType};
20//! # fn test() -> Result<(), PortalError> {
21//! let mut screen_cast = ScreenCast::new()?;
22//! // Set which source types to allow, and enable multiple items to be shared.
23//! screen_cast.set_source_types(SourceType::MONITOR);
24//! screen_cast.enable_multiple();
25//! // If you have a window handle you can tie the dialog to it
26//! let screen_cast = screen_cast.start(Some("wayland:<window_id>"))?;
27//! # Ok(())
28//! # }
29//! ```
30
31use bitflags::bitflags;
32use dbus::{
33    arg::{OwnedFd, RefArg, Variant},
34    blocking::{Connection, Proxy},
35    channel::Token,
36    Message, Path,
37};
38use generated::{
39    OrgFreedesktopPortalRequestResponse, OrgFreedesktopPortalScreenCast,
40    OrgFreedesktopPortalSession,
41};
42use std::{
43    collections::HashMap,
44    convert::TryInto,
45    os::unix::prelude::RawFd,
46    sync::mpsc::{self, Receiver},
47    time::Duration,
48};
49
50mod generated;
51
52// - - - - - - - - - - - - - - -  Public Interface - - - - - - - - - - - - - -
53
54/// Desktop portal error. This could be an error from the underlying `dbus`
55/// library, a generic error string, or some structured error.
56#[derive(Debug)]
57pub enum PortalError {
58    /// A generic error string describing the problem.
59    Generic(String),
60    /// A raw error from the `dbus` library.
61    DBus(dbus::Error),
62    /// A problem with deserialising the response to a portal request.
63    Parse,
64    /// Cancelled by the user.
65    Cancelled,
66}
67
68impl std::convert::From<String> for PortalError {
69    fn from(error_string: String) -> Self {
70        PortalError::Generic(error_string)
71    }
72}
73
74impl std::convert::From<dbus::Error> for PortalError {
75    fn from(err: dbus::Error) -> Self {
76        PortalError::DBus(err)
77    }
78}
79
80impl std::fmt::Display for PortalError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(f, "D-Bus Portal error: {0:?}", self)
83    }
84}
85
86impl std::error::Error for PortalError {}
87
88/// An un-opened screencast session. This can be queried fro the supported
89/// capture source types, and used to configure which source types to prompt
90/// for. Each `ScreenCast` can be mde active once by calling `start()`.
91pub struct ScreenCast {
92    state: ConnectionState,
93    session: String,
94    multiple: bool,
95    source_types: Option<SourceType>,
96}
97
98impl ScreenCast {
99    /// Create a new ScreenCast Session
100    ///
101    /// Connects to D-Bus and initaialises a ScreenCast object.
102    pub fn new() -> Result<Self, PortalError> {
103        let state = ConnectionState::open_new()?;
104
105        let session = {
106            let request = Request::with_handler(&state, |a| {
107                a.results
108                    .get("session_handle")
109                    .unwrap()
110                    .as_str()
111                    .unwrap()
112                    .to_owned()
113            })?;
114            // Make the initail call to open the session.
115            let mut session_args = HashMap::<String, Variant<Box<dyn RefArg>>>::new();
116            session_args.insert(
117                "handle_token".into(),
118                Variant(Box::new(String::from(&request.handle))),
119            );
120            session_args.insert(
121                "session_handle_token".into(),
122                Variant(Box::new(String::from(&request.handle))),
123            );
124            state.desktop_proxy().create_session(session_args)?;
125            request.wait_response()?
126        };
127
128        Ok(ScreenCast {
129            state,
130            session,
131            multiple: false,
132            source_types: None,
133        })
134    }
135
136    /// Get the supported source types for this connection
137    pub fn source_types(&self) -> Result<SourceType, PortalError> {
138        let types = self.state.desktop_proxy().available_source_types()?;
139        Ok(SourceType::from_bits_truncate(types))
140    }
141
142    /// Set the source types to capture. This should be a subset of
143    /// those from `source_types()`.
144    pub fn set_source_types(&mut self, types: SourceType) {
145        self.source_types = Some(types);
146    }
147
148    /// Enable multi-stream selection. This allows the user to choose more than
149    /// one thing to share. Each will be a separate item in the
150    /// `ActiveScreenCast::streams()` iterator.
151    pub fn enable_multiple(&mut self) {
152        self.multiple = true;
153    }
154
155    /// Try to start the screen cast. This will prompt the user to select a
156    /// source to share.
157    pub fn start(self, parent_window: Option<&str>) -> Result<ActiveScreenCast, PortalError> {
158        let desktop_proxy = self.state.desktop_proxy();
159
160        {
161            let request = Request::new(&self.state)?;
162            let session = dbus::Path::from(&self.session);
163            let mut select_args = HashMap::<String, Variant<Box<dyn RefArg>>>::new();
164            select_args.insert(
165                "handle_token".into(),
166                Variant(Box::new(String::from(&request.handle))),
167            );
168            select_args.insert(
169                "types".into(),
170                Variant(Box::new(match self.source_types {
171                    Some(types) => types.bits(),
172                    None => desktop_proxy.available_source_types()?,
173                })),
174            );
175            select_args.insert("multiple".into(), Variant(Box::new(self.multiple)));
176            desktop_proxy.select_sources(session, select_args)?;
177            request.wait_response()?;
178        }
179
180        let streams = {
181            let request = Request::with_handler(&self.state, |response| {
182                if response.response != 0 {
183                    return Err(PortalError::Cancelled);
184                }
185                match response.results.get("streams") {
186                    Some(streams) => match streams.as_iter() {
187                        Some(streams) => streams
188                            .flat_map(|s| {
189                                s.as_iter()
190                                    .into_iter()
191                                    .flat_map(|t| t.map(|u| u.try_into()))
192                            })
193                            .collect(),
194                        None => Err(PortalError::Parse),
195                    },
196                    None => Err(PortalError::Parse),
197                }
198            })?;
199            let session = dbus::Path::from(&self.session);
200            let mut select_args = HashMap::<String, Variant<Box<dyn RefArg>>>::new();
201            select_args.insert(
202                "handle_token".into(),
203                Variant(Box::new(String::from(&request.handle))),
204            );
205            desktop_proxy.start(session, parent_window.unwrap_or(""), select_args)?;
206            request.wait_response()?
207        }?;
208
209        let pipewire_fd =
210            desktop_proxy.open_pipe_wire_remote(dbus::Path::from(&self.session), HashMap::new())?;
211
212        Ok(ActiveScreenCast {
213            state: self.state,
214            session_path: self.session,
215            pipewire_fd,
216            streams,
217        })
218    }
219}
220
221/// An active ScreenCast session. This holds a file descriptor for connecting
222/// to PipeWire along with metadata for the active streams.
223pub struct ActiveScreenCast {
224    state: ConnectionState,
225    session_path: String,
226    pipewire_fd: OwnedFd,
227    streams: Vec<ScreenCastStream>,
228}
229
230impl ActiveScreenCast {
231    /// Get the fille descriptor for the PipeWire session.
232    pub fn pipewire_fd(&self) -> RawFd {
233        self.pipewire_fd.clone().into_fd()
234    }
235
236    /// Get the streams active in this ScreenCast.
237    pub fn streams(&self) -> impl Iterator<Item = &ScreenCastStream> {
238        self.streams.iter()
239    }
240
241    /// Close the ScreenCast session. This ends the cast.
242    pub fn close(&self) -> Result<(), PortalError> {
243        // Open a handle to the active session, and close it.
244        let session = Session::open(&self.state, &self.session_path)?;
245        session.close()?;
246        Ok(())
247    }
248}
249
250impl std::ops::Drop for ActiveScreenCast {
251    fn drop(&mut self) {
252        let _ = self.close();
253    }
254}
255
256#[derive(Debug)]
257pub struct ScreenCastStream {
258    pipewire_node: u32,
259    // TODO: other stream metadata.
260}
261
262impl ScreenCastStream {
263    /// Get the PipeWire node ID for this stream.
264    pub fn pipewire_node(&self) -> u32 {
265        self.pipewire_node
266    }
267}
268
269impl std::convert::TryFrom<&dyn RefArg> for ScreenCastStream {
270    type Error = PortalError;
271
272    fn try_from(value: &dyn RefArg) -> Result<Self, Self::Error> {
273        let mut parts_iter = value.as_iter().ok_or(PortalError::Parse)?;
274        let node_id = parts_iter
275            .next()
276            .and_then(|r| r.as_u64())
277            .map(|r| r as u32)
278            .ok_or(PortalError::Parse)?;
279        // TODO: parse other metdata here.
280        Ok(ScreenCastStream {
281            pipewire_node: node_id,
282        })
283    }
284}
285
286bitflags! {
287    pub struct SourceType : u32  {
288        const MONITOR = 0b00001;
289        const WINDOW = 0b00010;
290    }
291}
292
293// - - - - - - - - - - - - - -  Private Implementation - - - - - - - - - - - -
294
295/// D-Bus connection state. Used to access the Desktop portal
296/// and open our screencast.
297struct ConnectionState {
298    connection: Connection,
299    sender_token: String,
300}
301
302impl ConnectionState {
303    /// Open a new D-Bus connection to use for all our requests
304    pub fn open_new() -> Result<Self, dbus::Error> {
305        // Create a new session and work out our session's sender token. Portal
306        // requests will send responses to paths based on this token.
307        let connection = Connection::new_session()?;
308        let sender_token = String::from(&connection.unique_name().replace(".", "_")[1..]);
309        Ok(ConnectionState {
310            connection,
311            sender_token,
312        })
313    }
314
315    /// Create a proxy to the main desktop portal object
316    pub fn desktop_proxy(&self) -> Proxy<&Connection> {
317        self.connection.with_proxy(
318            "org.freedesktop.portal.Desktop",
319            "/org/freedesktop/portal/desktop",
320            Duration::from_secs(20),
321        )
322    }
323}
324
325/// A request object. Portal requests are used to wait for responses to ongoing
326/// portal operations.
327struct Request<'a, Response> {
328    /// A proxy connected to this reuqest object on the bus.
329    proxy: Proxy<'a, &'a Connection>,
330    /// The handle for this request.
331    handle: String,
332    /// The channel reciever that we can read responses from.
333    response: Receiver<Response>,
334    /// The match token to remove our D-Bus matcher.
335    match_token: Token,
336}
337
338impl<'a> Request<'a, ()> {
339    /// Create a new request object with the given connection. This generates
340    /// a random token for the handle.
341    pub fn new(state: &'a ConnectionState) -> Result<Self, PortalError> {
342        Self::with_handler(state, |_| {})
343    }
344}
345
346impl<'a, Response> Request<'a, Response> {
347    /// Create a new request object with the given connection and handler. This
348    /// generates a random token for the handle. The results of the handler can
349    /// be retrieved by calling `wait_result()`.
350    pub fn with_handler<ResponseHandler>(
351        state: &'a ConnectionState,
352        mut on_response: ResponseHandler,
353    ) -> Result<Self, PortalError>
354    where
355        ResponseHandler: FnMut(OrgFreedesktopPortalRequestResponse) -> Response + Send + 'static,
356        Response: Send + 'static,
357    {
358        let handle = format!("screencap{0}", rand::random::<usize>());
359        let resp_path = Path::new(format!(
360            "/org/freedesktop/portal/desktop/request/{0}/{1}",
361            state.sender_token, handle
362        ))?;
363        let proxy = state.connection.with_proxy(
364            "org.freedesktop.portal.Desktop",
365            resp_path,
366            Duration::from_secs(20),
367        );
368        let (sender, response) = mpsc::channel();
369        let match_token = proxy.match_signal(
370            move |a: OrgFreedesktopPortalRequestResponse, _: &Connection, _: &Message| {
371                // FIXME: handle error responses here somehow? Currently it is
372                //        just up to the `on_response` to deal with it.
373                let res = on_response(a);
374                sender.send(res).is_ok()
375            },
376        )?;
377        Ok(Request {
378            proxy,
379            handle,
380            response,
381            match_token,
382        })
383    }
384
385    pub fn wait_response(&self) -> Result<Response, PortalError> {
386        // Pump the event loop until we receive our expected result
387        loop {
388            if let Ok(data) = self.response.try_recv() {
389                return Ok(data);
390            } else {
391                self.proxy.connection.process(Duration::from_millis(100))?;
392            }
393        }
394    }
395}
396
397impl<'a, T> std::ops::Drop for Request<'a, T> {
398    fn drop(&mut self) {
399        let _ = self.proxy.match_stop(self.match_token, true);
400    }
401}
402
403/// A session handle.
404struct Session<'a> {
405    proxy: Proxy<'a, &'a Connection>,
406}
407
408impl<'a> Session<'a> {
409    pub fn open(state: &'a ConnectionState, path: &str) -> Result<Self, PortalError> {
410        let path = dbus::Path::new(path)?;
411        let proxy = state.connection.with_proxy(
412            "org.freedesktop.portal.Desktop",
413            path,
414            Duration::from_secs(20),
415        );
416        Ok(Session { proxy })
417    }
418
419    pub fn close(&self) -> Result<(), PortalError> {
420        self.proxy.close()?;
421        Ok(())
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::SourceType;
428
429    #[test]
430    pub fn check_source_types() {
431        assert_eq!(1, SourceType::MONITOR.bits());
432        assert_eq!(2, SourceType::WINDOW.bits());
433        assert_eq!(3, (SourceType::WINDOW | SourceType::MONITOR).bits());
434    }
435}