@desklink/host 0.3.0 → 0.4.0

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.
Files changed (47) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +92 -23
  3. package/bin/desklink-host.mjs +14 -5
  4. package/dist/bridge.d.ts +3 -4
  5. package/dist/bridge.d.ts.map +1 -1
  6. package/dist/bridge.js +18 -7
  7. package/dist/bridge.js.map +1 -1
  8. package/dist/engineProcess.d.ts +6 -1
  9. package/dist/engineProcess.d.ts.map +1 -1
  10. package/dist/engineProcess.js +34 -5
  11. package/dist/engineProcess.js.map +1 -1
  12. package/dist/protocol.d.ts +35 -2
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/protocol.js.map +1 -1
  15. package/dist/resolveEngine.d.ts +6 -0
  16. package/dist/resolveEngine.d.ts.map +1 -1
  17. package/dist/resolveEngine.js +45 -6
  18. package/dist/resolveEngine.js.map +1 -1
  19. package/docs/BOUNDARY.md +8 -8
  20. package/docs/PROTOCOL.md +185 -45
  21. package/engine/Cargo.lock +2 -2
  22. package/engine/Cargo.toml +7 -12
  23. package/engine/build.rs +62 -5
  24. package/engine/native/agent_indicator.h +57 -0
  25. package/engine/native/agent_overlay_mac.m +51 -4
  26. package/engine/native/agent_overlay_wayland.c +189 -35
  27. package/engine/native/agent_overlay_x11.c +186 -55
  28. package/engine/src/capture.rs +96 -5
  29. package/engine/src/capture_win.rs +23 -0
  30. package/engine/src/clipboard.rs +174 -0
  31. package/engine/src/clipboard_win.rs +8 -0
  32. package/engine/src/cursor.rs +118 -0
  33. package/engine/src/indicator.rs +271 -5
  34. package/engine/src/input_win.rs +91 -0
  35. package/engine/src/keymap_win.rs +39 -0
  36. package/engine/src/mac.rs +28 -6
  37. package/engine/src/main.rs +167 -25
  38. package/engine/src/peer.rs +10 -4
  39. package/engine/src/portal.rs +42 -4
  40. package/engine/src/portal_win.rs +37 -0
  41. package/engine/src/protocol.rs +56 -10
  42. package/engine/src/session.rs +749 -203
  43. package/engine/src/win.rs +110 -0
  44. package/engine/src/x11.rs +44 -29
  45. package/engine/src/x11_mac.rs +4 -0
  46. package/examples/reference-client.html +3 -5
  47. package/package.json +4 -4
@@ -0,0 +1,91 @@
1
+ use anyhow::{bail, Result};
2
+
3
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4
+ pub enum Button {
5
+ Left,
6
+ Middle,
7
+ Right,
8
+ }
9
+
10
+ impl Button {
11
+ pub fn from_number(number: i64) -> Self {
12
+ match number {
13
+ 2 => Self::Middle,
14
+ 3 => Self::Right,
15
+ _ => Self::Left,
16
+ }
17
+ }
18
+ }
19
+
20
+ #[derive(Debug, Default, Clone, PartialEq, Eq)]
21
+ pub struct HeldState {
22
+ keys: Vec<i16>,
23
+ buttons: Vec<Button>,
24
+ }
25
+
26
+ impl HeldState {
27
+ pub fn key(&mut self, code: i16, down: bool) {
28
+ if down {
29
+ if !self.keys.contains(&code) {
30
+ self.keys.push(code);
31
+ }
32
+ } else {
33
+ self.keys.retain(|held| *held != code);
34
+ }
35
+ }
36
+
37
+ pub fn button(&mut self, button: Button, down: bool) {
38
+ if down {
39
+ if !self.buttons.contains(&button) {
40
+ self.buttons.push(button);
41
+ }
42
+ } else {
43
+ self.buttons.retain(|held| *held != button);
44
+ }
45
+ }
46
+
47
+ pub fn release_plan(&mut self) -> (Vec<Button>, Vec<i16>) {
48
+ (
49
+ std::mem::take(&mut self.buttons),
50
+ std::mem::take(&mut self.keys),
51
+ )
52
+ }
53
+ }
54
+
55
+ #[derive(Debug, Clone, serde::Serialize)]
56
+ pub struct InputUnavailable {
57
+ pub reason: String,
58
+ pub remedy: String,
59
+ }
60
+
61
+ pub fn unavailable_reason() -> &'static str {
62
+ "Windows input is not implemented in this build."
63
+ }
64
+ pub fn probe() -> Result<(), InputUnavailable> {
65
+ Err(InputUnavailable {
66
+ reason: unavailable_reason().into(),
67
+ remedy: "Use a future Windows input-enabled build.".into(),
68
+ })
69
+ }
70
+ // This private, unconstructible adapter cannot inject native input.
71
+ pub struct InputDevices {
72
+ _private: (),
73
+ }
74
+ impl InputDevices {
75
+ pub fn create_for_display(_width: i32, _height: i32, _display: u32) -> Result<Self> {
76
+ bail!("{}", unavailable_reason())
77
+ }
78
+ pub fn move_absolute(&mut self, _x: i64, _y: i64) {}
79
+ pub fn button(&mut self, _button: Button, _down: bool) {}
80
+ pub fn scroll(&mut self, _dx: f64, _dy: f64) {}
81
+ pub fn key(&mut self, _code: i16, _down: bool) -> Result<()> {
82
+ bail!("{}", unavailable_reason())
83
+ }
84
+ pub fn unicode_text(&mut self, _text: &str) -> Result<()> {
85
+ bail!("{}", unavailable_reason())
86
+ }
87
+ }
88
+
89
+ pub fn native_keycode(_code: i16) -> Result<i16> {
90
+ bail!("{}", unavailable_reason())
91
+ }
@@ -0,0 +1,39 @@
1
+ use anyhow::Result;
2
+
3
+ // No key mapping is advertised until the native input lane is implemented.
4
+ pub struct Keystroke {
5
+ pub code: i16,
6
+ }
7
+ impl Keystroke {
8
+ pub fn modifiers(&self) -> Vec<i16> {
9
+ Vec::new()
10
+ }
11
+ }
12
+
13
+ pub fn named_key(_name: &str) -> Option<Keystroke> {
14
+ None
15
+ }
16
+ pub fn modifier_key(_name: &str) -> Option<i16> {
17
+ None
18
+ }
19
+ pub fn keyboard_layout() -> &'static str {
20
+ "unknown"
21
+ }
22
+ pub struct LayoutNames;
23
+ impl LayoutNames {
24
+ pub fn from_environment() -> Self {
25
+ Self
26
+ }
27
+ pub fn identity(&self) -> String {
28
+ keyboard_layout().into()
29
+ }
30
+ }
31
+ pub struct Layout;
32
+ impl Layout {
33
+ pub fn from_environment() -> Result<Self> {
34
+ Ok(Self)
35
+ }
36
+ pub fn keystroke_for_char(&self, _character: char) -> Option<Keystroke> {
37
+ None
38
+ }
39
+ }
package/engine/src/mac.rs CHANGED
@@ -26,6 +26,7 @@ struct CGRect {
26
26
  type DisplayId = u32;
27
27
 
28
28
  unsafe extern "C" {
29
+ fn desklink_point_overlay_main(display_id: i32) -> i32;
29
30
  fn desklink_agent_overlay_main(display_id: i32) -> i32;
30
31
  fn desklink_image_bgra(image: *const c_void, length: *mut usize, stride: *mut usize)
31
32
  -> *mut u8;
@@ -182,6 +183,7 @@ fn capabilities() -> Value {
182
183
  "backends": ["screencapturekit"],
183
184
  "formats": ["bgra"],
184
185
  "cursor": "embedded",
186
+ "cursor_modes": ["embedded"],
185
187
  "audio": false,
186
188
  "displays": displays(),
187
189
  "grant": if capture_granted { "granted" } else { "missing-screen-recording" },
@@ -463,14 +465,25 @@ fn indicator_demo(display_id: u32, path: &str) -> Result<(), String> {
463
465
  fn record_agent_overlay(path: &str) -> Result<(), String> {
464
466
  use std::io::Write;
465
467
  let display = unsafe { CGMainDisplayID() };
466
- let (w, h) = crate::convert::fit(unsafe { CGDisplayPixelsWide(display) },
467
- unsafe { CGDisplayPixelsHigh(display) }, 960, 640);
468
+ let (w, h) = crate::convert::fit(
469
+ unsafe { CGDisplayPixelsWide(display) },
470
+ unsafe { CGDisplayPixelsHigh(display) },
471
+ 960,
472
+ 640,
473
+ );
468
474
  let mut file = std::fs::File::create(path).map_err(|error| error.to_string())?;
469
475
  for _ in 0..30 {
470
476
  let (pixels, sw, sh, stride) = capture_display(display)?;
471
- let raw = crate::convert::to_bgrx(&pixels, sw, sh, stride,
472
- crate::convert::PixelFormat::Bgra, w, h)
473
- .ok_or_else(|| String::from("could not scale recording frame"))?;
477
+ let raw = crate::convert::to_bgrx(
478
+ &pixels,
479
+ sw,
480
+ sh,
481
+ stride,
482
+ crate::convert::PixelFormat::Bgra,
483
+ w,
484
+ h,
485
+ )
486
+ .ok_or_else(|| String::from("could not scale recording frame"))?;
474
487
  file.write_all(&raw).map_err(|error| error.to_string())?;
475
488
  std::thread::sleep(Duration::from_millis(100));
476
489
  }
@@ -581,7 +594,12 @@ pub fn run() -> i32 {
581
594
  let command = args.next().unwrap_or_else(|| "help".into());
582
595
  if matches!(
583
596
  command.as_str(),
584
- "serve" | "capabilities" | "capture-probe" | "indicator-demo" | "axi-record" | "setup-input"
597
+ "serve"
598
+ | "capabilities"
599
+ | "capture-probe"
600
+ | "indicator-demo"
601
+ | "axi-record"
602
+ | "setup-input"
585
603
  ) {
586
604
  #[cfg(not(desklink_macos_cli))]
587
605
  if let Err(error) = ensure_disclaimed() {
@@ -599,6 +617,10 @@ pub fn run() -> i32 {
599
617
  .status();
600
618
  return match result { Ok(status) => status.code().unwrap_or(1), Err(error) => { eprintln!("axi bridge: {error}"); 1 } };
601
619
  }
620
+ "point-overlay" => {
621
+ let display = args.next().and_then(|value| value.parse().ok()).unwrap_or(0);
622
+ return unsafe { desklink_point_overlay_main(display) };
623
+ }
602
624
  "agent-overlay" => {
603
625
  let display = args.next().and_then(|value| value.parse().ok()).unwrap_or(0);
604
626
  return unsafe { desklink_agent_overlay_main(display) };
@@ -11,18 +11,29 @@ mod capture;
11
11
  #[cfg(target_os = "macos")]
12
12
  #[path = "capture_mac.rs"]
13
13
  mod capture;
14
+ #[cfg(target_os = "windows")]
15
+ #[path = "capture_win.rs"]
16
+ mod capture;
14
17
  #[cfg(target_os = "linux")]
15
18
  mod clipboard;
16
19
  #[cfg(target_os = "macos")]
17
20
  #[path = "clipboard_mac.rs"]
18
21
  mod clipboard;
19
- #[cfg(any(target_os = "linux", target_os = "macos"))]
22
+ #[cfg(target_os = "windows")]
23
+ #[path = "clipboard_win.rs"]
24
+ mod clipboard;
25
+ #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
20
26
  mod convert;
21
27
  #[cfg(target_os = "linux")]
28
+ mod cursor;
29
+ #[cfg(target_os = "linux")]
22
30
  mod encoder;
23
- #[cfg(all(target_os = "macos", desklink_vpx))]
31
+ #[cfg(all(any(target_os = "macos", target_os = "windows"), desklink_vpx))]
24
32
  mod encoder;
25
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
33
+ #[cfg(any(
34
+ target_os = "linux",
35
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
36
+ ))]
26
37
  mod h264;
27
38
  #[cfg(target_os = "linux")]
28
39
  mod h264_nvenc;
@@ -32,13 +43,19 @@ mod h264_openh264;
32
43
  mod h264_vaapi;
33
44
  #[cfg(all(target_os = "macos", desklink_vpx))]
34
45
  mod h264_vt;
35
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
46
+ #[cfg(any(
47
+ target_os = "linux",
48
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
49
+ ))]
36
50
  mod indicator;
37
51
  #[cfg(target_os = "linux")]
38
52
  mod input;
39
53
  #[cfg(target_os = "macos")]
40
54
  #[path = "input_mac.rs"]
41
55
  mod input;
56
+ #[cfg(target_os = "windows")]
57
+ #[path = "input_win.rs"]
58
+ mod input;
42
59
  #[cfg(target_os = "linux")]
43
60
  mod keeper;
44
61
  #[cfg(target_os = "linux")]
@@ -46,44 +63,76 @@ mod keymap;
46
63
  #[cfg(target_os = "macos")]
47
64
  #[path = "keymap_mac.rs"]
48
65
  mod keymap;
66
+ #[cfg(target_os = "windows")]
67
+ #[path = "keymap_win.rs"]
68
+ mod keymap;
49
69
  #[cfg(target_os = "macos")]
50
70
  mod mac;
51
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
71
+ #[cfg(any(
72
+ target_os = "linux",
73
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
74
+ ))]
52
75
  mod peer;
53
76
  #[cfg(target_os = "linux")]
54
77
  mod portal;
55
78
  #[cfg(target_os = "macos")]
56
79
  #[path = "portal_mac.rs"]
57
80
  mod portal;
58
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
81
+ #[cfg(target_os = "windows")]
82
+ #[path = "portal_win.rs"]
83
+ mod portal;
84
+ #[cfg(any(
85
+ target_os = "linux",
86
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
87
+ ))]
59
88
  mod protocol;
60
89
  #[cfg(target_os = "linux")]
61
90
  mod session;
62
- #[cfg(all(target_os = "macos", desklink_vpx))]
91
+ #[cfg(all(any(target_os = "macos", target_os = "windows"), desklink_vpx))]
63
92
  mod session;
93
+ #[cfg(target_os = "windows")]
94
+ mod win;
64
95
  #[cfg(target_os = "linux")]
65
96
  mod x11;
66
- #[cfg(target_os = "macos")]
97
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
67
98
  #[path = "x11_mac.rs"]
68
99
  mod x11;
69
100
 
70
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
101
+ #[cfg(any(
102
+ target_os = "linux",
103
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
104
+ ))]
71
105
  use anyhow::Result;
72
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
106
+ #[cfg(any(
107
+ target_os = "linux",
108
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
109
+ ))]
73
110
  use protocol::{ErrorBody, Event, Request, Response};
74
111
  #[cfg(target_os = "linux")]
75
112
  use std::sync::mpsc as std_mpsc;
76
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
113
+ #[cfg(any(
114
+ target_os = "linux",
115
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
116
+ ))]
77
117
  use std::time::Duration;
78
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
118
+ #[cfg(any(
119
+ target_os = "linux",
120
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
121
+ ))]
79
122
  use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
80
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
123
+ #[cfg(any(
124
+ target_os = "linux",
125
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
126
+ ))]
81
127
  use tokio::sync::mpsc as tokio_mpsc;
82
128
 
83
129
  #[cfg(target_os = "linux")]
84
130
  unsafe extern "C" {
131
+ fn desklink_point_overlay_main(display: *const std::ffi::c_char) -> i32;
85
132
  fn desklink_agent_overlay_main(display: *const std::ffi::c_char) -> i32;
86
133
  fn desklink_agent_overlay_wayland(source_w: i32, source_h: i32) -> i32;
134
+ fn desklink_wayland_layer_shell_available() -> i32;
135
+ fn desklink_point_overlay_wayland(w: i32, h: i32, known: i32, x: i32, y: i32) -> i32;
87
136
  }
88
137
 
89
138
  #[cfg(target_os = "linux")]
@@ -105,11 +154,39 @@ fn main() {
105
154
  println!("{}", env!("CARGO_PKG_VERSION"));
106
155
  0
107
156
  }
157
+ "point-overlay" => {
158
+ let display = args.get(1).map(String::as_str).unwrap_or("");
159
+ let display = std::ffi::CString::new(display).expect("valid X display");
160
+ unsafe { desklink_point_overlay_main(display.as_ptr()) }
161
+ }
108
162
  "agent-overlay" => {
109
163
  let display = args.get(1).map(String::as_str).unwrap_or("");
110
164
  let display = std::ffi::CString::new(display).expect("valid X display");
111
165
  unsafe { desklink_agent_overlay_main(display.as_ptr()) }
112
166
  }
167
+ "point-layer-shell-probe" => match unsafe { desklink_wayland_layer_shell_available() } {
168
+ 1 => {
169
+ println!("READY");
170
+ 0
171
+ }
172
+ 0 => {
173
+ println!("layer_shell_unavailable");
174
+ 2
175
+ }
176
+ _ => 2,
177
+ },
178
+ "point-overlay-wayland" => {
179
+ let values: Vec<i32> = args.iter().skip(1).filter_map(|v| v.parse().ok()).collect();
180
+ if values.len() != 5 || values[0] <= 0 || values[1] <= 0 {
181
+ 2
182
+ } else {
183
+ unsafe {
184
+ desklink_point_overlay_wayland(
185
+ values[0], values[1], values[2], values[3], values[4],
186
+ )
187
+ }
188
+ }
189
+ }
113
190
  "agent-overlay-wayland" => {
114
191
  let width = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(0);
115
192
  let height = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(0);
@@ -149,7 +226,10 @@ fn main() {
149
226
  std::process::exit(code);
150
227
  }
151
228
 
152
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
229
+ #[cfg(any(
230
+ target_os = "linux",
231
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
232
+ ))]
153
233
  fn report(result: Result<()>) -> i32 {
154
234
  match result {
155
235
  Ok(()) => 0,
@@ -230,7 +310,7 @@ fn print_input_setup() {
230
310
  println!(
231
311
  "To undo it: remove the rule, remove the user from the group, reload the rules."
232
312
  );
233
- println!("Without it the engine still captures the desktop and reports view-only.");
313
+ println!("Without it the engine still captures the desktop; input actions are refused with input-unavailable.");
234
314
  }
235
315
  }
236
316
  }
@@ -295,7 +375,7 @@ fn frame_shape(frame: &crate::convert::I420) -> u64 {
295
375
 
296
376
  #[cfg(target_os = "linux")]
297
377
  async fn probe_portal(seconds: u64) -> Result<()> {
298
- let portal = portal::open(None).await?;
378
+ let portal = portal::open(None, protocol::CursorMode::Embedded).await?;
299
379
  eprintln!("portal source: {:?}", portal.source);
300
380
  // The source's own size: a zero box converts nothing and reports no frames.
301
381
  let (width, height) = crate::convert::fit(
@@ -311,6 +391,7 @@ async fn probe_portal(seconds: u64) -> Result<()> {
311
391
  width,
312
392
  height,
313
393
  30,
394
+ None,
314
395
  Box::new(move |frame, seq, _raw| {
315
396
  let _ = tx.send((frame.width, frame.height, seq));
316
397
  }),
@@ -349,7 +430,10 @@ async fn probe_portal(seconds: u64) -> Result<()> {
349
430
  }
350
431
 
351
432
  /// The local control protocol loop.
352
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
433
+ #[cfg(any(
434
+ target_os = "linux",
435
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
436
+ ))]
353
437
  async fn serve() -> Result<()> {
354
438
  let (out_tx, mut out_rx) = tokio_mpsc::unbounded_channel::<String>();
355
439
  let writer = tokio::spawn(async move {
@@ -434,13 +518,16 @@ async fn serve() -> Result<()> {
434
518
  if let Some(session) = current.take() {
435
519
  session.close("the consumer disconnected").await;
436
520
  }
437
- let _ = out_tx.send(String::new()).ok();
521
+ drop(events_tx);
438
522
  drop(out_tx);
439
523
  let _ = writer.await;
440
524
  Ok(())
441
525
  }
442
526
 
443
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
527
+ #[cfg(any(
528
+ target_os = "linux",
529
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
530
+ ))]
444
531
  fn render_event(notice: session::Notice) -> Option<String> {
445
532
  let session::Notice { session_id, event } = notice;
446
533
  let (name, params) = match event {
@@ -471,9 +558,17 @@ fn render_event(notice: session::Notice) -> Option<String> {
471
558
  "session.state",
472
559
  serde_json::json!({ "sessionId": session_id, "capture": capture, "transport": transport, "firstFrame": first_frame }),
473
560
  ),
474
- session::SessionEvent::Frame { seq, damage } => (
561
+ session::SessionEvent::Cursor { position } => (
562
+ "session.cursor",
563
+ serde_json::json!({ "sessionId": session_id, "x": position.x, "y": position.y, "visible": position.visible, "timestamp_us": position.timestamp_us }),
564
+ ),
565
+ session::SessionEvent::Frame {
566
+ seq,
567
+ damage,
568
+ timestamp_us,
569
+ } => (
475
570
  "session.frame.changed",
476
- serde_json::json!({ "sessionId": session_id, "seq": seq, "damage": damage }),
571
+ serde_json::json!({ "sessionId": session_id, "seq": seq, "damage": damage, "timestamp_us": timestamp_us }),
477
572
  ),
478
573
  session::SessionEvent::Input { input } => (
479
574
  "session.input",
@@ -503,7 +598,10 @@ fn render_event(notice: session::Notice) -> Option<String> {
503
598
  .ok()
504
599
  }
505
600
 
506
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
601
+ #[cfg(any(
602
+ target_os = "linux",
603
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
604
+ ))]
507
605
  async fn dispatch(
508
606
  request: &Request,
509
607
  hello_seen: &mut bool,
@@ -546,7 +644,7 @@ async fn dispatch(
546
644
  .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
547
645
  let open = session::OpenRequest {
548
646
  source: params.source,
549
- permissions: params.permissions,
647
+ cursor: params.cursor,
550
648
  max_width: params.max_width,
551
649
  max_height: params.max_height,
552
650
  bitrate_kbps: params.bitrate_kbps,
@@ -580,10 +678,22 @@ async fn dispatch(
580
678
  "origin": { "x": session.source().origin_x, "y": session.source().origin_y },
581
679
  },
582
680
  "geometry": session.geometry(),
681
+ "cursor": session.cursor_info(),
682
+ "agentIndicator": session.agent_indicator(),
583
683
  });
584
684
  *current = Some(session);
585
685
  Ok(result)
586
686
  }
687
+ "session.point" => {
688
+ let session = require_session(current)?;
689
+ let params: protocol::PointParams = serde_json::from_value(request.params.clone())
690
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
691
+ check_session(session, &params.session_id, None)?;
692
+ session
693
+ .point(&params)
694
+ .map_err(|error| ErrorBody::new(error.code, error.message))?;
695
+ Ok(serde_json::json!({"shown": !params.clear}))
696
+ }
587
697
  "session.description" => {
588
698
  let session = require_session(current)?;
589
699
  let params: protocol::DescriptionParams =
@@ -705,7 +815,10 @@ async fn dispatch(
705
815
  }
706
816
  }
707
817
 
708
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
818
+ #[cfg(any(
819
+ target_os = "linux",
820
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
821
+ ))]
709
822
  fn require_session(
710
823
  current: &mut Option<session::Session>,
711
824
  ) -> std::result::Result<&mut session::Session, ErrorBody> {
@@ -716,7 +829,10 @@ fn require_session(
716
829
 
717
830
  /// A late message for a session that has already ended must not revive it, and a
718
831
  /// message for a different session must not reach this one.
719
- #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
832
+ #[cfg(any(
833
+ target_os = "linux",
834
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
835
+ ))]
720
836
  fn check_session(
721
837
  session: &session::Session,
722
838
  id: &str,
@@ -741,6 +857,11 @@ fn main() {
741
857
  std::process::exit(mac::run());
742
858
  }
743
859
 
860
+ #[cfg(target_os = "windows")]
861
+ fn main() {
862
+ std::process::exit(win::run());
863
+ }
864
+
744
865
  #[cfg(all(test, target_os = "linux"))]
745
866
  mod tests {
746
867
  use super::*;
@@ -762,6 +883,27 @@ mod tests {
762
883
  assert!(!hello_seen, "a refused handshake must not open the gate");
763
884
  }
764
885
 
886
+ #[test]
887
+ fn cursor_notice_preserves_source_coordinates_visibility_and_clock() {
888
+ let line = render_event(session::Notice {
889
+ session_id: String::from("cursor-session"),
890
+ event: session::SessionEvent::Cursor {
891
+ position: protocol::CursorPosition {
892
+ x: 32,
893
+ y: 64,
894
+ visible: false,
895
+ timestamp_us: 123456,
896
+ },
897
+ },
898
+ })
899
+ .unwrap();
900
+ let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
901
+ assert_eq!(
902
+ parsed,
903
+ serde_json::json!({"event":"session.cursor","params":{"sessionId":"cursor-session","x":32,"y":64,"visible":false,"timestamp_us":123456}})
904
+ );
905
+ }
906
+
765
907
  #[tokio::test]
766
908
  async fn the_state_notification_carries_exactly_what_the_document_promises() {
767
909
  let line = render_event(session::Notice {
@@ -5,10 +5,10 @@
5
5
  //! module never assumes anything about that channel, which is what keeps the
6
6
  //! engine usable by an application that is not this one.
7
7
  //!
8
- //! Input and clipboard ride the session's data channel rather than the local
9
- //! protocol. The channel is inside the DTLS/SRTP session the engine created for
10
- //! one authorized grant, so it inherits that session's identity and dies with
11
- //! it — a revoked session cannot be driven by a client that kept a socket open.
8
+ //! Remote input and clipboard ride the session's data channel; co-located
9
+ //! consumers can also use the local clipboard protocol. The channel is inside
10
+ //! the DTLS/SRTP session opened by a paired consumer and dies with it — a revoked
11
+ //! session cannot be driven by a client that kept a socket open.
12
12
 
13
13
  use anyhow::{Context, Result};
14
14
  use rtc::interceptor::{
@@ -1120,6 +1120,12 @@ impl VideoPeer {
1120
1120
  }
1121
1121
 
1122
1122
  pub async fn close(&self) {
1123
+ let _ = tokio::time::timeout(Duration::from_millis(500), async {
1124
+ while self.control.outstanding_bytes().await.unwrap_or(0) > 0 {
1125
+ tokio::time::sleep(Duration::from_millis(5)).await;
1126
+ }
1127
+ })
1128
+ .await;
1123
1129
  self.feedback.abort();
1124
1130
  let _ = self.control.close().await;
1125
1131
  let _ = self.peer.close().await;
@@ -33,6 +33,7 @@ pub struct SelectedSource {
33
33
 
34
34
  pub struct PortalSession {
35
35
  pub fd: OwnedFd,
36
+ pub cursor_mode: &'static str,
36
37
  pub source: SelectedSource,
37
38
  pub restore_token: Option<String>,
38
39
  }
@@ -46,7 +47,10 @@ pub async fn open_display(_: Option<u32>) -> Result<PortalSession> {
46
47
  anyhow::bail!("display selection is only supported on macOS")
47
48
  }
48
49
 
49
- pub async fn open(restore_token: Option<&str>) -> Result<PortalSession> {
50
+ pub async fn open(
51
+ restore_token: Option<&str>,
52
+ cursor: crate::protocol::CursorMode,
53
+ ) -> Result<PortalSession> {
50
54
  let proxy = Screencast::new()
51
55
  .await
52
56
  .context("compositor has no ScreenCast portal")?;
@@ -59,6 +63,15 @@ pub async fn open(restore_token: Option<&str>) -> Result<PortalSession> {
59
63
  anyhow::bail!("ScreenCast portal advertises no monitor source");
60
64
  }
61
65
 
66
+ let cursor_mode = if cursor == crate::protocol::CursorMode::Hidden {
67
+ let available = proxy
68
+ .available_cursor_modes()
69
+ .await
70
+ .context("portal did not advertise cursor modes")?;
71
+ choose_cursor_mode(available)?
72
+ } else {
73
+ CursorMode::Embedded
74
+ };
62
75
  let session = proxy
63
76
  .create_session()
64
77
  .await
@@ -67,9 +80,7 @@ pub async fn open(restore_token: Option<&str>) -> Result<PortalSession> {
67
80
  proxy
68
81
  .select_sources(
69
82
  &session,
70
- // The cursor is part of the picture the user drives; hiding it would
71
- // make precise placement impossible.
72
- CursorMode::Embedded,
83
+ cursor_mode,
73
84
  BitFlags::from(SourceType::Monitor),
74
85
  false,
75
86
  restore_token,
@@ -104,6 +115,11 @@ pub async fn open(restore_token: Option<&str>) -> Result<PortalSession> {
104
115
  .context("OpenPipeWireRemote failed")?;
105
116
 
106
117
  Ok(PortalSession {
118
+ cursor_mode: match cursor_mode {
119
+ CursorMode::Metadata => "metadata",
120
+ CursorMode::Hidden => "hidden",
121
+ _ => "embedded",
122
+ },
107
123
  fd,
108
124
  source: SelectedSource {
109
125
  node_id: stream.pipe_wire_node_id(),
@@ -117,3 +133,25 @@ pub async fn open(restore_token: Option<&str>) -> Result<PortalSession> {
117
133
  restore_token: streams.restore_token().map(str::to_owned),
118
134
  })
119
135
  }
136
+
137
+ fn choose_cursor_mode(available: BitFlags<CursorMode>) -> Result<CursorMode> {
138
+ if available.contains(CursorMode::Metadata) {
139
+ return Ok(CursorMode::Metadata);
140
+ }
141
+ anyhow::bail!("cursor_positions_unavailable: portal does not provide cursor metadata")
142
+ }
143
+
144
+ #[cfg(test)]
145
+ mod tests {
146
+ use super::*;
147
+ #[test]
148
+ fn hidden_requires_metadata_and_refuses_hidden_only_portals() {
149
+ assert_eq!(
150
+ choose_cursor_mode(CursorMode::Metadata | CursorMode::Hidden).unwrap(),
151
+ CursorMode::Metadata
152
+ );
153
+ let error = choose_cursor_mode(CursorMode::Hidden.into()).unwrap_err();
154
+ assert!(error.to_string().contains("cursor_positions_unavailable:"));
155
+ assert!(choose_cursor_mode(CursorMode::Embedded.into()).is_err());
156
+ }
157
+ }