@desklink/host 0.1.0 → 0.1.1

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 (45) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +50 -16
  3. package/bin/desklink-host.mjs +13 -2
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js +5 -1
  6. package/dist/bridge.js.map +1 -1
  7. package/dist/engineProcess.d.ts.map +1 -1
  8. package/dist/engineProcess.js +17 -5
  9. package/dist/engineProcess.js.map +1 -1
  10. package/dist/protocol.d.ts +42 -5
  11. package/dist/protocol.d.ts.map +1 -1
  12. package/dist/protocol.js +1 -1
  13. package/dist/protocol.js.map +1 -1
  14. package/dist/resolveEngine.d.ts +1 -0
  15. package/dist/resolveEngine.d.ts.map +1 -1
  16. package/dist/resolveEngine.js +16 -1
  17. package/dist/resolveEngine.js.map +1 -1
  18. package/docs/BOUNDARY.md +4 -3
  19. package/docs/PROTOCOL.md +95 -27
  20. package/engine/Cargo.lock +195 -1
  21. package/engine/Cargo.toml +25 -5
  22. package/engine/build.rs +98 -23
  23. package/engine/native/agent_overlay_mac.m +133 -0
  24. package/engine/native/agent_overlay_wayland.c +192 -0
  25. package/engine/native/agent_overlay_x11.c +103 -0
  26. package/engine/native/mac_stream.mm +188 -0
  27. package/engine/src/capture.rs +169 -19
  28. package/engine/src/capture_mac.rs +145 -0
  29. package/engine/src/clipboard_mac.rs +182 -0
  30. package/engine/src/convert.rs +57 -1
  31. package/engine/src/indicator.rs +162 -0
  32. package/engine/src/input.rs +12 -0
  33. package/engine/src/input_mac.rs +312 -0
  34. package/engine/src/keymap.rs +25 -0
  35. package/engine/src/keymap_mac.rs +209 -0
  36. package/engine/src/mac.rs +722 -0
  37. package/engine/src/main.rs +116 -2
  38. package/engine/src/portal.rs +4 -0
  39. package/engine/src/portal_mac.rs +68 -0
  40. package/engine/src/protocol.rs +48 -1
  41. package/engine/src/session.rs +556 -62
  42. package/engine/src/x11.rs +23 -3
  43. package/engine/src/x11_mac.rs +40 -0
  44. package/examples/reference-client.html +33 -4
  45. package/package.json +2 -2
@@ -6,25 +6,75 @@
6
6
  //! process, owns its stdin/stdout, and carries the SDP/ICE the engine produces
7
7
  //! over whatever authenticated channel it already has.
8
8
 
9
+ #[cfg(target_os = "linux")]
9
10
  mod capture;
11
+ #[cfg(target_os = "macos")]
12
+ #[path = "capture_mac.rs"]
13
+ mod capture;
14
+ #[cfg(target_os = "linux")]
15
+ mod clipboard;
16
+ #[cfg(target_os = "macos")]
17
+ #[path = "clipboard_mac.rs"]
10
18
  mod clipboard;
19
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
11
20
  mod convert;
21
+ #[cfg(target_os = "linux")]
12
22
  mod encoder;
23
+ #[cfg(all(target_os = "macos", desklink_vpx))]
24
+ mod encoder;
25
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
26
+ mod indicator;
27
+ #[cfg(target_os = "linux")]
28
+ mod input;
29
+ #[cfg(target_os = "macos")]
30
+ #[path = "input_mac.rs"]
13
31
  mod input;
32
+ #[cfg(target_os = "linux")]
14
33
  mod keymap;
34
+ #[cfg(target_os = "macos")]
35
+ #[path = "keymap_mac.rs"]
36
+ mod keymap;
37
+ #[cfg(target_os = "macos")]
38
+ mod mac;
39
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
15
40
  mod peer;
41
+ #[cfg(target_os = "linux")]
42
+ mod portal;
43
+ #[cfg(target_os = "macos")]
44
+ #[path = "portal_mac.rs"]
16
45
  mod portal;
46
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
17
47
  mod protocol;
48
+ #[cfg(target_os = "linux")]
49
+ mod session;
50
+ #[cfg(all(target_os = "macos", desklink_vpx))]
18
51
  mod session;
52
+ #[cfg(target_os = "linux")]
53
+ mod x11;
54
+ #[cfg(target_os = "macos")]
55
+ #[path = "x11_mac.rs"]
19
56
  mod x11;
20
57
 
58
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
21
59
  use anyhow::Result;
60
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
22
61
  use protocol::{ErrorBody, Event, Request, Response};
62
+ #[cfg(target_os = "linux")]
23
63
  use std::sync::mpsc as std_mpsc;
64
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
24
65
  use std::time::Duration;
66
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
25
67
  use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
68
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
26
69
  use tokio::sync::mpsc as tokio_mpsc;
27
70
 
71
+ #[cfg(target_os = "linux")]
72
+ unsafe extern "C" {
73
+ fn desklink_agent_overlay_main(display: *const std::ffi::c_char) -> i32;
74
+ fn desklink_agent_overlay_wayland(source_w: i32, source_h: i32) -> i32;
75
+ }
76
+
77
+ #[cfg(target_os = "linux")]
28
78
  fn main() {
29
79
  let args: Vec<String> = std::env::args().skip(1).collect();
30
80
  let command = args.first().map(String::as_str).unwrap_or("help");
@@ -43,6 +93,20 @@ fn main() {
43
93
  println!("{}", env!("CARGO_PKG_VERSION"));
44
94
  0
45
95
  }
96
+ "agent-overlay" => {
97
+ let display = args.get(1).map(String::as_str).unwrap_or("");
98
+ let display = std::ffi::CString::new(display).expect("valid X display");
99
+ unsafe { desklink_agent_overlay_main(display.as_ptr()) }
100
+ }
101
+ "agent-overlay-wayland" => {
102
+ let width = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(0);
103
+ let height = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(0);
104
+ if width <= 0 || height <= 0 {
105
+ 2
106
+ } else {
107
+ unsafe { desklink_agent_overlay_wayland(width, height) }
108
+ }
109
+ }
46
110
  "capabilities" => {
47
111
  println!(
48
112
  "{}",
@@ -72,6 +136,7 @@ fn main() {
72
136
  std::process::exit(code);
73
137
  }
74
138
 
139
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
75
140
  fn report(result: Result<()>) -> i32 {
76
141
  match result {
77
142
  Ok(()) => 0,
@@ -82,6 +147,7 @@ fn report(result: Result<()>) -> i32 {
82
147
  }
83
148
  }
84
149
 
150
+ #[cfg(target_os = "linux")]
85
151
  fn print_help() {
86
152
  println!(
87
153
  "desklink-host {}
@@ -105,6 +171,7 @@ anything. See docs/PROTOCOL.md for the protocol.",
105
171
 
106
172
  /// Deliberately does not perform the change. Kernel input access is a privileged
107
173
  /// decision, so the engine explains it and stops; the user runs the command.
174
+ #[cfg(target_os = "linux")]
108
175
  fn print_input_setup() {
109
176
  match input::probe() {
110
177
  Ok(()) => {
@@ -154,6 +221,7 @@ fn print_input_setup() {
154
221
 
155
222
  /// Start the portal session, capture a few frames, and print what actually
156
223
  /// arrived. This is the diagnostic to run when the picture does not appear.
224
+ #[cfg(target_os = "linux")]
157
225
  async fn probe(seconds: Option<u64>, display: Option<&str>) -> Result<()> {
158
226
  let seconds = seconds.unwrap_or(3);
159
227
  match display {
@@ -164,6 +232,7 @@ async fn probe(seconds: Option<u64>, display: Option<&str>) -> Result<()> {
164
232
 
165
233
  /// Read a named X display's root window for a few seconds. The frame shape is
166
234
  /// reported as a coarse checksum so a flat or unreadable screen is visible.
235
+ #[cfg(target_os = "linux")]
167
236
  fn probe_x11(display: &str, seconds: u64) -> Result<()> {
168
237
  let mut desktop = crate::x11::X11Desktop::connect(Some(display))?;
169
238
  let (width, height) = desktop.screen_size();
@@ -195,6 +264,7 @@ fn probe_x11(display: &str, seconds: u64) -> Result<()> {
195
264
 
196
265
  /// A cheap fingerprint of a frame, enough to tell "the screen changed" from
197
266
  /// "the screen is one flat colour".
267
+ #[cfg(target_os = "linux")]
198
268
  fn frame_shape(frame: &crate::convert::I420) -> u64 {
199
269
  frame
200
270
  .y_plane()
@@ -207,6 +277,7 @@ fn frame_shape(frame: &crate::convert::I420) -> u64 {
207
277
  })
208
278
  }
209
279
 
280
+ #[cfg(target_os = "linux")]
210
281
  async fn probe_portal(seconds: u64) -> Result<()> {
211
282
  let portal = portal::open(None).await?;
212
283
  eprintln!("portal source: {:?}", portal.source);
@@ -217,9 +288,11 @@ async fn probe_portal(seconds: u64) -> Result<()> {
217
288
  0,
218
289
  0,
219
290
  30,
220
- Box::new(move |frame, seq| {
291
+ Box::new(move |frame, seq, _raw| {
221
292
  let _ = tx.send((frame.width, frame.height, seq));
222
293
  }),
294
+ None,
295
+ Box::new(|reason| eprintln!("{reason}")),
223
296
  )?;
224
297
 
225
298
  let deadline = std::time::Instant::now() + Duration::from_secs(seconds);
@@ -253,6 +326,7 @@ async fn probe_portal(seconds: u64) -> Result<()> {
253
326
  }
254
327
 
255
328
  /// The local control protocol loop.
329
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
256
330
  async fn serve() -> Result<()> {
257
331
  let (out_tx, mut out_rx) = tokio_mpsc::unbounded_channel::<String>();
258
332
  let writer = tokio::spawn(async move {
@@ -343,6 +417,7 @@ async fn serve() -> Result<()> {
343
417
  Ok(())
344
418
  }
345
419
 
420
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
346
421
  fn render_event(notice: session::Notice) -> Option<String> {
347
422
  let session::Notice { session_id, event } = notice;
348
423
  let (name, params) = match event {
@@ -373,10 +448,18 @@ fn render_event(notice: session::Notice) -> Option<String> {
373
448
  "session.state",
374
449
  serde_json::json!({ "sessionId": session_id, "capture": capture, "transport": transport, "firstFrame": first_frame }),
375
450
  ),
451
+ session::SessionEvent::Frame { seq, damage } => (
452
+ "session.frame.changed",
453
+ serde_json::json!({ "sessionId": session_id, "seq": seq, "damage": damage }),
454
+ ),
376
455
  session::SessionEvent::RestoreToken(token) => (
377
456
  "session.restoreToken",
378
457
  serde_json::json!({ "sessionId": session_id, "token": token }),
379
458
  ),
459
+ session::SessionEvent::CaptureStopped { reason } => (
460
+ "session.capture.stopped",
461
+ serde_json::json!({ "sessionId": session_id, "reason": reason }),
462
+ ),
380
463
  session::SessionEvent::Revoked { reason } => (
381
464
  "session.revoked",
382
465
  serde_json::json!({ "sessionId": session_id, "reason": reason }),
@@ -389,6 +472,7 @@ fn render_event(notice: session::Notice) -> Option<String> {
389
472
  .ok()
390
473
  }
391
474
 
475
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
392
476
  async fn dispatch(
393
477
  request: &Request,
394
478
  hello_seen: &mut bool,
@@ -439,6 +523,7 @@ async fn dispatch(
439
523
  restore_token: params.restore_token,
440
524
  ttl: params.ttl_seconds.map(Duration::from_secs),
441
525
  loopback_tcp: params.loopback_tcp,
526
+ agent_indicator: params.agent_indicator,
442
527
  ice_servers: params
443
528
  .ice_servers
444
529
  .into_iter()
@@ -518,6 +603,28 @@ async fn dispatch(
518
603
  .map_err(|reason| ErrorBody::new("clipboard", reason))?;
519
604
  Ok(serde_json::json!({ "written": true }))
520
605
  }
606
+ "session.frame" => {
607
+ let session = require_session(current)?;
608
+ let params: protocol::FrameParams = serde_json::from_value(request.params.clone())
609
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
610
+ check_session(session, &params.session_id, None)?;
611
+ if params.after_seq.is_some()
612
+ || params.still_ms.is_some()
613
+ || params.timeout_ms.is_some()
614
+ {
615
+ session
616
+ .wait_frame(
617
+ params.after_seq,
618
+ params.still_ms,
619
+ params.timeout_ms.unwrap_or(5000),
620
+ )
621
+ .await
622
+ .map_err(|error| ErrorBody::new(error.code, error.message))?;
623
+ }
624
+ session
625
+ .frame(params.since, &params.path, params.region)
626
+ .map_err(|error| ErrorBody::new(error.code, error.message))
627
+ }
521
628
  "session.metrics" => {
522
629
  let session = require_session(current)?;
523
630
  Ok(serde_json::to_value(session.metrics()).unwrap_or(serde_json::Value::Null))
@@ -538,6 +645,7 @@ async fn dispatch(
538
645
  }
539
646
  }
540
647
 
648
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
541
649
  fn require_session(
542
650
  current: &mut Option<session::Session>,
543
651
  ) -> std::result::Result<&mut session::Session, ErrorBody> {
@@ -548,6 +656,7 @@ fn require_session(
548
656
 
549
657
  /// A late message for a session that has already ended must not revive it, and a
550
658
  /// message for a different session must not reach this one.
659
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
551
660
  fn check_session(
552
661
  session: &session::Session,
553
662
  id: &str,
@@ -567,7 +676,12 @@ fn check_session(
567
676
  Ok(())
568
677
  }
569
678
 
570
- #[cfg(test)]
679
+ #[cfg(target_os = "macos")]
680
+ fn main() {
681
+ std::process::exit(mac::run());
682
+ }
683
+
684
+ #[cfg(all(test, target_os = "linux"))]
571
685
  mod tests {
572
686
  use super::*;
573
687
 
@@ -42,6 +42,10 @@ pub struct PortalSession {
42
42
  /// `restore_token` is a token from an earlier session; supplying it lets a
43
43
  /// backend skip the picker when its policy allows it. The returned token must be
44
44
  /// persisted by the consumer and is single-use.
45
+ pub async fn open_display(_: Option<u32>) -> Result<PortalSession> {
46
+ anyhow::bail!("display selection is only supported on macOS")
47
+ }
48
+
45
49
  pub async fn open(restore_token: Option<&str>) -> Result<PortalSession> {
46
50
  let proxy = Screencast::new()
47
51
  .await
@@ -0,0 +1,68 @@
1
+ use anyhow::{bail, Result};
2
+ use serde_json::Value;
3
+
4
+ /// A selected macOS display. `node_id` is its CoreGraphics display identifier.
5
+ #[derive(Debug, Clone, serde::Serialize)]
6
+ pub struct SelectedSource {
7
+ pub node_id: u32,
8
+ pub width: i32,
9
+ pub height: i32,
10
+ #[serde(skip_serializing_if = "Option::is_none")]
11
+ pub position: Option<(i32, i32)>,
12
+ #[serde(skip_serializing_if = "Option::is_none")]
13
+ pub source_type: Option<String>,
14
+ pub origin_x: i32,
15
+ pub origin_y: i32,
16
+ }
17
+
18
+ pub struct PortalSession {
19
+ pub source: SelectedSource,
20
+ pub restore_token: Option<String>,
21
+ }
22
+
23
+ pub async fn open(_restore_token: Option<&str>) -> Result<PortalSession> {
24
+ open_display_inner(None)
25
+ }
26
+
27
+ pub async fn open_display(display_id: Option<u32>) -> Result<PortalSession> {
28
+ open_display_inner(display_id)
29
+ }
30
+
31
+ fn open_display_inner(display_id: Option<u32>) -> Result<PortalSession> {
32
+ if !unsafe { crate::mac::CGPreflightScreenCaptureAccess() } {
33
+ crate::mac::request_screen_capture_content().map_err(anyhow::Error::msg)?;
34
+ if !unsafe { crate::mac::CGPreflightScreenCaptureAccess() } {
35
+ bail!("capture-permission: allow {} in System Settings › Privacy & Security › Screen & System Audio Recording, then reconnect", crate::mac::tcc_responsible_app_name());
36
+ }
37
+ }
38
+ let displays = crate::mac::displays();
39
+ let selected = displays
40
+ .iter()
41
+ .find(|display| {
42
+ display_id.is_some_and(|id| display["id"].as_u64() == Some(id as u64))
43
+ || (display_id.is_none() && display["main"] == true)
44
+ })
45
+ .or_else(|| display_id.is_none().then(|| displays.first()).flatten())
46
+ .ok_or_else(|| anyhow::anyhow!("no active display is available on the Mac"))?;
47
+ let origin_x = selected
48
+ .pointer("/origin/x")
49
+ .and_then(Value::as_f64)
50
+ .unwrap_or(0.0) as i32;
51
+ let origin_y = selected
52
+ .pointer("/origin/y")
53
+ .and_then(Value::as_f64)
54
+ .unwrap_or(0.0) as i32;
55
+ let source = SelectedSource {
56
+ node_id: selected["id"].as_u64().unwrap_or_default() as u32,
57
+ width: selected["width"].as_u64().unwrap_or_default() as i32,
58
+ height: selected["height"].as_u64().unwrap_or_default() as i32,
59
+ position: Some((origin_x, origin_y)),
60
+ source_type: Some(String::from("display")),
61
+ origin_x,
62
+ origin_y,
63
+ };
64
+ Ok(PortalSession {
65
+ source,
66
+ restore_token: None,
67
+ })
68
+ }
@@ -3,7 +3,7 @@
3
3
 
4
4
  use serde::{Deserialize, Serialize};
5
5
 
6
- pub const PROTOCOL_VERSION: u32 = 2;
6
+ pub const PROTOCOL_VERSION: u32 = 3;
7
7
 
8
8
  /// A request from the consumer to the engine.
9
9
  #[derive(Debug, Deserialize)]
@@ -81,6 +81,10 @@ pub struct IceServerParam {
81
81
  #[serde(tag = "kind", rename_all = "snake_case")]
82
82
  pub enum SourceRequest {
83
83
  Portal,
84
+ Display {
85
+ #[serde(default)]
86
+ display_id: Option<u32>,
87
+ },
84
88
  X11 {
85
89
  #[serde(default)]
86
90
  display: Option<String>,
@@ -112,6 +116,9 @@ pub struct OpenParams {
112
116
  /// whose only way here is a local forward (an SSH tunnel).
113
117
  #[serde(default)]
114
118
  pub loopback_tcp: bool,
119
+ /// Show local, capture-excluded feedback for an agent controlling this session.
120
+ #[serde(default)]
121
+ pub agent_indicator: bool,
115
122
  }
116
123
 
117
124
  /// The encode box when the consumer names none: a desktop's own pixels up to
@@ -130,6 +137,23 @@ fn default_fps() -> u32 {
130
137
  30
131
138
  }
132
139
 
140
+ #[derive(Debug, Deserialize)]
141
+ pub struct FrameParams {
142
+ pub session_id: String,
143
+ #[serde(default)]
144
+ pub since: Option<u64>,
145
+ #[serde(default)]
146
+ pub after_seq: Option<u64>,
147
+ #[serde(default)]
148
+ pub still_ms: Option<u64>,
149
+ #[serde(default)]
150
+ pub timeout_ms: Option<u64>,
151
+ #[serde(default)]
152
+ pub path: String,
153
+ #[serde(default)]
154
+ pub region: Option<[usize; 4]>,
155
+ }
156
+
133
157
  #[derive(Debug, Deserialize)]
134
158
  pub struct SessionRef {
135
159
  pub session_id: String,
@@ -316,6 +340,21 @@ mod tests {
316
340
  assert!(serde_json::from_str::<ControlMessage>(r#"{"kind":"teleport"}"#).is_err());
317
341
  }
318
342
 
343
+ #[test]
344
+ fn display_source_round_trips_its_explicit_id() {
345
+ let request: SourceRequest = serde_json::from_value(serde_json::json!({
346
+ "kind": "display",
347
+ "display_id": 42
348
+ }))
349
+ .unwrap();
350
+ assert!(matches!(
351
+ request,
352
+ SourceRequest::Display {
353
+ display_id: Some(42)
354
+ }
355
+ ));
356
+ }
357
+
319
358
  #[test]
320
359
  fn open_defaults_keep_the_desktops_own_pixels_up_to_4k() {
321
360
  let params: OpenParams =
@@ -327,5 +366,13 @@ mod tests {
327
366
  "the engine sizes the rate to the surface"
328
367
  );
329
368
  assert_eq!(params.max_fps, 30);
369
+ assert!(
370
+ !params.agent_indicator,
371
+ "human control has no overlay by default"
372
+ );
373
+ let agent: OpenParams =
374
+ serde_json::from_str(r#"{"permissions":["view","control"],"agent_indicator":true}"#)
375
+ .unwrap();
376
+ assert!(agent.agent_indicator);
330
377
  }
331
378
  }