@desklink/host 0.5.9 → 0.5.10

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.
package/engine/src/mac.rs CHANGED
@@ -39,6 +39,8 @@ unsafe extern "C" {
39
39
  pub(crate) fn CGPreflightScreenCaptureAccess() -> bool;
40
40
  pub(crate) fn CGPreflightPostEventAccess() -> bool;
41
41
  pub(crate) fn CGRequestPostEventAccess() -> bool;
42
+ pub(crate) fn CGPreflightListenEventAccess() -> bool;
43
+ pub(crate) fn CGRequestListenEventAccess() -> bool;
42
44
  fn CGGetActiveDisplayList(max: u32, displays: *mut DisplayId, count: *mut u32) -> i32;
43
45
  fn CGMainDisplayID() -> DisplayId;
44
46
  fn CGDisplayPixelsWide(display: DisplayId) -> usize;
@@ -713,6 +715,12 @@ pub fn run() -> i32 {
713
715
  let _ = crate::input::request_access();
714
716
  println!("If the Accessibility alert did not open, allow {} in System Settings › Privacy & Security › Accessibility.", tcc_responsible_app_name());
715
717
  }
718
+ if crate::input::observe_probe().is_ok() {
719
+ println!("Input Monitoring permission is already granted to {}.", tcc_responsible_app_name());
720
+ } else {
721
+ let _ = crate::input::request_observe_access();
722
+ println!("If the Input Monitoring alert did not open, allow {} in System Settings › Privacy & Security › Input Monitoring.", tcc_responsible_app_name());
723
+ }
716
724
  }
717
725
  _ => println!("desklink-host {}\nUSAGE: desklink-host [serve|capabilities|capture-probe|encode-probe|setup-input|version]", env!("CARGO_PKG_VERSION")),
718
726
  }
@@ -26,7 +26,7 @@ mod clipboard;
26
26
  mod clipboard;
27
27
  #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
28
28
  mod convert;
29
- #[cfg(target_os = "linux")]
29
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
30
30
  mod cursor;
31
31
  #[cfg(target_os = "linux")]
32
32
  mod encoder;
@@ -72,6 +72,8 @@ mod keymap;
72
72
  mod keymap;
73
73
  #[cfg(target_os = "macos")]
74
74
  mod mac;
75
+ #[cfg(any(target_os = "linux", all(any(target_os = "macos", target_os = "windows"), desklink_vpx)))]
76
+ mod observe;
75
77
  #[cfg(any(
76
78
  target_os = "linux",
77
79
  all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
@@ -666,6 +668,13 @@ fn render_event(notice: session::Notice) -> Option<String> {
666
668
  "session.input",
667
669
  serde_json::json!({ "sessionId": session_id, "input": input }),
668
670
  ),
671
+ session::SessionEvent::Observed {
672
+ timestamp_us,
673
+ input,
674
+ } => (
675
+ "session.input.observed",
676
+ serde_json::json!({ "sessionId": session_id, "timestamp_us": timestamp_us, "input": input }),
677
+ ),
669
678
  session::SessionEvent::KeyframeRequest { generation } => (
670
679
  "session.keyframeRequest",
671
680
  serde_json::json!({ "sessionId": session_id, "generation": generation }),
@@ -752,6 +761,7 @@ async fn dispatch(
752
761
  agent_indicator: params.agent_indicator,
753
762
  local_frames: params.local_frames,
754
763
  audio: params.audio,
764
+ observe_input: params.observe_input,
755
765
  ice_servers: usable.0,
756
766
  };
757
767
  let session = session::Session::open(open, events.clone())
@@ -770,6 +780,7 @@ async fn dispatch(
770
780
  "cursor": session.cursor_info(),
771
781
  "agentIndicator": session.agent_indicator(),
772
782
  "audio": session.audio_info(),
783
+ "inputObservation": session.observation_info(),
773
784
  });
774
785
  // URLs the WebRTC stack cannot dial were left out of the peer's
775
786
  // configuration; each skipped one is named once instead of
@@ -0,0 +1,94 @@
1
+ //! Passive observation of the desktop's own pointer buttons, cursor and keys.
2
+ //!
3
+ //! The engine only listens; events are the ones `docs/PROTOCOL.md` describes. A
4
+ //! platform that cannot observe says why with a typed reason and delivers nothing.
5
+
6
+ use crate::protocol::{CursorPosition, ObservedInput};
7
+ use std::sync::Arc;
8
+
9
+ #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
10
+ pub enum Observation {
11
+ Input {
12
+ timestamp_us: u64,
13
+ input: ObservedInput,
14
+ },
15
+ Cursor(CursorPosition),
16
+ }
17
+
18
+ pub type Sink = Arc<dyn Fn(Observation) + Send + Sync + 'static>;
19
+
20
+ /// Keeps an observer running; dropping it stops it.
21
+ #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
22
+ pub struct Tap {
23
+ pub mechanism: &'static str,
24
+ pub guard: Box<dyn Send>,
25
+ }
26
+
27
+ /// Why the desktop's input cannot be observed, and what the owner can do.
28
+ #[derive(Debug, Clone, PartialEq, Eq)]
29
+ pub struct Unavailable {
30
+ pub code: &'static str,
31
+ pub reason: String,
32
+ pub remedy: String,
33
+ }
34
+
35
+ #[cfg(target_os = "macos")]
36
+ pub fn start(display_id: u32, pixels: (usize, usize), sink: Sink) -> Result<Tap, Unavailable> {
37
+ crate::input::observe(display_id, pixels, sink)
38
+ }
39
+
40
+ #[cfg(not(target_os = "macos"))]
41
+ pub fn start(_: u32, _: (usize, usize), _: Sink) -> Result<Tap, Unavailable> {
42
+ Err(Unavailable {
43
+ code: "unsupported-platform",
44
+ reason: String::from("observing the desktop's own input is only implemented on macOS"),
45
+ remedy: String::from("none: this platform delivers no input events"),
46
+ })
47
+ }
48
+
49
+ /// The `inputObservation` member of the `session.open` reply.
50
+ pub fn status(result: Option<&Result<Tap, Unavailable>>) -> serde_json::Value {
51
+ match result {
52
+ None => serde_json::json!({"observing": false}),
53
+ Some(Ok(tap)) => serde_json::json!({"observing": true, "mechanism": tap.mechanism}),
54
+ Some(Err(error)) => serde_json::json!({
55
+ "observing": false,
56
+ "code": error.code,
57
+ "reason": error.reason,
58
+ "remedy": error.remedy,
59
+ }),
60
+ }
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
+ }
@@ -47,13 +47,20 @@ pub async fn open_display(_: Option<u32>) -> Result<PortalSession> {
47
47
  anyhow::bail!("display selection is only supported on macOS")
48
48
  }
49
49
 
50
+ /// The portal service answered without a ScreenCast interface. That happens
51
+ /// when xdg-desktop-portal started before the compositor's portal and cached an
52
+ /// interface list without it; only a restart makes it look again.
53
+ const NO_SCREENCAST: &str = "compositor has no ScreenCast portal: xdg-desktop-portal does not export \
54
+ org.freedesktop.portal.ScreenCast. If it started before the compositor's portal, run \
55
+ `systemctl --user restart xdg-desktop-portal` once the compositor portal is running";
56
+
50
57
  pub async fn open(
51
58
  restore_token: Option<&str>,
52
59
  cursor: crate::protocol::CursorMode,
53
60
  ) -> Result<PortalSession> {
54
61
  let proxy = Screencast::new()
55
62
  .await
56
- .context("compositor has no ScreenCast portal")?;
63
+ .context(NO_SCREENCAST)?;
57
64
 
58
65
  if proxy
59
66
  .available_source_types()
@@ -142,3 +149,78 @@ fn choose_cursor_mode(available: BitFlags<CursorMode>) -> Result<CursorMode> {
142
149
  .find(|mode| available.contains(*mode))
143
150
  .context("cursor-unavailable: portal cannot leave the cursor out of capture")
144
151
  }
152
+
153
+ #[cfg(all(test, target_os = "linux"))]
154
+ mod tests {
155
+ use super::*;
156
+ use std::process::{Child, Command, Stdio};
157
+ use zbus::{connection::Builder, message::Type, MessageStream};
158
+
159
+ struct Bus(Child);
160
+
161
+ impl Drop for Bus {
162
+ fn drop(&mut self) {
163
+ let _ = self.0.kill();
164
+ let _ = self.0.wait();
165
+ }
166
+ }
167
+
168
+ /// A private session bus whose `org.freedesktop.portal.Desktop` owner
169
+ /// answers every property read the way a stale xdg-desktop-portal answers
170
+ /// for ScreenCast: no such interface.
171
+ #[tokio::test]
172
+ async fn open_names_the_fix_when_the_portal_exports_no_screencast() {
173
+ use zbus::export::futures_core::Stream;
174
+
175
+ let mut daemon = Command::new("dbus-daemon")
176
+ .args(["--session", "--nofork", "--print-address=1"])
177
+ .stdout(Stdio::piped())
178
+ .spawn()
179
+ .expect("dbus-daemon");
180
+ let mut address = String::new();
181
+ std::io::BufRead::read_line(
182
+ &mut std::io::BufReader::new(daemon.stdout.take().unwrap()),
183
+ &mut address,
184
+ )
185
+ .unwrap();
186
+ let _bus = Bus(daemon);
187
+ let address = address.trim().to_owned();
188
+
189
+ let fake = Builder::address(address.as_str())
190
+ .unwrap()
191
+ .name("org.freedesktop.portal.Desktop")
192
+ .unwrap()
193
+ .build()
194
+ .await
195
+ .unwrap();
196
+ let service = fake.clone();
197
+ let answering = tokio::spawn(async move {
198
+ let mut messages = MessageStream::from(&service);
199
+ while let Some(Ok(message)) =
200
+ std::future::poll_fn(|cx| std::pin::Pin::new(&mut messages).poll_next(cx)).await
201
+ {
202
+ if message.message_type() != Type::MethodCall {
203
+ continue;
204
+ }
205
+ let _ = service
206
+ .reply_dbus_error(
207
+ &message.header(),
208
+ zbus::fdo::Error::InvalidArgs(
209
+ "No such interface \u{201c}org.freedesktop.portal.ScreenCast\u{201d}".into(),
210
+ ),
211
+ )
212
+ .await;
213
+ }
214
+ });
215
+ // Screencast::new reads the session bus from the environment.
216
+ std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &address);
217
+
218
+ let error = match open(None, crate::protocol::CursorMode::Embedded).await {
219
+ Ok(_) => panic!("a portal without ScreenCast must not open a session"),
220
+ Err(error) => format!("{error:#}"),
221
+ };
222
+ answering.abort();
223
+ assert!(error.starts_with("compositor has no ScreenCast portal"), "{error}");
224
+ assert!(error.contains("systemctl --user restart xdg-desktop-portal"), "{error}");
225
+ }
226
+ }
@@ -149,6 +149,11 @@ pub struct OpenParams {
149
149
  /// cannot be answered is refused.
150
150
  #[serde(default)]
151
151
  pub audio: bool,
152
+ /// Observe this computer's own pointer buttons, cursor and keys and raise
153
+ /// them as `session.input.observed` and `session.cursor`. Off unless asked:
154
+ /// it reports every key typed on the desktop, in every application.
155
+ #[serde(default)]
156
+ pub observe_input: bool,
152
157
  }
153
158
 
154
159
  fn default_local_frames() -> bool {
@@ -379,7 +384,7 @@ impl ControlMessage {
379
384
  }
380
385
  }
381
386
 
382
- #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
387
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383
388
  #[serde(rename_all = "snake_case")]
384
389
  pub enum PointerPhase {
385
390
  Move,
@@ -388,6 +393,30 @@ pub enum PointerPhase {
388
393
  Cancel,
389
394
  }
390
395
 
396
+ /// A pointer button or key the person pressed on the observed desktop. The
397
+ /// vocabulary is the control channel's own (`pointer` and `key`), so a consumer
398
+ /// reads what it sent and what it saw the same way. `x`/`y` are in the source's
399
+ /// own pixels (`geometry.source`); `modifiers` is the modifier state after the
400
+ /// event.
401
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
402
+ #[serde(tag = "kind", rename_all = "snake_case")]
403
+ pub enum ObservedInput {
404
+ Pointer {
405
+ phase: PointerPhase,
406
+ x: i64,
407
+ y: i64,
408
+ button: i64,
409
+ },
410
+ Key {
411
+ #[serde(skip_serializing_if = "Option::is_none")]
412
+ name: Option<String>,
413
+ #[serde(skip_serializing_if = "Option::is_none")]
414
+ character: Option<String>,
415
+ down: bool,
416
+ modifiers: Vec<String>,
417
+ },
418
+ }
419
+
391
420
  /// Messages the engine sends back on the control channel.
392
421
  #[derive(Debug, Serialize)]
393
422
  #[serde(tag = "kind", rename_all = "snake_case")]
@@ -714,6 +714,8 @@ pub struct OpenRequest {
714
714
  pub local_frames: bool,
715
715
  /// Capture the desktop's own system audio for `session.audio`.
716
716
  pub audio: bool,
717
+ /// Listen to the desktop's own pointer buttons, cursor and keys.
718
+ pub observe_input: bool,
717
719
  }
718
720
 
719
721
  /// Events a session raises for its consumer.
@@ -755,6 +757,11 @@ pub enum SessionEvent {
755
757
  Input {
756
758
  input: serde_json::Value,
757
759
  },
760
+ /// A pointer button or key the person pressed, observed rather than applied.
761
+ Observed {
762
+ timestamp_us: u64,
763
+ input: crate::protocol::ObservedInput,
764
+ },
758
765
  /// The far end needs a fresh reference frame and only the consumer can make
759
766
  /// one: the encoder for this track is on its side. Raised by an RTCP PLI or
760
767
  /// FIR, and once when the transport connects, because everything sent before
@@ -854,6 +861,9 @@ struct Inner {
854
861
  #[cfg(target_os = "linux")]
855
862
  audio: Mutex<Option<crate::audio::AudioCapture>>,
856
863
  cursor_info: serde_json::Value,
864
+ /// `session.open`'s `inputObservation` member, fixed when the session opened.
865
+ observation: serde_json::Value,
866
+ observer: Mutex<Option<crate::observe::Tap>>,
857
867
  }
858
868
 
859
869
  /// A source whose video arrives already encoded.
@@ -978,6 +988,7 @@ pub fn capabilities() -> serde_json::Value {
978
988
  }),
979
989
  ),
980
990
  };
991
+ let observe = crate::input::observe_probe();
981
992
  serde_json::json!({
982
993
  "protocol": crate::protocol::PROTOCOL_VERSION,
983
994
  "engine": format!("desklink-host/{}", env!("CARGO_PKG_VERSION")),
@@ -1009,6 +1020,11 @@ pub fn capabilities() -> serde_json::Value {
1009
1020
  "layout": keymap::LayoutNames::from_environment().identity(),
1010
1021
  "unavailable_reason": unavailable,
1011
1022
  "grant": input_grant,
1023
+ "observe": {
1024
+ "mechanism": "cgeventtap",
1025
+ "grant": if observe.is_ok() { "granted" } else { "missing-input-monitoring" },
1026
+ "unavailable_reason": observe.as_ref().err().map(|e| serde_json::json!({"reason": e.reason, "remedy": e.remedy})),
1027
+ },
1012
1028
  },
1013
1029
  "clipboard": { "read": true, "write": true, "mime": ["text/plain;charset=utf-8"],
1014
1030
  "maxBytes": clipboard::MAX_CLIPBOARD_BYTES },
@@ -1480,6 +1496,29 @@ impl Session {
1480
1496
  "origin": { "x": source.origin_x, "y": source.origin_y },
1481
1497
  });
1482
1498
 
1499
+ // Observation is an addition to the stream, never a condition of it: a
1500
+ // desktop that cannot be observed still streams, and the reply says why.
1501
+ let observed = request.observe_input.then(|| {
1502
+ let sink: crate::observe::Sink = {
1503
+ let events = events.clone();
1504
+ let id = id.clone();
1505
+ Arc::new(move |observation| {
1506
+ let event = match observation {
1507
+ crate::observe::Observation::Input { timestamp_us, input } => {
1508
+ SessionEvent::Observed { timestamp_us, input }
1509
+ }
1510
+ crate::observe::Observation::Cursor(position) => SessionEvent::Cursor { position },
1511
+ };
1512
+ let _ = events.send(Notice { session_id: id.clone(), event });
1513
+ })
1514
+ };
1515
+ crate::observe::start(source.node_id, (source_w, source_h), sink)
1516
+ });
1517
+ if let Some(Err(error)) = &observed {
1518
+ log::warn!("input observation unavailable: {} ({})", error.reason, error.code);
1519
+ }
1520
+ let observation = crate::observe::status(observed.as_ref());
1521
+
1483
1522
  let pipeline = Arc::new(AtomicBool::new(true));
1484
1523
  let inner = Arc::new(Inner {
1485
1524
  id,
@@ -1526,6 +1565,8 @@ impl Session {
1526
1565
  encoded: Mutex::new(None),
1527
1566
  local_frames: request.local_frames,
1528
1567
  cursor_info,
1568
+ observation,
1569
+ observer: Mutex::new(observed.and_then(Result::ok)),
1529
1570
  });
1530
1571
 
1531
1572
  inner.notify(SessionEvent::Description {
@@ -1641,6 +1682,13 @@ impl Session {
1641
1682
  })),
1642
1683
  local_frames: false,
1643
1684
  cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
1685
+ observation: serde_json::json!({
1686
+ "observing": false,
1687
+ "code": "unsupported-source",
1688
+ "reason": "consumer-encoded video has no desktop of its own to observe",
1689
+ "remedy": "observe the device where its pixels come from",
1690
+ }),
1691
+ observer: Mutex::new(None),
1644
1692
  });
1645
1693
  inner.notify(SessionEvent::State {
1646
1694
  capture: "consented",
@@ -1706,6 +1754,10 @@ impl Session {
1706
1754
  &self.inner.cursor_info
1707
1755
  }
1708
1756
 
1757
+ pub fn observation_info(&self) -> &serde_json::Value {
1758
+ &self.inner.observation
1759
+ }
1760
+
1709
1761
  pub fn geometry(&self) -> &serde_json::Value {
1710
1762
  &self.inner.geometry
1711
1763
  }
@@ -2176,6 +2228,8 @@ impl Inner {
2176
2228
  // handlers, and `shutdown` exits right after this. The encode thread
2177
2229
  // finds it gone and stops.
2178
2230
  drop(lock(&self.encoder).take());
2231
+ // Stop listening to the person's keys the moment the session ends.
2232
+ drop(lock(&self.observer).take());
2179
2233
  if let Ok(mut indicator) = self.indicator.lock() {
2180
2234
  indicator.take();
2181
2235
  }
@@ -3829,6 +3883,8 @@ mod tests {
3829
3883
  })),
3830
3884
  local_frames: false,
3831
3885
  cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
3886
+ observation: serde_json::json!({"observing": false}),
3887
+ observer: Mutex::new(None),
3832
3888
  });
3833
3889
  spawn_peer_events(&inner, peer_events_rx);
3834
3890
  spawn_encoded(&inner, feed_rx);
@@ -4084,6 +4140,8 @@ mod tests {
4084
4140
  encoded: Mutex::new(None),
4085
4141
  local_frames: true,
4086
4142
  cursor_info: serde_json::json!({"mode":"embedded", "positions":false}),
4143
+ observation: serde_json::json!({"observing": false}),
4144
+ observer: Mutex::new(None),
4087
4145
  });
4088
4146
  (inner, recorded)
4089
4147
  }
@@ -4484,6 +4542,7 @@ mod tests {
4484
4542
  agent_indicator: false,
4485
4543
  local_frames: true,
4486
4544
  audio: false,
4545
+ observe_input: false,
4487
4546
  }
4488
4547
  }
4489
4548
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/host",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "ws": "8.21.3"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@desklink/host-linux-x64-gnu": "0.5.9",
42
- "@desklink/host-darwin-arm64": "0.5.9"
41
+ "@desklink/host-linux-x64-gnu": "0.5.10",
42
+ "@desklink/host-darwin-arm64": "0.5.10"
43
43
  }
44
44
  }