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 go to an input thread of the session's own, so no
17//! caller waits on the bus round trip, and it makes each once the one before it is answered: the
18//! frontend handles every method call on a thread pool, so two in flight together reach the
19//! backend in either order (a click's release before its press, which leaves the button held).
20//! Each public call runs its async zbus calls to completion with
21//! [`zbus::block_on`] on the calling thread; between calls zbus drives the connection from a
22//! helper thread of its own, so a `Response` that lands meanwhile is queued for the stream that
23//! subscribed to it. A restore token the backend hands back is kept under the
24//! user's state directory, so a session reopened to change the cursor mode, and every later
25//! start on this machine, needs no second consent; a backend that hands out none (KDE) shows
26//! no dialog to an unsandboxed app in the first place.
27
28use std::collections::HashMap;
29use std::os::fd::OwnedFd;
30use std::pin::Pin;
31use std::sync::atomic::{AtomicU32, Ordering};
32use std::sync::{OnceLock, mpsc};
33
34use zbus::export::futures_core::Stream;
35use zbus::{Connection, OwnedObjectPath, OwnedVariant, Proxy, Variant};
36
37const DESKTOP: &str = "org.freedesktop.portal.Desktop";
38const DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop";
39const REQUEST_IFACE: &str = "org.freedesktop.portal.Request";
40const SESSION_IFACE: &str = "org.freedesktop.portal.Session";
41const SCREENCAST_IFACE: &str = "org.freedesktop.portal.ScreenCast";
42const REMOTE_DESKTOP_IFACE: &str = "org.freedesktop.portal.RemoteDesktop";
43
44pub const DEVICE_KEYBOARD: u32 = 1;
45pub const DEVICE_POINTER: u32 = 2;
46pub const CURSOR_HIDDEN: u32 = 1;
47pub const CURSOR_EMBEDDED: u32 = 2;
48pub const CURSOR_METADATA: u32 = 4;
49const SOURCE_MONITOR: u32 = 1;
50const PERSIST_UNTIL_REVOKED: u32 = 2;
51
52type Options<'a> = HashMap<&'a str, Variant<'a>>;
53
54/// A `Notify*` method for the input thread, and the call that makes it.
55type NotifyCall<P = Proxy<'static>> = (&'static str, Box<dyn FnOnce(&P) + Send>);
56
57const MOTION_ABSOLUTE: &str = "NotifyPointerMotionAbsolute";
58
59/// One monitor stream the portal started: its PipeWire node and, where the backend says,
60/// its logical position and size in the compositor's coordinate space.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct PortalStream {
63    pub node_id: u32,
64    pub position: Option<(i32, i32)>,
65    pub size: Option<(i32, i32)>,
66}
67
68/// A started portal session: its streams, the devices it may drive, and the token that
69/// restores it without a dialog.
70pub struct PortalSession {
71    conn: Connection,
72    screencast: Proxy<'static>,
73    remote: Option<Proxy<'static>>,
74    session: OwnedObjectPath,
75    pub streams: Vec<PortalStream>,
76    pub devices: u32,
77    pub cursor_mode: u32,
78    pub restore_token: Option<String>,
79    /// RemoteDesktop interface version, 0 when the session drives no input. `ConnectToEIS`
80    /// exists from version 2.
81    pub rd_version: u32,
82    /// KDE's backend serves the session: it reads a smooth scroll's vertical axis negated.
83    kde: bool,
84    /// Mutter's remote-desktop service runs on the session bus, which every GNOME backend hands
85    /// input to: it reads `NotifyPointerMotionAbsolute` in the stream's pixels, which
86    /// xdg-desktop-portal-gnome passes the spec's logical point on as.
87    pub mutter: bool,
88    /// Whether dropping the session closes it on the portal: not while `open` still runs it
89    /// inside its `zbus::block_on`, which a second one inside would panic on.
90    armed: bool,
91    /// The input thread's queue, from the first `Notify*` call on.
92    notify_tx: OnceLock<mpsc::Sender<NotifyCall>>,
93}
94
95fn opt_u32(o: &mut Options<'_>, key: &'static str, v: u32) {
96    o.insert(key, Variant::U32(v));
97}
98
99/// Whether `path` is absolute and free of `..`: the XDG Base Directory specification has a
100/// relative path in its variables ignored.
101fn sound_path(path: &std::path::Path) -> bool {
102    path.is_absolute()
103        && !path
104            .components()
105            .any(|c| c == std::path::Component::ParentDir)
106}
107
108/// Where the portal's restore token lives between runs: `$XDG_STATE_HOME/pixelflux`, or
109/// `~/.local/state/pixelflux` where that is unset or not absolute.
110fn token_path() -> Option<std::path::PathBuf> {
111    let base = match std::env::var_os("XDG_STATE_HOME").map(std::path::PathBuf::from) {
112        Some(dir) if sound_path(&dir) => dir,
113        _ => std::path::PathBuf::from(std::env::var_os("HOME")?).join(".local/state"),
114    };
115    Some(base.join("pixelflux/portal-restore-token"))
116}
117
118/// The restore token saved by an earlier session, if any.
119pub fn saved_restore_token() -> Option<String> {
120    let path = token_path()?;
121    if !sound_path(&path) {
122        return None;
123    }
124    let token = std::fs::read_to_string(path).ok()?;
125    let token = token.trim();
126    (!token.is_empty()).then(|| token.to_string())
127}
128
129/// Keep a restore token for the next session (a token is single-use, so the newest replaces
130/// the one that opened this session).
131pub fn save_restore_token(token: &str) {
132    let Some(path) = token_path() else { return };
133    if !sound_path(&path) {
134        return;
135    }
136    if let Some(dir) = path.parent() {
137        let _ = std::fs::create_dir_all(dir);
138    }
139    if let Err(e) = std::fs::write(&path, token) {
140        eprintln!(
141            "[HostCapture] portal restore token not saved to {}: {e}",
142            path.display()
143        );
144    }
145}
146
147fn value_i32(v: &Variant<'_>) -> Option<i32> {
148    match v {
149        Variant::I32(x) => Some(*x),
150        Variant::U32(x) => Some(*x as i32),
151        Variant::Variant(inner) => value_i32(inner),
152        _ => None,
153    }
154}
155
156fn value_pair(v: &Variant<'_>) -> Option<(i32, i32)> {
157    match v {
158        Variant::Structure(s) if s.fields().len() == 2 => {
159            Some((value_i32(&s.fields()[0])?, value_i32(&s.fields()[1])?))
160        }
161        Variant::Variant(inner) => value_pair(inner),
162        _ => None,
163    }
164}
165
166/// The `a(ua{sv})` stream list of a `Start` response.
167fn parse_streams(v: &Variant<'_>) -> Vec<PortalStream> {
168    let mut out = Vec::new();
169    let Variant::Array(arr) = v else { return out };
170    for item in arr.iter() {
171        let Variant::Structure(s) = item else {
172            continue;
173        };
174        let fields = s.fields();
175        let Some(Variant::U32(node_id)) = fields.first() else {
176            continue;
177        };
178        let mut stream = PortalStream {
179            node_id: *node_id,
180            position: None,
181            size: None,
182        };
183        if let Some(Variant::Dict(d)) = fields.get(1) {
184            for (k, val) in d.iter() {
185                let Variant::Str(k) = k else { continue };
186                match k.as_str() {
187                    "position" => stream.position = value_pair(val),
188                    "size" => stream.size = value_pair(val),
189                    _ => {}
190                }
191            }
192        }
193        out.push(stream);
194    }
195    out
196}
197
198/// The next message of `stream`, or `None` once it ends.
199async fn next_message<S: Stream + Unpin>(stream: &mut S) -> Option<S::Item> {
200    std::future::poll_fn(|cx| Pin::new(&mut *stream).poll_next(cx)).await
201}
202
203/// Whether `name` has an owner on the bus `conn` is on.
204async fn name_owned(conn: &Connection, name: &str) -> bool {
205    let Ok(bus) = zbus::fdo::DBusProxy::new(conn).await else {
206        return false;
207    };
208    let Ok(name) = zbus::names::BusName::try_from(name) else {
209        return false;
210    };
211    bus.name_has_owner(name).await.unwrap_or(false)
212}
213
214impl PortalSession {
215    /// Open and start a session. `devices` (`DEVICE_*` bits) selects what input the session
216    /// drives — zero opens a plain ScreenCast session; `capture` asks for every monitor as its
217    /// own stream with `cursor_mode` (`CURSOR_*`), downgraded to what the portal offers. The
218    /// call returns once the portal (and any dialog it shows) has answered; a session the
219    /// portal created before a later step failed is closed before the error returns.
220    pub fn open(
221        capture: bool,
222        devices: u32,
223        cursor_mode: u32,
224        restore_token: Option<&str>,
225    ) -> Result<Self, String> {
226        zbus::block_on(async {
227            let mut this = Self::connect(devices, cursor_mode).await?;
228            if let Err(e) = this.start(capture, devices, restore_token).await {
229                this.close().await;
230                return Err(e);
231            }
232            this.armed = true;
233            Ok(this)
234        })
235    }
236
237    /// The session bus and the portal interfaces a session with `devices` speaks to, before
238    /// any session exists.
239    async fn connect(devices: u32, cursor_mode: u32) -> Result<Self, String> {
240        let conn = Connection::session()
241            .await
242            .map_err(|e| format!("session bus: {e}"))?;
243        let screencast = Proxy::new(&conn, DESKTOP, DESKTOP_PATH, SCREENCAST_IFACE)
244            .await
245            .map_err(|e| e.to_string())?;
246        let remote = if devices != 0 {
247            Some(
248                Proxy::new(&conn, DESKTOP, DESKTOP_PATH, REMOTE_DESKTOP_IFACE)
249                    .await
250                    .map_err(|e| e.to_string())?,
251            )
252        } else {
253            None
254        };
255        Ok(Self {
256            conn,
257            screencast,
258            remote,
259            session: OwnedObjectPath::default(),
260            streams: Vec::new(),
261            devices: 0,
262            cursor_mode,
263            restore_token: None,
264            rd_version: 0,
265            kde: false,
266            mutter: false,
267            armed: false,
268            notify_tx: OnceLock::new(),
269        })
270    }
271
272    /// Create the session, select its sources and devices, and start it (`open`).
273    async fn start(
274        &mut self,
275        capture: bool,
276        devices: u32,
277        restore_token: Option<&str>,
278    ) -> Result<(), String> {
279        let sc_version: u32 = self
280            .screencast
281            .get_property("version")
282            .await
283            .map_err(|e| format!("no ScreenCast portal: {e}"))?;
284        let opener = self
285            .remote
286            .clone()
287            .unwrap_or_else(|| self.screencast.clone());
288        let rd_version: u32 = match &self.remote {
289            Some(rd) => rd
290                .get_property("version")
291                .await
292                .map_err(|e| format!("no RemoteDesktop portal: {e}"))?,
293            None => 0,
294        };
295        self.rd_version = rd_version;
296        let mut options = Options::new();
297        options.insert(
298            "session_handle_token",
299            Variant::from(format!("pixelflux{}", std::process::id())),
300        );
301        let (_, results) = self
302            .request("CreateSession", options, async |o| {
303                opener.call("CreateSession", &(o,)).await
304            })
305            .await?;
306        let session = results
307            .get("session_handle")
308            .and_then(|v| match &**v {
309                Variant::Str(s) => OwnedObjectPath::try_from(s.as_str()).ok(),
310                Variant::ObjectPath(p) => Some(p.clone().into()),
311                _ => None,
312            })
313            .ok_or("CreateSession returned no session handle")?;
314        self.session = session;
315
316        // Persistence rides SelectDevices on a remote-desktop session and SelectSources on a
317        // plain screencast one; a backend without the option ignores it.
318        let persist_on_sources = self.remote.is_none() && sc_version >= 4;
319        let persist_on_devices = self.remote.is_some() && rd_version >= 2;
320        if capture {
321            let available: u32 = self
322                .screencast
323                .get_property("AvailableCursorModes")
324                .await
325                .unwrap_or(CURSOR_EMBEDDED);
326            if available & self.cursor_mode == 0 {
327                self.cursor_mode = [CURSOR_EMBEDDED, CURSOR_HIDDEN, CURSOR_METADATA]
328                    .into_iter()
329                    .find(|m| available & m != 0)
330                    .unwrap_or(CURSOR_EMBEDDED);
331            }
332            let mut options = Options::new();
333            opt_u32(&mut options, "types", SOURCE_MONITOR);
334            options.insert("multiple", Variant::Bool(true));
335            if sc_version >= 2 {
336                opt_u32(&mut options, "cursor_mode", self.cursor_mode);
337            }
338            if persist_on_sources {
339                opt_u32(&mut options, "persist_mode", PERSIST_UNTIL_REVOKED);
340                if let Some(t) = restore_token {
341                    options.insert("restore_token", Variant::from(t.to_string()));
342                }
343            }
344            let (sc, session) = (self.screencast.clone(), self.session.clone());
345            self.request("SelectSources", options, async |o| {
346                sc.call("SelectSources", &(&session, o)).await
347            })
348            .await?;
349        }
350        if let Some(rd) = self.remote.clone() {
351            let available: u32 = rd
352                .get_property("AvailableDeviceTypes")
353                .await
354                .unwrap_or(DEVICE_KEYBOARD | DEVICE_POINTER);
355            let mut options = Options::new();
356            opt_u32(&mut options, "types", devices & available);
357            if persist_on_devices {
358                opt_u32(&mut options, "persist_mode", PERSIST_UNTIL_REVOKED);
359                if let Some(t) = restore_token {
360                    options.insert("restore_token", Variant::from(t.to_string()));
361                }
362            }
363            let session = self.session.clone();
364            self.request("SelectDevices", options, async |o| {
365                rd.call("SelectDevices", &(&session, o)).await
366            })
367            .await?;
368        }
369        let session = self.session.clone();
370        let (_, results) = self
371            .request("Start", Options::new(), async |o| {
372                opener.call("Start", &(&session, "", o)).await
373            })
374            .await?;
375        if let Some(v) = results.get("streams") {
376            self.streams = parse_streams(v);
377        }
378        if let Some(Variant::U32(d)) = results.get("devices").map(|v| &**v) {
379            self.devices = *d;
380        }
381        if let Some(Variant::Str(t)) = results.get("restore_token").map(|v| &**v) {
382            self.restore_token = Some(t.to_string());
383            save_restore_token(t);
384        }
385        if capture && self.streams.is_empty() {
386            return Err("the portal started no monitor stream".into());
387        }
388        // The backend has answered Start, so it runs by now; the frontend names none.
389        self.kde = self.remote.is_some()
390            && name_owned(&self.conn, "org.freedesktop.impl.portal.desktop.kde").await;
391        self.mutter =
392            self.remote.is_some() && name_owned(&self.conn, "org.gnome.Mutter.RemoteDesktop").await;
393        Ok(())
394    }
395
396    /// Whether a ScreenCast portal answers on the session bus; the error says which step
397    /// did not, since a bus nobody listens on and a portal without a backend read the same.
398    pub fn probe() -> Result<(), String> {
399        zbus::block_on(async {
400            let conn = Connection::session()
401                .await
402                .map_err(|e| format!("session bus: {e}"))?;
403            let screencast = Proxy::new(&conn, DESKTOP, DESKTOP_PATH, SCREENCAST_IFACE)
404                .await
405                .map_err(|e| e.to_string())?;
406            screencast
407                .get_property::<u32>("version")
408                .await
409                .map(|_| ())
410                .map_err(|e| format!("no ScreenCast portal: {e}"))
411        })
412    }
413
414    /// A method whose result arrives as a `Response` on a request object: subscribe to the
415    /// request path the handle token names before the call `call` makes with the completed
416    /// options, then wait for the answer.
417    async fn request(
418        &self,
419        method: &str,
420        mut options: Options<'_>,
421        call: impl AsyncFnOnce(&Options<'_>) -> zbus::Result<OwnedObjectPath>,
422    ) -> Result<(u32, HashMap<String, OwnedVariant>), String> {
423        static COUNTER: AtomicU32 = AtomicU32::new(1);
424        let token = format!(
425            "pixelflux{}_{}",
426            std::process::id(),
427            COUNTER.fetch_add(1, Ordering::Relaxed)
428        );
429        let sender = self
430            .conn
431            .unique_name()
432            .ok_or("bus connection has no unique name")?
433            .as_str()
434            .to_string();
435        let path = request_path(&sender, &token);
436        options.insert("handle_token", Variant::from(token.clone()));
437        let request = Proxy::new(&self.conn, DESKTOP, path.as_str(), REQUEST_IFACE)
438            .await
439            .map_err(|e| e.to_string())?;
440        let mut responses = request
441            .receive_signal("Response")
442            .await
443            .map_err(|e| e.to_string())?;
444        let handle = call(&options).await.map_err(|e| format!("{method}: {e}"))?;
445        if handle.as_str() != path {
446            // A portal predating handle tokens answers on the path it returned.
447            let request = Proxy::new(&self.conn, DESKTOP, handle.as_str(), REQUEST_IFACE)
448                .await
449                .map_err(|e| e.to_string())?;
450            responses = request
451                .receive_signal("Response")
452                .await
453                .map_err(|e| e.to_string())?;
454        }
455        let msg = next_message(&mut responses)
456            .await
457            .ok_or_else(|| format!("{method}: the request closed without a response"))?;
458        let (code, results): (u32, HashMap<String, OwnedVariant>) = msg
459            .body()
460            .deserialize()
461            .map_err(|e| format!("{method}: {e}"))?;
462        match code {
463            0 => Ok((code, results)),
464            1 => Err(format!("{method}: the user canceled the portal dialog")),
465            _ => Err(format!("{method}: the portal refused (response {code})")),
466        }
467    }
468
469    /// Whether this session can hand out an EIS socket: a RemoteDesktop session (input was
470    /// granted) on a backend new enough to carry `ConnectToEIS`.
471    pub fn eis_capable(&self) -> bool {
472        self.remote.is_some() && self.devices != 0 && self.rd_version >= 2
473    }
474
475    /// The EIS socket for this session's seat. A backend that answers hands input over libei
476    /// from here on and refuses the `Notify*` methods, so this is called once, at open, and
477    /// only when the injector is committed to.
478    pub fn connect_to_eis(&self) -> Result<OwnedFd, String> {
479        let rd = self.remote.as_ref().ok_or("session drives no input")?;
480        let fd: zbus::OwnedFd =
481            zbus::block_on(rd.call("ConnectToEIS", &(self.session.clone(), Options::new())))
482                .map_err(|e| format!("ConnectToEIS: {e}"))?;
483        Ok(fd.into())
484    }
485
486    /// The PipeWire connection the streams live on, opened by the portal with the node
487    /// permissions of this session.
488    pub fn open_pipewire_remote(&self) -> Result<OwnedFd, String> {
489        let fd: zbus::OwnedFd = zbus::block_on(self.screencast.call(
490            "OpenPipeWireRemote",
491            &(self.session.clone(), Options::new()),
492        ))
493        .map_err(|e| format!("OpenPipeWireRemote: {e}"))?;
494        Ok(fd.into())
495    }
496
497    /// Queue a `Notify*` call for the input thread, started by the first.
498    fn notify<B>(&self, method: &'static str, args: B)
499    where
500        B: serde::Serialize + zbus::DynamicType + Send + 'static,
501    {
502        let Some(rd) = &self.remote else { return };
503        let tx = self.notify_tx.get_or_init(|| {
504            let (tx, rx) = mpsc::channel();
505            let rd = rd.clone();
506            let _ = std::thread::Builder::new()
507                .name("pf-portal-input".into())
508                .spawn(move || notify_loop(rd, rx));
509            tx
510        });
511        let _ = tx.send((
512            method,
513            Box::new(move |rd: &Proxy<'static>| {
514                let _ = zbus::block_on(rd.call_method(method, &args));
515            }),
516        ));
517    }
518
519    /// Absolute pointer motion in the logical coordinate space of the stream `node`, the `size`
520    /// it announced: the portal frontend refuses a point outside that size.
521    pub fn pointer_motion_abs(&self, node: u32, x: f64, y: f64) {
522        self.notify(
523            MOTION_ABSOLUTE,
524            (self.session.clone(), Options::new(), node, x, y),
525        );
526    }
527
528    pub fn pointer_motion(&self, dx: f64, dy: f64) {
529        self.notify(
530            "NotifyPointerMotion",
531            (self.session.clone(), Options::new(), dx, dy),
532        );
533    }
534
535    /// Button by evdev code.
536    pub fn pointer_button(&self, button: i32, pressed: bool) {
537        self.notify(
538            "NotifyPointerButton",
539            (
540                self.session.clone(),
541                Options::new(),
542                button,
543                u32::from(pressed),
544            ),
545        );
546    }
547
548    /// Smooth scroll by logical pixels, positive down and right as on `wl_pointer`; `finish`
549    /// closes the scroll series. KDE's backend negates the vertical axis it is handed and drops
550    /// `finish` (xdg-desktop-portal-kde's `requestPointerAxis`, 5.27 through master), where
551    /// GNOME's passes both on, so it is handed the axis negated.
552    pub fn pointer_axis(&self, dx: f64, dy: f64, finish: bool) {
553        let mut options = Options::new();
554        options.insert("finish", Variant::Bool(finish));
555        let dy = if self.kde { -dy } else { dy };
556        self.notify("NotifyPointerAxis", (self.session.clone(), options, dx, dy));
557    }
558
559    /// Wheel steps: `axis` 0 vertical, 1 horizontal.
560    pub fn pointer_axis_discrete(&self, axis: u32, steps: i32) {
561        self.notify(
562            "NotifyPointerAxisDiscrete",
563            (self.session.clone(), Options::new(), axis, steps),
564        );
565    }
566
567    pub fn keysym(&self, keysym: i32, pressed: bool) {
568        self.notify(
569            "NotifyKeyboardKeysym",
570            (
571                self.session.clone(),
572                Options::new(),
573                keysym,
574                u32::from(pressed),
575            ),
576        );
577    }
578
579    /// Key by evdev keycode, in the host's own layout.
580    pub fn keycode(&self, keycode: i32, pressed: bool) {
581        self.notify(
582            "NotifyKeyboardKeycode",
583            (
584                self.session.clone(),
585                Options::new(),
586                keycode,
587                u32::from(pressed),
588            ),
589        );
590    }
591}
592
593impl PortalSession {
594    /// Close the session on the portal, if one was created.
595    async fn close(&self) {
596        if let Ok(session) =
597            Proxy::new(&self.conn, DESKTOP, self.session.as_str(), SESSION_IFACE).await
598        {
599            let _ = session.call_noreply("Close", &()).await;
600        }
601    }
602}
603
604impl Drop for PortalSession {
605    fn drop(&mut self) {
606        if self.armed {
607            zbus::block_on(self.close());
608        }
609    }
610}
611
612/// The input thread: each `Notify*` call once the one before it is answered, an absolute motion
613/// with another queued behind it left out; it ends with the session's queue.
614fn notify_loop<P>(rd: P, rx: mpsc::Receiver<NotifyCall<P>>) {
615    let mut next = rx.recv().ok();
616    while let Some((method, call)) = next.take() {
617        next = rx.try_recv().ok();
618        if method == MOTION_ABSOLUTE && next.as_ref().is_some_and(|(m, _)| *m == MOTION_ABSOLUTE) {
619            continue;
620        }
621        call(&rd);
622        if next.is_none() {
623            next = rx.recv().ok();
624        }
625    }
626}
627
628/// The request object the portal answers a call from `sender` with `token` on.
629fn request_path(sender: &str, token: &str) -> String {
630    format!(
631        "{DESKTOP_PATH}/request/{}/{token}",
632        sender.trim_start_matches(':').replace('.', "_")
633    )
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use zbus::Type as _;
640    use zbus::wire::StructureBuilder;
641    use zbus::{Array, Dict, Structure};
642
643    fn stream_value(node: u32, props: Vec<(&'static str, Variant<'static>)>) -> Variant<'static> {
644        let mut dict = Dict::new(<&str>::SIGNATURE, Variant::SIGNATURE);
645        for (k, v) in props {
646            dict.append(Variant::from(k), Variant::Variant(Box::new(v)))
647                .unwrap();
648        }
649        let st: Structure = StructureBuilder::new()
650            .add_field(node)
651            .append_field(Variant::Dict(dict))
652            .build()
653            .unwrap();
654        Variant::Structure(st)
655    }
656
657    /// A `Start` response's stream list parses into node ids with the optional geometry, as
658    /// KDE (size only) and GNOME (position and size) send it.
659    #[test]
660    fn streams_parse_with_and_without_geometry() {
661        let kde = stream_value(
662            113,
663            vec![
664                ("size", Variant::from((1280i32, 720i32))),
665                ("source_type", Variant::U32(1)),
666            ],
667        );
668        let gnome = stream_value(
669            42,
670            vec![
671                ("position", Variant::from((1920i32, 0i32))),
672                ("size", Variant::from((1280i32, 720i32))),
673            ],
674        );
675        let mut arr = Array::new(&kde.value_signature().clone());
676        arr.append(kde).unwrap();
677        arr.append(gnome).unwrap();
678        let streams = parse_streams(&Variant::Array(arr));
679        assert_eq!(
680            streams,
681            vec![
682                PortalStream {
683                    node_id: 113,
684                    position: None,
685                    size: Some((1280, 720))
686                },
687                PortalStream {
688                    node_id: 42,
689                    position: Some((1920, 0)),
690                    size: Some((1280, 720))
691                },
692            ]
693        );
694    }
695
696    /// The request path follows the handle-token convention: the sender's unique name with
697    /// its colon dropped and dots turned to underscores, then the token.
698    #[test]
699    fn request_path_follows_the_handle_token_convention() {
700        assert_eq!(
701            request_path(":1.42", "pixelflux7_3"),
702            "/org/freedesktop/portal/desktop/request/1_42/pixelflux7_3"
703        );
704    }
705
706    /// The input thread makes its calls in the order queued, an absolute motion with another
707    /// behind it left out, and ends once the session's queue is gone.
708    #[test]
709    fn notify_calls_keep_their_order_and_runs_of_motion_end_at_their_last() {
710        type Seen = std::rc::Rc<std::cell::RefCell<Vec<&'static str>>>;
711        let (tx, rx) = mpsc::channel::<NotifyCall<Seen>>();
712        for (method, tag) in [
713            (MOTION_ABSOLUTE, "motion 1"),
714            (MOTION_ABSOLUTE, "motion 2"),
715            ("NotifyPointerButton", "press"),
716            ("NotifyPointerButton", "release"),
717            (MOTION_ABSOLUTE, "motion 3"),
718            ("NotifyPointerMotion", "relative 1"),
719            ("NotifyPointerMotion", "relative 2"),
720            (MOTION_ABSOLUTE, "motion 4"),
721        ] {
722            let call = move |seen: &Seen| seen.borrow_mut().push(tag);
723            tx.send((method, Box::new(call))).unwrap();
724        }
725        drop(tx);
726        let seen = Seen::default();
727        notify_loop(seen.clone(), rx);
728        assert_eq!(
729            *seen.borrow(),
730            [
731                "motion 2",
732                "press",
733                "release",
734                "motion 3",
735                "relative 1",
736                "relative 2",
737                "motion 4"
738            ]
739        );
740    }
741}