1use 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
54type NotifyCall<P = Proxy<'static>> = (&'static str, Box<dyn FnOnce(&P) + Send>);
56
57const MOTION_ABSOLUTE: &str = "NotifyPointerMotionAbsolute";
58
59#[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
68pub 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 pub rd_version: u32,
82 kde: bool,
84 pub mutter: bool,
88 armed: bool,
91 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
99fn 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
108fn 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
118pub 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
129pub 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
166fn 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
198async 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
203async 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 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 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 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 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 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 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 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 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 pub fn eis_capable(&self) -> bool {
472 self.remote.is_some() && self.devices != 0 && self.rd_version >= 2
473 }
474
475 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 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 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 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 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 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 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 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 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
612fn 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
628fn 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 #[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 #[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 #[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}