1use 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#[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
55pub 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 pub rd_version: u32,
69 kde: bool,
71}
72
73fn opt_u32(o: &mut Options<'_>, key: &'static str, v: u32) {
74 o.insert(key, Value::U32(v));
75}
76
77fn 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
87pub 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
94pub 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
128fn 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 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 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 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 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 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 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 pub fn eis_capable(&self) -> bool {
369 self.remote.is_some() && self.devices != 0 && self.rd_version >= 2
370 }
371
372 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 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 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 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 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 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 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
479fn 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 #[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 #[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}