@desklink/host 0.5.15 → 0.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -349,104 +349,3 @@ impl Drop for Indicator {
349
349
  }
350
350
  }
351
351
  }
352
-
353
- #[cfg(all(test, target_os = "linux"))]
354
- mod tests {
355
- #[test]
356
- fn cue_feed_keeps_recent_states_until_the_helper_exits() {
357
- use std::os::fd::AsRawFd;
358
- let mut child = std::process::Command::new("sh")
359
- .args([
360
- "-c",
361
- "for x in 1 2 3; do echo \"P $x 0 110 1 1 -1 -1 128 99\"; done; read _",
362
- ])
363
- .stdin(std::process::Stdio::piped())
364
- .stdout(std::process::Stdio::piped())
365
- .spawn()
366
- .unwrap();
367
- let reader = super::BufReader::new(child.stdout.take().unwrap());
368
- let fd = reader.get_ref().as_raw_fd();
369
- unsafe {
370
- libc::fcntl(
371
- fd,
372
- libc::F_SETFL,
373
- libc::fcntl(fd, libc::F_GETFL) | libc::O_NONBLOCK,
374
- )
375
- };
376
- let mut feed = super::CueFeed {
377
- reader: Some(reader),
378
- ..Default::default()
379
- };
380
- let mut drained = vec![];
381
- for _ in 0..200 {
382
- // Each drain re-reports the last two remembered states, so the
383
- // same state can arrive in several drains when the helper's lines
384
- // are split across reads. Keep each state once, in pipe order.
385
- for cue in feed.drain() {
386
- if !drained.contains(&cue.x) {
387
- drained.push(cue.x);
388
- }
389
- }
390
- if drained.len() >= 3 {
391
- break;
392
- }
393
- std::thread::sleep(std::time::Duration::from_millis(10));
394
- }
395
- assert_eq!(
396
- drained,
397
- [1, 2, 3],
398
- "a drain that finds nothing does not block"
399
- );
400
- let xs =
401
- |feed: &mut super::CueFeed| feed.drain().iter().map(|cue| cue.x).collect::<Vec<_>>();
402
- assert_eq!(
403
- xs(&mut feed),
404
- [2, 3],
405
- "either of the last two may be on screen"
406
- );
407
- drop(child.stdin.take());
408
- child.wait().unwrap();
409
- assert_eq!(xs(&mut feed), [2, 3]);
410
- assert!(
411
- xs(&mut feed).is_empty(),
412
- "a closed helper has no window left"
413
- );
414
- }
415
-
416
- unsafe extern "C" {
417
- fn desklink_wayland_geometry(
418
- source_w: i32,
419
- source_h: i32,
420
- surface_w: i32,
421
- surface_h: i32,
422
- px: f64,
423
- py: f64,
424
- x: *mut f64,
425
- y: *mut f64,
426
- ) -> i32;
427
- fn desklink_wayland_policy(
428
- anchor: *mut u32,
429
- keyboard_focus: *mut u32,
430
- empty_input: *mut u32,
431
- );
432
- }
433
-
434
- #[test]
435
- fn layer_shell_maps_source_pixels_and_never_takes_input_or_focus() {
436
- let (mut x, mut y) = (0.0, 0.0);
437
- unsafe {
438
- assert_eq!(
439
- desklink_wayland_geometry(3840, 2160, 1920, 1080, 1000.0, 600.0, &mut x, &mut y),
440
- 1
441
- );
442
- assert_eq!((x, y), (500.0, 300.0));
443
- assert_eq!(
444
- desklink_wayland_geometry(0, 2160, 1920, 1080, 10.0, 10.0, &mut x, &mut y),
445
- 0
446
- );
447
- let (mut anchor, mut keyboard, mut empty) = (0, 1, 0);
448
- desklink_wayland_policy(&mut anchor, &mut keyboard, &mut empty);
449
- assert_eq!((anchor, keyboard, empty), (15, 0, 1));
450
- }
451
- }
452
- }
@@ -0,0 +1,337 @@
1
+ //! Keyboard through the session portal's RemoteDesktop interface.
2
+ //!
3
+ //! Chosen only when the portal itself advertises a keyboard device, so no
4
+ //! host-wide `uinput` device is opened. The portal session is the engine's own:
5
+ //! it carries only the keycodes this session sends, it listens to nothing (this
6
+ //! is the injection interface, not InputCapture, which would read the host
7
+ //! seat), and it is closed when the adapter is dropped with the session.
8
+
9
+ use anyhow::{Context, Result};
10
+ use ashpd::desktop::remote_desktop::{DeviceType, KeyState, RemoteDesktop};
11
+ use ashpd::desktop::PersistMode;
12
+ use tokio::sync::mpsc;
13
+
14
+ pub struct PortalKeys(mpsc::UnboundedSender<(i32, KeyState)>);
15
+
16
+ /// Whether the portal offers a keyboard. Reads one property; creates no
17
+ /// session and cannot prompt.
18
+ pub async fn advertised() -> bool {
19
+ match RemoteDesktop::new().await {
20
+ Ok(proxy) => proxy
21
+ .available_device_types()
22
+ .await
23
+ .is_ok_and(|types| types.contains(DeviceType::Keyboard)),
24
+ Err(_) => false,
25
+ }
26
+ }
27
+
28
+ /// [`advertised`] for the synchronous capabilities report. Inside the serving
29
+ /// runtime it runs there, so ashpd's cached bus connection lives on that
30
+ /// runtime; the one-shot CLI has none and builds a throwaway. A silent bus
31
+ /// counts as no keyboard.
32
+ pub fn advertised_blocking() -> bool {
33
+ let probe = async {
34
+ tokio::time::timeout(std::time::Duration::from_secs(2), advertised())
35
+ .await
36
+ .unwrap_or(false)
37
+ };
38
+ match tokio::runtime::Handle::try_current() {
39
+ Ok(handle) => tokio::task::block_in_place(|| handle.block_on(probe)),
40
+ Err(_) => tokio::runtime::Builder::new_current_thread()
41
+ .enable_all()
42
+ .build()
43
+ .is_ok_and(|runtime| runtime.block_on(probe)),
44
+ }
45
+ }
46
+
47
+ /// Open a keyboard-only RemoteDesktop session. Keys are applied in send order
48
+ /// by one task, which closes the portal session when the adapter is dropped.
49
+ pub async fn open() -> Result<PortalKeys> {
50
+ anyhow::ensure!(advertised().await, "the portal offers no RemoteDesktop keyboard");
51
+ let proxy = RemoteDesktop::new().await?;
52
+ let session = proxy
53
+ .create_session()
54
+ .await
55
+ .context("RemoteDesktop.CreateSession failed")?;
56
+ proxy
57
+ .select_devices(&session, DeviceType::Keyboard.into(), None, PersistMode::DoNot)
58
+ .await
59
+ .context("RemoteDesktop.SelectDevices failed")?
60
+ .response()
61
+ .context("RemoteDesktop.SelectDevices was cancelled")?;
62
+ let granted = proxy
63
+ .start(&session, None)
64
+ .await
65
+ .context("RemoteDesktop.Start failed")?
66
+ .response()
67
+ .context("keyboard access was not granted")?;
68
+ anyhow::ensure!(
69
+ granted.devices().contains(DeviceType::Keyboard),
70
+ "the portal did not grant the keyboard"
71
+ );
72
+
73
+ let (keys, mut queue) = mpsc::unbounded_channel::<(i32, KeyState)>();
74
+ tokio::spawn(async move {
75
+ while let Some((code, state)) = queue.recv().await {
76
+ if let Err(error) = proxy.notify_keyboard_keycode(&session, code, state).await {
77
+ log::warn!("portal keyboard: key {code} refused: {error}");
78
+ }
79
+ }
80
+ let _ = session.close().await;
81
+ });
82
+ Ok(PortalKeys(keys))
83
+ }
84
+
85
+ impl PortalKeys {
86
+ /// `code` is an evdev key code, the portal's own vocabulary.
87
+ pub fn key(&self, code: i16, down: bool) -> Result<()> {
88
+ let state = if down { KeyState::Pressed } else { KeyState::Released };
89
+ self.0
90
+ .send((code as i32, state))
91
+ .map_err(|_| anyhow::anyhow!("the portal keyboard session has ended"))
92
+ }
93
+ }
94
+
95
+ #[cfg(test)]
96
+ mod tests {
97
+ use super::*;
98
+ use std::collections::HashMap;
99
+ use std::process::{Child, Command, Stdio};
100
+ use std::sync::atomic::{AtomicU32, Ordering};
101
+ use std::sync::{Arc, Mutex};
102
+ use zbus::zvariant::{ObjectPath, OwnedValue, Value};
103
+ use zbus::{connection::Builder, interface, Connection};
104
+
105
+ type Log = Arc<Mutex<Vec<String>>>;
106
+
107
+ struct Bus(Child);
108
+
109
+ impl Drop for Bus {
110
+ fn drop(&mut self) {
111
+ let _ = self.0.kill();
112
+ let _ = self.0.wait();
113
+ }
114
+ }
115
+
116
+ struct MockSession(Log);
117
+
118
+ #[interface(name = "org.freedesktop.portal.Session")]
119
+ impl MockSession {
120
+ fn close(&self) {
121
+ self.0.lock().unwrap().push("close".into());
122
+ }
123
+ }
124
+
125
+ /// A RemoteDesktop portal that grants what `devices` says and records
126
+ /// every call, the way a compositor's portal would.
127
+ struct MockPortal {
128
+ devices: Arc<AtomicU32>,
129
+ log: Log,
130
+ }
131
+
132
+ impl MockPortal {
133
+ fn token(options: &HashMap<String, OwnedValue>, key: &str) -> String {
134
+ String::try_from(options[key].try_clone().unwrap()).unwrap()
135
+ }
136
+
137
+ async fn respond(
138
+ connection: &Connection,
139
+ sender: &str,
140
+ options: &HashMap<String, OwnedValue>,
141
+ results: HashMap<&str, Value<'_>>,
142
+ ) -> ObjectPath<'static> {
143
+ let path = ObjectPath::try_from(format!(
144
+ "/org/freedesktop/portal/desktop/request/{}/{}",
145
+ sender.trim_start_matches(':').replace('.', "_"),
146
+ Self::token(options, "handle_token")
147
+ ))
148
+ .unwrap();
149
+ connection
150
+ .emit_signal(
151
+ None::<&str>,
152
+ &path,
153
+ "org.freedesktop.portal.Request",
154
+ "Response",
155
+ &(0u32, results),
156
+ )
157
+ .await
158
+ .unwrap();
159
+ path
160
+ }
161
+ }
162
+
163
+ #[interface(name = "org.freedesktop.portal.RemoteDesktop")]
164
+ impl MockPortal {
165
+ #[zbus(property)]
166
+ fn available_device_types(&self) -> u32 {
167
+ self.devices.load(Ordering::SeqCst)
168
+ }
169
+
170
+ #[zbus(property, name = "version")]
171
+ fn version(&self) -> u32 {
172
+ 2
173
+ }
174
+
175
+ async fn create_session(
176
+ &self,
177
+ #[zbus(connection)] connection: &Connection,
178
+ #[zbus(header)] header: zbus::message::Header<'_>,
179
+ options: HashMap<String, OwnedValue>,
180
+ ) -> ObjectPath<'static> {
181
+ let sender = header.sender().unwrap().to_string();
182
+ let session = format!(
183
+ "/org/freedesktop/portal/desktop/session/{}/{}",
184
+ sender.trim_start_matches(':').replace('.', "_"),
185
+ Self::token(&options, "session_handle_token")
186
+ );
187
+ connection
188
+ .object_server()
189
+ .at(session.as_str(), MockSession(self.log.clone()))
190
+ .await
191
+ .unwrap();
192
+ let results = HashMap::from([("session_handle", Value::from(session))]);
193
+ Self::respond(connection, &sender, &options, results).await
194
+ }
195
+
196
+ async fn select_devices(
197
+ &self,
198
+ #[zbus(connection)] connection: &Connection,
199
+ #[zbus(header)] header: zbus::message::Header<'_>,
200
+ _session: ObjectPath<'_>,
201
+ options: HashMap<String, OwnedValue>,
202
+ ) -> ObjectPath<'static> {
203
+ let sender = header.sender().unwrap().to_string();
204
+ Self::respond(connection, &sender, &options, HashMap::new()).await
205
+ }
206
+
207
+ async fn start(
208
+ &self,
209
+ #[zbus(connection)] connection: &Connection,
210
+ #[zbus(header)] header: zbus::message::Header<'_>,
211
+ _session: ObjectPath<'_>,
212
+ _parent: String,
213
+ options: HashMap<String, OwnedValue>,
214
+ ) -> ObjectPath<'static> {
215
+ let sender = header.sender().unwrap().to_string();
216
+ let results = HashMap::from([("devices", Value::from(self.devices.load(Ordering::SeqCst)))]);
217
+ Self::respond(connection, &sender, &options, results).await
218
+ }
219
+
220
+ fn notify_keyboard_keycode(
221
+ &self,
222
+ _session: ObjectPath<'_>,
223
+ _options: HashMap<String, OwnedValue>,
224
+ keycode: i32,
225
+ state: u32,
226
+ ) {
227
+ self.log.lock().unwrap().push(format!("key {keycode} {state}"));
228
+ }
229
+ }
230
+
231
+ async fn serve(devices: Arc<AtomicU32>) -> (Bus, Connection, Log) {
232
+ let mut daemon = Command::new("dbus-daemon")
233
+ .args(["--session", "--nofork", "--print-address=1"])
234
+ .stdout(Stdio::piped())
235
+ .spawn()
236
+ .expect("dbus-daemon");
237
+ let mut address = String::new();
238
+ std::io::BufRead::read_line(
239
+ &mut std::io::BufReader::new(daemon.stdout.take().unwrap()),
240
+ &mut address,
241
+ )
242
+ .unwrap();
243
+ let bus = Bus(daemon);
244
+ let log = Log::default();
245
+ let connection = Builder::address(address.trim())
246
+ .unwrap()
247
+ .name("org.freedesktop.portal.Desktop")
248
+ .unwrap()
249
+ .serve_at(
250
+ "/org/freedesktop/portal/desktop",
251
+ MockPortal { devices, log: log.clone() },
252
+ )
253
+ .unwrap()
254
+ .build()
255
+ .await
256
+ .unwrap();
257
+ // The proxies read the session bus from the environment.
258
+ std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address.trim());
259
+ (bus, connection, log)
260
+ }
261
+
262
+ /// ashpd caches its session-bus connection per process, so the scenario
263
+ /// runs in a child process of its own rather than beside the other portal
264
+ /// test.
265
+ #[test]
266
+ fn keys_reach_the_portal_in_order_and_the_session_closes() {
267
+ if std::env::var_os("DESKLINK_PORTAL_MOCK_CHILD").is_none() {
268
+ let status = Command::new(std::env::current_exe().unwrap())
269
+ .args(["--nocapture", "--exact", "input_portal::tests::keys_reach_the_portal_in_order_and_the_session_closes"])
270
+ .env("DESKLINK_PORTAL_MOCK_CHILD", "1")
271
+ .status()
272
+ .unwrap();
273
+ assert!(status.success());
274
+ return;
275
+ }
276
+ tokio::runtime::Builder::new_multi_thread()
277
+ .enable_all()
278
+ .build()
279
+ .unwrap()
280
+ .block_on(scenario());
281
+ }
282
+
283
+ /// What the capabilities report selects on a host without `/dev/uinput` access.
284
+ async fn mechanism() -> String {
285
+ let no_uinput = crate::input::InputUnavailable {
286
+ reason: "no /dev/uinput".into(),
287
+ remedy: String::new(),
288
+ };
289
+ report(Err(no_uinput))["mechanism"].to_string()
290
+ }
291
+
292
+ fn report(uinput: Result<(), crate::input::InputUnavailable>) -> serde_json::Value {
293
+ let input = crate::session::capabilities_with(uinput)["input"].clone();
294
+ println!("capabilities input: {input}");
295
+ input
296
+ }
297
+
298
+ /// Keys sent through the adapter reach the portal in order, and dropping
299
+ /// the adapter closes the portal session. A portal that offers no keyboard
300
+ /// is not selected, so the caller falls back to uinput.
301
+ async fn scenario() {
302
+ let devices = Arc::new(AtomicU32::new(2));
303
+ let (_bus, _service, log) = serve(devices.clone()).await;
304
+ assert!(!advertised().await);
305
+ assert!(open().await.is_err());
306
+ assert!(log.lock().unwrap().is_empty());
307
+ assert_eq!(mechanism().await, "\"inputtino/uinput\"");
308
+
309
+ devices.store(1, Ordering::SeqCst);
310
+ assert!(advertised().await);
311
+ // Where uinput works too, it stays the path and the pointer keeps working.
312
+ let both = report(Ok(()));
313
+ assert_eq!(both["mechanism"], "inputtino/uinput");
314
+ assert_eq!((&both["pointer"], &both["wheel"], &both["keyboard"]), (&true.into(), &true.into(), &true.into()));
315
+ assert_eq!(mechanism().await, "\"portal-remotedesktop\"");
316
+ let keys = open().await.expect("keyboard session");
317
+ // No host-wide virtual device while the portal carries the keys.
318
+ for fd in std::fs::read_dir("/proc/self/fd").unwrap().flatten() {
319
+ let target = std::fs::read_link(fd.path()).unwrap_or_default();
320
+ assert!(!target.starts_with("/dev/uinput"), "uinput open: {target:?}");
321
+ }
322
+ for (code, down) in [(35, true), (35, false), (30, true), (30, false)] {
323
+ keys.key(code, down).unwrap();
324
+ }
325
+ drop(keys);
326
+ for _ in 0..100 {
327
+ if log.lock().unwrap().last().is_some_and(|last| last == "close") {
328
+ break;
329
+ }
330
+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;
331
+ }
332
+ assert_eq!(
333
+ *log.lock().unwrap(),
334
+ ["key 35 1", "key 35 0", "key 30 1", "key 30 0", "close"]
335
+ );
336
+ }
337
+ }
@@ -56,6 +56,8 @@ mod hyprland;
56
56
  mod indicator;
57
57
  #[cfg(target_os = "linux")]
58
58
  mod input;
59
+ #[cfg(target_os = "linux")]
60
+ mod input_portal;
59
61
  #[cfg(target_os = "macos")]
60
62
  #[path = "input_mac.rs"]
61
63
  mod input;
@@ -986,91 +988,3 @@ fn main() {
986
988
  fn main() {
987
989
  std::process::exit(win::run());
988
990
  }
989
-
990
- #[cfg(all(test, target_os = "linux"))]
991
- mod tests {
992
- use super::*;
993
-
994
- #[tokio::test]
995
- async fn a_consumer_speaking_another_protocol_version_is_refused() {
996
- let mut hello_seen = false;
997
- let mut current = None;
998
- let (events, _received) = tokio_mpsc::unbounded_channel();
999
- let request = Request {
1000
- id: Some(1),
1001
- method: String::from("hello"),
1002
- params: serde_json::json!({ "protocol": 99, "client": "test" }),
1003
- };
1004
- let error = dispatch(&request, &mut hello_seen, &mut current, &events)
1005
- .await
1006
- .unwrap_err();
1007
- assert_eq!(error.code, "unsupported-protocol");
1008
- assert!(!hello_seen, "a refused handshake must not open the gate");
1009
- }
1010
-
1011
- #[test]
1012
- fn cursor_notice_preserves_source_coordinates_visibility_and_clock() {
1013
- let line = render_event(session::Notice {
1014
- session_id: String::from("cursor-session"),
1015
- event: session::SessionEvent::Cursor {
1016
- position: protocol::CursorPosition {
1017
- x: 32,
1018
- y: 64,
1019
- visible: false,
1020
- hotspot: None,
1021
- timestamp_us: 123456,
1022
- },
1023
- },
1024
- })
1025
- .unwrap();
1026
- let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
1027
- assert_eq!(
1028
- parsed,
1029
- serde_json::json!({"event":"session.cursor","params":{"sessionId":"cursor-session","x":32,"y":64,"visible":false,"timestamp_us":123456}})
1030
- );
1031
- }
1032
-
1033
- #[test]
1034
- fn the_skipped_ice_server_warning_names_the_url_once() {
1035
- let line = render_event(session::Notice {
1036
- session_id: String::from("session-1"),
1037
- event: session::SessionEvent::IceServerSkipped {
1038
- url: String::from("turn:turn.example.com:3478?transport=tcp"),
1039
- },
1040
- })
1041
- .expect("a skipped-URL warning is renderable");
1042
- let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
1043
- assert_eq!(parsed["event"], "session.iceServerSkipped");
1044
- assert_eq!(
1045
- parsed["params"],
1046
- serde_json::json!({
1047
- "sessionId": "session-1",
1048
- "url": "turn:turn.example.com:3478?transport=tcp"
1049
- })
1050
- );
1051
- }
1052
-
1053
- #[tokio::test]
1054
- async fn the_state_notification_carries_exactly_what_the_document_promises() {
1055
- let line = render_event(session::Notice {
1056
- session_id: String::from("session-1"),
1057
- event: session::SessionEvent::State {
1058
- capture: "streaming",
1059
- transport: String::from("connected"),
1060
- first_frame: true,
1061
- },
1062
- })
1063
- .expect("a state event is renderable");
1064
- let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
1065
- assert_eq!(parsed["event"], "session.state");
1066
- assert_eq!(
1067
- parsed["params"],
1068
- serde_json::json!({
1069
- "sessionId": "session-1",
1070
- "capture": "streaming",
1071
- "transport": "connected",
1072
- "firstFrame": true
1073
- })
1074
- );
1075
- }
1076
- }
@@ -59,36 +59,3 @@ pub fn status(result: Option<&Result<Tap, Unavailable>>) -> serde_json::Value {
59
59
  }),
60
60
  }
61
61
  }
62
-
63
- #[cfg(test)]
64
- mod tests {
65
- use super::*;
66
-
67
- #[test]
68
- #[cfg(not(target_os = "macos"))]
69
- fn a_platform_that_cannot_observe_says_so_and_delivers_nothing() {
70
- let delivered = Arc::new(std::sync::atomic::AtomicUsize::new(0));
71
- let counted = delivered.clone();
72
- let sink: Sink = Arc::new(move |_| {
73
- counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
74
- });
75
- let refused = start(0, (1920, 1080), sink);
76
- let status = status(Some(&refused));
77
- assert_eq!(status["observing"], false);
78
- assert_eq!(status["code"], "unsupported-platform");
79
- assert_eq!(delivered.load(std::sync::atomic::Ordering::SeqCst), 0);
80
- }
81
-
82
- #[test]
83
- fn a_refusal_carries_its_code_reason_and_remedy() {
84
- let refused: Result<Tap, Unavailable> = Err(Unavailable {
85
- code: "missing-input-monitoring",
86
- reason: String::from("reason"),
87
- remedy: String::from("remedy"),
88
- });
89
- assert_eq!(
90
- status(Some(&refused)),
91
- serde_json::json!({"observing": false, "code": "missing-input-monitoring", "reason": "reason", "remedy": "remedy"})
92
- );
93
- }
94
- }