Skip to main content

pixelflux/wayland/
portal.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! xdg-desktop-portal client: one `RemoteDesktop` (or `ScreenCast`) session on the session bus
8//! that hands out the PipeWire streams of the host's monitors and takes input events for its
9//! seat. Compositors without the privileged wlroots protocols (GNOME, KDE) expose both only this
10//! way, through their portal backend, so this is the rung host capture falls to when the registry
11//! lacks capture or virtual-input globals.
12//!
13//! Every portal call that ends in a `Response` signal is made through [`PortalSession::request`],
14//! which subscribes to the request object the portal will answer on before calling, as the
15//! handle-token convention requires; a consent dialog the backend shows blocks that call until
16//! the user answers. Input notifications are fire-and-forget method calls, so an event never
17//! waits on the bus round trip. A restore token the backend hands back is kept under the
18//! user's state directory, so a session reopened to change the cursor mode, and every later
19//! start on this machine, needs no second consent; a backend that hands out none (KDE) shows
20//! no dialog to an unsandboxed app in the first place.
21
22use std::collections::HashMap;
23use std::os::fd::OwnedFd;
24use std::sync::atomic::{AtomicU32, Ordering};
25
26use zbus::blocking::{Connection, Proxy};
27use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
28
29const DESKTOP: &str = "org.freedesktop.portal.Desktop";
30const DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop";
31const REQUEST_IFACE: &str = "org.freedesktop.portal.Request";
32const SESSION_IFACE: &str = "org.freedesktop.portal.Session";
33const SCREENCAST_IFACE: &str = "org.freedesktop.portal.ScreenCast";
34const REMOTE_DESKTOP_IFACE: &str = "org.freedesktop.portal.RemoteDesktop";
35
36pub const DEVICE_KEYBOARD: u32 = 1;
37pub const DEVICE_POINTER: u32 = 2;
38pub const CURSOR_HIDDEN: u32 = 1;
39pub const CURSOR_EMBEDDED: u32 = 2;
40pub const CURSOR_METADATA: u32 = 4;
41const SOURCE_MONITOR: u32 = 1;
42const PERSIST_UNTIL_REVOKED: u32 = 2;
43
44type Options<'a> = HashMap<&'a str, Value<'a>>;
45
46/// One monitor stream the portal started: its PipeWire node and, where the backend says,
47/// its logical position and size in the compositor's coordinate space.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct PortalStream {
50    pub node_id: u32,
51    pub position: Option<(i32, i32)>,
52    pub size: Option<(i32, i32)>,
53}
54
55/// A started portal session: its streams, the devices it may drive, and the token that
56/// restores it without a dialog.
57pub struct PortalSession {
58    conn: Connection,
59    screencast: Proxy<'static>,
60    remote: Option<Proxy<'static>>,
61    session: OwnedObjectPath,
62    pub streams: Vec<PortalStream>,
63    pub devices: u32,
64    pub cursor_mode: u32,
65    pub restore_token: Option<String>,
66    /// RemoteDesktop interface version, 0 when the session drives no input. `ConnectToEIS`
67    /// exists from version 2.
68    pub rd_version: u32,
69    /// KDE's backend serves the session: it reads a smooth scroll's vertical axis negated.
70    kde: bool,
71}
72
73fn opt_u32(o: &mut Options<'_>, key: &'static str, v: u32) {
74    o.insert(key, Value::U32(v));
75}
76
77/// Where the portal's restore token lives between runs: `$XDG_STATE_HOME/pixelflux`, or
78/// `~/.local/state/pixelflux` in its absence.
79fn token_path() -> Option<std::path::PathBuf> {
80    let base = match std::env::var_os("XDG_STATE_HOME") {
81        Some(dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
82        _ => std::path::PathBuf::from(std::env::var_os("HOME")?).join(".local/state"),
83    };
84    Some(base.join("pixelflux/portal-restore-token"))
85}
86
87/// The restore token saved by an earlier session, if any.
88pub fn saved_restore_token() -> Option<String> {
89    let token = std::fs::read_to_string(token_path()?).ok()?;
90    let token = token.trim();
91    (!token.is_empty()).then(|| token.to_string())
92}
93
94/// Keep a restore token for the next session (a token is single-use, so the newest replaces
95/// the one that opened this session).
96pub fn save_restore_token(token: &str) {
97    let Some(path) = token_path() else { return };
98    if let Some(dir) = path.parent() {
99        let _ = std::fs::create_dir_all(dir);
100    }
101    if let Err(e) = std::fs::write(&path, token) {
102        eprintln!(
103            "[HostCapture] portal restore token not saved to {}: {e}",
104            path.display()
105        );
106    }
107}
108
109fn value_i32(v: &Value<'_>) -> Option<i32> {
110    match v {
111        Value::I32(x) => Some(*x),
112        Value::U32(x) => Some(*x as i32),
113        Value::Value(inner) => value_i32(inner),
114        _ => None,
115    }
116}
117
118fn value_pair(v: &Value<'_>) -> Option<(i32, i32)> {
119    match v {
120        Value::Structure(s) if s.fields().len() == 2 => {
121            Some((value_i32(&s.fields()[0])?, value_i32(&s.fields()[1])?))
122        }
123        Value::Value(inner) => value_pair(inner),
124        _ => None,
125    }
126}
127
128/// The `a(ua{sv})` stream list of a `Start` response.
129fn parse_streams(v: &Value<'_>) -> Vec<PortalStream> {
130    let mut out = Vec::new();
131    let Value::Array(arr) = v else { return out };
132    for item in arr.iter() {
133        let Value::Structure(s) = item else { continue };
134        let fields = s.fields();
135        let Some(Value::U32(node_id)) = fields.first() else {
136            continue;
137        };
138        let mut stream = PortalStream {
139            node_id: *node_id,
140            position: None,
141            size: None,
142        };
143        if let Some(Value::Dict(d)) = fields.get(1) {
144            for (k, val) in d.iter() {
145                let Value::Str(k) = k else { continue };
146                match k.as_str() {
147                    "position" => stream.position = value_pair(val),
148                    "size" => stream.size = value_pair(val),
149                    _ => {}
150                }
151            }
152        }
153        out.push(stream);
154    }
155    out
156}
157
158impl PortalSession {
159    /// Open and start a session. `devices` (`DEVICE_*` bits) selects what input the session
160    /// drives — zero opens a plain ScreenCast session; `capture` asks for every monitor as its
161    /// own stream with `cursor_mode` (`CURSOR_*`), downgraded to what the portal offers. The
162    /// call returns once the portal (and any dialog it shows) has answered.
163    pub fn open(
164        capture: bool,
165        devices: u32,
166        cursor_mode: u32,
167        restore_token: Option<&str>,
168    ) -> Result<Self, String> {
169        let conn = Connection::session().map_err(|e| format!("session bus: {e}"))?;
170        let screencast = Proxy::new(&conn, DESKTOP, DESKTOP_PATH, SCREENCAST_IFACE)
171            .map_err(|e| e.to_string())?;
172        let remote = (devices != 0)
173            .then(|| {
174                Proxy::new(&conn, DESKTOP, DESKTOP_PATH, REMOTE_DESKTOP_IFACE)
175                    .map_err(|e| e.to_string())
176            })
177            .transpose()?;
178        let sc_version: u32 = screencast
179            .get_property("version")
180            .map_err(|e| format!("no ScreenCast portal: {e}"))?;
181        let mut this = Self {
182            conn,
183            screencast,
184            remote,
185            session: OwnedObjectPath::default(),
186            streams: Vec::new(),
187            devices: 0,
188            cursor_mode,
189            restore_token: None,
190            rd_version: 0,
191            kde: false,
192        };
193        let opener = this
194            .remote
195            .clone()
196            .unwrap_or_else(|| this.screencast.clone());
197        let rd_version: u32 = match &this.remote {
198            Some(rd) => rd
199                .get_property("version")
200                .map_err(|e| format!("no RemoteDesktop portal: {e}"))?,
201            None => 0,
202        };
203        this.rd_version = rd_version;
204        let mut options = Options::new();
205        options.insert(
206            "session_handle_token",
207            Value::from(format!("pixelflux{}", std::process::id())),
208        );
209        let (_, results) = this.request("CreateSession", options, |o| {
210            opener.call("CreateSession", &(o,))
211        })?;
212        let session = results
213            .get("session_handle")
214            .and_then(|v| match &**v {
215                Value::Str(s) => OwnedObjectPath::try_from(s.as_str()).ok(),
216                Value::ObjectPath(p) => Some(p.clone().into()),
217                _ => None,
218            })
219            .ok_or("CreateSession returned no session handle")?;
220        this.session = session;
221
222        // Persistence rides SelectDevices on a remote-desktop session and SelectSources on a
223        // plain screencast one; a backend without the option ignores it.
224        let persist_on_sources = this.remote.is_none() && sc_version >= 4;
225        let persist_on_devices = this.remote.is_some() && rd_version >= 2;
226        if capture {
227            let available: u32 = this
228                .screencast
229                .get_property("AvailableCursorModes")
230                .unwrap_or(CURSOR_EMBEDDED);
231            if available & cursor_mode == 0 {
232                this.cursor_mode = [CURSOR_EMBEDDED, CURSOR_HIDDEN, CURSOR_METADATA]
233                    .into_iter()
234                    .find(|m| available & m != 0)
235                    .unwrap_or(CURSOR_EMBEDDED);
236            }
237            let mut options = Options::new();
238            opt_u32(&mut options, "types", SOURCE_MONITOR);
239            options.insert("multiple", Value::Bool(true));
240            if sc_version >= 2 {
241                opt_u32(&mut options, "cursor_mode", this.cursor_mode);
242            }
243            if persist_on_sources {
244                opt_u32(&mut options, "persist_mode", PERSIST_UNTIL_REVOKED);
245                if let Some(t) = restore_token {
246                    options.insert("restore_token", Value::from(t.to_string()));
247                }
248            }
249            let (sc, session) = (this.screencast.clone(), this.session.clone());
250            this.request("SelectSources", options, |o| {
251                sc.call("SelectSources", &(&session, o))
252            })?;
253        }
254        if let Some(rd) = this.remote.clone() {
255            let available: u32 = rd
256                .get_property("AvailableDeviceTypes")
257                .unwrap_or(DEVICE_KEYBOARD | DEVICE_POINTER);
258            let mut options = Options::new();
259            opt_u32(&mut options, "types", devices & available);
260            if persist_on_devices {
261                opt_u32(&mut options, "persist_mode", PERSIST_UNTIL_REVOKED);
262                if let Some(t) = restore_token {
263                    options.insert("restore_token", Value::from(t.to_string()));
264                }
265            }
266            let session = this.session.clone();
267            this.request("SelectDevices", options, |o| {
268                rd.call("SelectDevices", &(&session, o))
269            })?;
270        }
271        let session = this.session.clone();
272        let (_, results) = this.request("Start", Options::new(), |o| {
273            opener.call("Start", &(&session, "", o))
274        })?;
275        if let Some(v) = results.get("streams") {
276            this.streams = parse_streams(v);
277        }
278        if let Some(Value::U32(d)) = results.get("devices").map(|v| &**v) {
279            this.devices = *d;
280        }
281        if let Some(Value::Str(t)) = results.get("restore_token").map(|v| &**v) {
282            this.restore_token = Some(t.to_string());
283            save_restore_token(t);
284        }
285        if capture && this.streams.is_empty() {
286            return Err("the portal started no monitor stream".into());
287        }
288        // The backend has answered Start, so it runs by now; the frontend names none.
289        this.kde = this.remote.is_some()
290            && zbus::blocking::fdo::DBusProxy::new(&this.conn)
291                .and_then(|bus| {
292                    bus.name_has_owner(
293                        "org.freedesktop.impl.portal.desktop.kde"
294                            .try_into()
295                            .map_err(zbus::Error::from)?,
296                    )
297                    .map_err(zbus::Error::from)
298                })
299                .unwrap_or(false);
300        Ok(this)
301    }
302
303    /// Whether a ScreenCast portal answers on the session bus; the error says which step
304    /// did not, since a bus nobody listens on and a portal without a backend read the same.
305    pub fn probe() -> Result<(), String> {
306        let conn = Connection::session().map_err(|e| format!("session bus: {e}"))?;
307        let screencast = Proxy::new(&conn, DESKTOP, DESKTOP_PATH, SCREENCAST_IFACE)
308            .map_err(|e| e.to_string())?;
309        screencast
310            .get_property::<u32>("version")
311            .map(|_| ())
312            .map_err(|e| format!("no ScreenCast portal: {e}"))
313    }
314
315    /// A method whose result arrives as a `Response` on a request object: subscribe to the
316    /// request path the handle token names before the call `call` makes with the completed
317    /// options, then wait for the answer.
318    fn request(
319        &self,
320        method: &str,
321        mut options: Options<'_>,
322        call: impl FnOnce(&Options<'_>) -> zbus::Result<OwnedObjectPath>,
323    ) -> Result<(u32, HashMap<String, OwnedValue>), String> {
324        static COUNTER: AtomicU32 = AtomicU32::new(1);
325        let token = format!(
326            "pixelflux{}_{}",
327            std::process::id(),
328            COUNTER.fetch_add(1, Ordering::Relaxed)
329        );
330        let sender = self
331            .conn
332            .unique_name()
333            .ok_or("bus connection has no unique name")?
334            .as_str()
335            .to_string();
336        let path = request_path(&sender, &token);
337        options.insert("handle_token", Value::from(token.clone()));
338        let request = Proxy::new(&self.conn, DESKTOP, path.as_str(), REQUEST_IFACE)
339            .map_err(|e| e.to_string())?;
340        let mut responses = request
341            .receive_signal("Response")
342            .map_err(|e| e.to_string())?;
343        let handle = call(&options).map_err(|e| format!("{method}: {e}"))?;
344        if handle.as_str() != path {
345            // A portal predating handle tokens answers on the path it returned.
346            let request = Proxy::new(&self.conn, DESKTOP, handle.as_str(), REQUEST_IFACE)
347                .map_err(|e| e.to_string())?;
348            responses = request
349                .receive_signal("Response")
350                .map_err(|e| e.to_string())?;
351        }
352        let msg = responses
353            .next()
354            .ok_or_else(|| format!("{method}: the request closed without a response"))?;
355        let (code, results): (u32, HashMap<String, OwnedValue>) = msg
356            .body()
357            .deserialize()
358            .map_err(|e| format!("{method}: {e}"))?;
359        match code {
360            0 => Ok((code, results)),
361            1 => Err(format!("{method}: the user canceled the portal dialog")),
362            _ => Err(format!("{method}: the portal refused (response {code})")),
363        }
364    }
365
366    /// Whether this session can hand out an EIS socket: a RemoteDesktop session (input was
367    /// granted) on a backend new enough to carry `ConnectToEIS`.
368    pub fn eis_capable(&self) -> bool {
369        self.remote.is_some() && self.devices != 0 && self.rd_version >= 2
370    }
371
372    /// The EIS socket for this session's seat. A backend that answers hands input over libei
373    /// from here on and refuses the `Notify*` methods, so this is called once, at open, and
374    /// only when the injector is committed to.
375    pub fn connect_to_eis(&self) -> Result<OwnedFd, String> {
376        let rd = self.remote.as_ref().ok_or("session drives no input")?;
377        let fd: zbus::zvariant::OwnedFd = rd
378            .call("ConnectToEIS", &(self.session.clone(), Options::new()))
379            .map_err(|e| format!("ConnectToEIS: {e}"))?;
380        Ok(fd.into())
381    }
382
383    /// The PipeWire connection the streams live on, opened by the portal with the node
384    /// permissions of this session.
385    pub fn open_pipewire_remote(&self) -> Result<OwnedFd, String> {
386        let fd: zbus::zvariant::OwnedFd = self
387            .screencast
388            .call(
389                "OpenPipeWireRemote",
390                &(self.session.clone(), Options::new()),
391            )
392            .map_err(|e| format!("OpenPipeWireRemote: {e}"))?;
393        Ok(fd.into())
394    }
395
396    fn notify<B>(&self, method: &str, args: &B)
397    where
398        B: serde::Serialize + zbus::zvariant::DynamicType,
399    {
400        if let Some(rd) = &self.remote {
401            let _ = rd.call_noreply(method, args);
402        }
403    }
404
405    /// Absolute pointer motion in the logical coordinate space of the stream `node`.
406    pub fn pointer_motion_abs(&self, node: u32, x: f64, y: f64) {
407        self.notify(
408            "NotifyPointerMotionAbsolute",
409            &(self.session.clone(), Options::new(), node, x, y),
410        );
411    }
412
413    pub fn pointer_motion(&self, dx: f64, dy: f64) {
414        self.notify(
415            "NotifyPointerMotion",
416            &(self.session.clone(), Options::new(), dx, dy),
417        );
418    }
419
420    /// Button by evdev code.
421    pub fn pointer_button(&self, button: i32, pressed: bool) {
422        self.notify(
423            "NotifyPointerButton",
424            &(self.session.clone(), Options::new(), button, pressed as u32),
425        );
426    }
427
428    /// Smooth scroll by logical pixels, positive down and right as on `wl_pointer`; `finish`
429    /// closes the scroll series. KDE's backend negates the vertical axis it is handed and drops
430    /// `finish` (xdg-desktop-portal-kde's `requestPointerAxis`, 5.27 through master), where
431    /// GNOME's passes both on, so it is handed the axis negated.
432    pub fn pointer_axis(&self, dx: f64, dy: f64, finish: bool) {
433        let mut options = Options::new();
434        options.insert("finish", Value::Bool(finish));
435        let dy = if self.kde { -dy } else { dy };
436        self.notify(
437            "NotifyPointerAxis",
438            &(self.session.clone(), options, dx, dy),
439        );
440    }
441
442    /// Wheel steps: `axis` 0 vertical, 1 horizontal.
443    pub fn pointer_axis_discrete(&self, axis: u32, steps: i32) {
444        self.notify(
445            "NotifyPointerAxisDiscrete",
446            &(self.session.clone(), Options::new(), axis, steps),
447        );
448    }
449
450    pub fn keysym(&self, keysym: i32, pressed: bool) {
451        self.notify(
452            "NotifyKeyboardKeysym",
453            &(self.session.clone(), Options::new(), keysym, pressed as u32),
454        );
455    }
456
457    /// Key by evdev keycode, in the host's own layout.
458    pub fn keycode(&self, keycode: i32, pressed: bool) {
459        self.notify(
460            "NotifyKeyboardKeycode",
461            &(
462                self.session.clone(),
463                Options::new(),
464                keycode,
465                pressed as u32,
466            ),
467        );
468    }
469}
470
471impl Drop for PortalSession {
472    fn drop(&mut self) {
473        if let Ok(session) = Proxy::new(&self.conn, DESKTOP, self.session.as_str(), SESSION_IFACE) {
474            let _ = session.call_noreply("Close", &());
475        }
476    }
477}
478
479/// The request object the portal answers a call from `sender` with `token` on.
480fn request_path(sender: &str, token: &str) -> String {
481    format!(
482        "{DESKTOP_PATH}/request/{}/{token}",
483        sender.trim_start_matches(':').replace('.', "_")
484    )
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use zbus::zvariant::{Array, Dict, Structure, StructureBuilder};
491
492    fn stream_value(node: u32, props: Vec<(&'static str, Value<'static>)>) -> Value<'static> {
493        let mut dict = Dict::new(<&str>::SIGNATURE, Value::SIGNATURE);
494        for (k, v) in props {
495            dict.append(Value::from(k), Value::Value(Box::new(v)))
496                .unwrap();
497        }
498        let st: Structure = StructureBuilder::new()
499            .add_field(node)
500            .append_field(Value::Dict(dict))
501            .build()
502            .unwrap();
503        Value::Structure(st)
504    }
505
506    use zbus::zvariant::Type as _;
507
508    /// A `Start` response's stream list parses into node ids with the optional geometry, as
509    /// KDE (size only) and GNOME (position and size) send it.
510    #[test]
511    fn streams_parse_with_and_without_geometry() {
512        let kde = stream_value(
513            113,
514            vec![
515                ("size", Value::from((1280i32, 720i32))),
516                ("source_type", Value::U32(1)),
517            ],
518        );
519        let gnome = stream_value(
520            42,
521            vec![
522                ("position", Value::from((1920i32, 0i32))),
523                ("size", Value::from((1280i32, 720i32))),
524            ],
525        );
526        let mut arr = Array::new(&kde.value_signature().clone());
527        arr.append(kde).unwrap();
528        arr.append(gnome).unwrap();
529        let streams = parse_streams(&Value::Array(arr));
530        assert_eq!(
531            streams,
532            vec![
533                PortalStream {
534                    node_id: 113,
535                    position: None,
536                    size: Some((1280, 720))
537                },
538                PortalStream {
539                    node_id: 42,
540                    position: Some((1920, 0)),
541                    size: Some((1280, 720))
542                },
543            ]
544        );
545    }
546
547    /// The request path follows the handle-token convention: the sender's unique name with
548    /// its colon dropped and dots turned to underscores, then the token.
549    #[test]
550    fn request_path_follows_the_handle_token_convention() {
551        assert_eq!(
552            request_path(":1.42", "pixelflux7_3"),
553            "/org/freedesktop/portal/desktop/request/1_42/pixelflux7_3"
554        );
555    }
556}