@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
@@ -61,11 +61,81 @@ pub struct Metrics {
61
61
  pub input_rejected: u64,
62
62
  }
63
63
 
64
- /// Which desktop is being captured.
65
- ///
66
- /// Dropping either variant stops its capture: the portal variant drops the
67
- /// PipeWire stream, which is what releases the compositor's consent, and the X11
68
- /// variant stops reading the server.
64
+ fn tile_hashes(raw: &[u8], width: usize, height: usize) -> Vec<u64> {
65
+ use std::hash::{Hash, Hasher};
66
+ let mut hashes = Vec::new();
67
+ for y in (0..height).step_by(32) {
68
+ for x in (0..width).step_by(32) {
69
+ let mut hash = std::collections::hash_map::DefaultHasher::new();
70
+ for row in y..(y + 32).min(height) {
71
+ raw[(row * width + x) * 4..(row * width + (x + 32).min(width)) * 4].hash(&mut hash);
72
+ }
73
+ hashes.push(hash.finish());
74
+ }
75
+ }
76
+ hashes
77
+ }
78
+
79
+ /// Group changed 32-pixel tiles without merging unrelated regions.
80
+ fn dirty_regions(hashes: &[u64], previous: &[u64], width: usize, height: usize) -> Vec<[usize; 4]> {
81
+ if hashes.len() != previous.len() {
82
+ return vec![[0, 0, width, height]];
83
+ }
84
+ let columns = width.div_ceil(32);
85
+ let mut dirty: Vec<bool> = hashes
86
+ .iter()
87
+ .zip(previous)
88
+ .map(|(now, before)| now != before)
89
+ .collect();
90
+ let mut regions = Vec::new();
91
+ for index in 0..dirty.len() {
92
+ if !dirty[index] {
93
+ continue;
94
+ }
95
+ dirty[index] = false;
96
+ let mut stack = vec![index];
97
+ let (mut left, mut top, mut right, mut bottom) = (
98
+ index % columns,
99
+ index / columns,
100
+ index % columns,
101
+ index / columns,
102
+ );
103
+ while let Some(tile) = stack.pop() {
104
+ let col = tile % columns;
105
+ let row = tile / columns;
106
+ left = left.min(col);
107
+ right = right.max(col);
108
+ top = top.min(row);
109
+ bottom = bottom.max(row);
110
+ for neighbor in [
111
+ tile.checked_sub(columns),
112
+ (row + 1 < height.div_ceil(32)).then_some(tile + columns),
113
+ (col > 0).then(|| tile - 1),
114
+ (col + 1 < columns).then_some(tile + 1),
115
+ ]
116
+ .into_iter()
117
+ .flatten()
118
+ {
119
+ if dirty[neighbor] {
120
+ dirty[neighbor] = false;
121
+ stack.push(neighbor);
122
+ }
123
+ }
124
+ }
125
+ let x = left * 32;
126
+ let y = top * 32;
127
+ regions.push([
128
+ x,
129
+ y,
130
+ ((right + 1) * 32).min(width) - x,
131
+ ((bottom + 1) * 32).min(height) - y,
132
+ ]);
133
+ }
134
+ regions
135
+ }
136
+
137
+ /// Dropping either variant stops its capture: the portal releases consent and
138
+ /// the X11 variant stops reading its server.
69
139
  enum FrameSource {
70
140
  Portal(Capture),
71
141
  X11(X11Capture),
@@ -103,6 +173,7 @@ fn select_x11(
103
173
  max_fps: u32,
104
174
  metrics: Arc<Mutex<Metrics>>,
105
175
  sink: capture::FrameSink,
176
+ on_stop: Box<dyn Fn(String) + Send>,
106
177
  ) -> Result<Selected> {
107
178
  let desktop = Arc::new(Mutex::new(X11Desktop::connect(display)?));
108
179
  let (width, height) = {
@@ -137,17 +208,17 @@ fn select_x11(
137
208
  let started = Instant::now();
138
209
  let frame = {
139
210
  let mut desktop = lock(&desktop);
140
- desktop.capture(max_width, max_height)
211
+ desktop.capture_with_pixels(max_width, max_height)
141
212
  };
142
213
  match frame {
143
- Ok(frame) => {
214
+ Ok((frame, raw)) => {
144
215
  use std::hash::{Hash, Hasher};
145
216
  let mut hasher = std::collections::hash_map::DefaultHasher::new();
146
- frame.data.hash(&mut hasher);
217
+ raw.hash(&mut hasher);
147
218
  let hash = hasher.finish();
148
219
  if last_hash != Some(hash) {
149
220
  last_hash = Some(hash);
150
- sink(frame, sequence);
221
+ sink(frame, sequence, raw);
151
222
  sequence += 1;
152
223
  }
153
224
  }
@@ -158,6 +229,7 @@ fn select_x11(
158
229
  // A display that has gone away is not recoverable by
159
230
  // retrying, so stop rather than spin on the error.
160
231
  if error.to_string().contains("the X11 connection dropped") {
232
+ on_stop(format!("X11 capture stopped: {error}"));
161
233
  break;
162
234
  }
163
235
  }
@@ -312,6 +384,19 @@ impl InputTarget {
312
384
  Ok(())
313
385
  }
314
386
 
387
+ #[cfg(target_os = "macos")]
388
+ fn unicode_text(&mut self, text: &str) -> Result<()> {
389
+ match &mut self.applier {
390
+ #[cfg(target_os = "macos")]
391
+ Applier::Uinput(devices) => devices.unicode_text(text),
392
+ #[cfg(target_os = "linux")]
393
+ Applier::Uinput(_) => anyhow::bail!("Unicode text input is unavailable on Linux"),
394
+ Applier::X11(_) => anyhow::bail!("Unicode text input is unavailable for X11"),
395
+ #[cfg(test)]
396
+ Applier::Recording(_) => Ok(()),
397
+ }
398
+ }
399
+
315
400
  fn chord(&mut self, code: i16, modifiers: Vec<i16>, down: bool) -> Result<()> {
316
401
  if down {
317
402
  if self.chord_keys.iter().any(|(held, _)| *held == code) {
@@ -412,6 +497,7 @@ pub struct OpenRequest {
412
497
  pub ttl: Option<Duration>,
413
498
  /// Offer a passive ICE-TCP candidate on the loopback, for a forwarded client.
414
499
  pub loopback_tcp: bool,
500
+ pub agent_indicator: bool,
415
501
  }
416
502
 
417
503
  /// Events a session raises for its consumer.
@@ -432,7 +518,12 @@ pub enum SessionEvent {
432
518
  transport: String,
433
519
  first_frame: bool,
434
520
  },
521
+ Frame {
522
+ seq: u64,
523
+ damage: Vec<[usize; 4]>,
524
+ },
435
525
  RestoreToken(String),
526
+ CaptureStopped { reason: String },
436
527
  Revoked {
437
528
  reason: String,
438
529
  },
@@ -447,6 +538,8 @@ pub struct Notice {
447
538
  pub event: SessionEvent,
448
539
  }
449
540
 
541
+ type FrameSnapshot = (u64, Instant, usize, usize, Vec<u8>, Vec<u64>, Vec<u64>);
542
+
450
543
  /// Everything a session's background tasks need, shared rather than borrowed so
451
544
  /// the consumer can own the `Session` handle while the pipeline runs.
452
545
  struct Inner {
@@ -456,9 +549,13 @@ struct Inner {
456
549
  source: SelectedSource,
457
550
  geometry: serde_json::Value,
458
551
  metrics: Arc<Mutex<Metrics>>,
552
+ latest: Arc<Mutex<Option<FrameSnapshot>>>,
553
+ capture_error: Arc<Mutex<Option<String>>>,
554
+ frame_changes: tokio::sync::watch::Receiver<u64>,
459
555
  peer: Arc<VideoPeer>,
460
556
  encoder: Mutex<Encoder>,
461
557
  input: Mutex<Option<InputTarget>>,
558
+ indicator: Mutex<Option<crate::indicator::Indicator>>,
462
559
  capture: Mutex<Option<FrameSource>>,
463
560
  layout: Mutex<Layout>,
464
561
  last_seq: Mutex<u64>,
@@ -474,12 +571,14 @@ pub struct Session {
474
571
  inner: Arc<Inner>,
475
572
  }
476
573
 
574
+ #[cfg(target_os = "linux")]
477
575
  fn wayland_clipboard_available() -> bool {
478
576
  std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("wayland")
479
577
  && std::env::var_os("WAYLAND_DISPLAY").is_some()
480
578
  }
481
579
 
482
580
  /// What the engine can actually do on this machine right now.
581
+ #[cfg(target_os = "linux")]
483
582
  pub fn capabilities() -> serde_json::Value {
484
583
  let session_kind = if std::env::var_os("WAYLAND_DISPLAY").is_some() {
485
584
  "wayland"
@@ -537,32 +636,149 @@ pub fn capabilities() -> serde_json::Value {
537
636
  })
538
637
  }
539
638
 
639
+ #[cfg(target_os = "macos")]
640
+ fn wayland_clipboard_available() -> bool {
641
+ true
642
+ }
643
+
644
+ #[cfg(target_os = "macos")]
645
+ pub fn capabilities() -> serde_json::Value {
646
+ let capture_granted = unsafe { crate::mac::CGPreflightScreenCaptureAccess() };
647
+ let (input, input_grant, unavailable) = match crate::input::probe() {
648
+ Ok(()) => (true, "granted", serde_json::Value::Null),
649
+ Err(error) => (
650
+ false,
651
+ "missing-accessibility",
652
+ serde_json::json!({
653
+ "reason": error.reason,
654
+ "remedy": error.remedy,
655
+ }),
656
+ ),
657
+ };
658
+ serde_json::json!({
659
+ "protocol": crate::protocol::PROTOCOL_VERSION,
660
+ "engine": format!("desklink-host/{}", env!("CARGO_PKG_VERSION")),
661
+ "platform": "macos",
662
+ "session": { "kind": "quartz", "on_console": crate::mac::on_console() },
663
+ "x11": { "available": false, "size": null },
664
+ "capture": {
665
+ "mechanism": "screencapturekit",
666
+ "backends": ["screencapturekit"],
667
+ "formats": ["bgra"],
668
+ "cursor": "embedded",
669
+ "audio": false,
670
+ "displays": crate::mac::displays(),
671
+ "grant": if capture_granted { "granted" } else { "missing-screen-recording" },
672
+ "unavailable_reason": if capture_granted { serde_json::Value::Null } else { serde_json::json!({
673
+ "reason": format!("Screen Recording permission has not been granted to {}.", crate::mac::tcc_responsible_app_name()),
674
+ "remedy": format!("Allow {} in System Settings › Privacy & Security › Screen & System Audio Recording, then reconnect.", crate::mac::tcc_responsible_app_name())
675
+ }) }
676
+ },
677
+ "encode": { "codecs": ["vp9"], "hardware": false },
678
+ "input": {
679
+ "mechanism": "quartz-cgevent",
680
+ "pointer": input,
681
+ "wheel": input,
682
+ "keyboard": input,
683
+ "text": ["unicode", "us-ansi-keymap"],
684
+ "layout": keymap::LayoutNames::from_environment().identity(),
685
+ "unavailable_reason": unavailable,
686
+ "grant": input_grant,
687
+ },
688
+ "clipboard": { "read": true, "write": true, "mime": ["text/plain;charset=utf-8"],
689
+ "maxBytes": clipboard::MAX_CLIPBOARD_BYTES },
690
+ })
691
+ }
692
+
540
693
  impl Session {
541
694
  pub async fn open(
542
695
  request: OpenRequest,
543
696
  events: tokio_mpsc::UnboundedSender<Notice>,
544
697
  ) -> std::result::Result<Self, SessionError> {
698
+ if !request.permissions.contains(&Permission::View) {
699
+ return Err(SessionError::new(
700
+ "permission",
701
+ "session.open requires the view permission",
702
+ ));
703
+ }
545
704
  let id = opaque_id();
546
705
  let wants_control = request.permissions.contains(&Permission::Control);
547
706
  let wants_x11 = matches!(request.source, Some(SourceRequest::X11 { .. }));
707
+ let max_fps = request.max_fps.clamp(1, 60);
548
708
  // Which desktop decides which input path is even available: an X display
549
709
  // takes XTest, which cannot reach any other session, while a portal
550
710
  // desktop needs kernel input access.
551
711
  if wants_control && !wants_x11 {
552
712
  // Refuse up front rather than presenting a control surface that
553
713
  // silently does nothing.
554
- if let Err(unavailable) = crate::input::probe() {
555
- return Err(SessionError::new(
556
- "input-unavailable",
557
- format!("{}; {}", unavailable.reason, unavailable.remedy),
558
- ));
714
+ if crate::input::probe().is_err() {
715
+ #[cfg(target_os = "macos")]
716
+ let _ = crate::input::request_access();
717
+ if let Err(unavailable) = crate::input::probe() {
718
+ return Err(SessionError::new(
719
+ "input-unavailable",
720
+ format!("{}; {}", unavailable.reason, unavailable.remedy),
721
+ ));
722
+ }
559
723
  }
560
724
  }
561
725
 
562
726
  let metrics = Arc::new(Mutex::new(Metrics::default()));
727
+ let mut indicator = None;
563
728
  let (frame_tx, frame_rx) = latest_frame();
729
+ let latest = Arc::new(Mutex::new(None::<FrameSnapshot>));
730
+ let observed = latest.clone();
731
+ let frame_sequence = Arc::new(std::sync::atomic::AtomicU64::new(0));
732
+ let capture_error = Arc::new(Mutex::new(None::<String>));
733
+ let stopped_error = capture_error.clone();
734
+ let stopped_latest = latest.clone();
735
+ let stopped_events = events.clone();
736
+ let stopped_id = id.clone();
737
+ let (frame_tx_signal, frame_changes) = tokio::sync::watch::channel(0u64);
564
738
  let captured = metrics.clone();
565
- let sink = Box::new(move |frame: I420, _seq: u64| {
739
+ let stop_signal = frame_tx_signal.clone();
740
+ let on_capture_status = move |running: bool, reason: String| {
741
+ if let Ok(mut error) = stopped_error.lock() { *error = if running { None } else { Some(reason.clone()) }; }
742
+ if let Ok(mut frame) = stopped_latest.lock() { *frame = None; }
743
+ stop_signal.send_replace(0);
744
+ let _ = stopped_events.send(Notice { session_id: stopped_id.clone(),
745
+ event: if running { SessionEvent::State { capture: "streaming", transport: String::from("connected"), first_frame: false } }
746
+ else { SessionEvent::CaptureStopped { reason } } });
747
+ };
748
+ let capture_status = Arc::new(on_capture_status);
749
+ let frame_events = events.clone();
750
+ let frame_session_id = id.clone();
751
+ let sink = Box::new(move |frame: I420, _seq: u64, raw: Vec<u8>| {
752
+ let hashes = tile_hashes(&raw, frame.width, frame.height);
753
+ if let Ok(mut held) = observed.lock() {
754
+ let unchanged =
755
+ held.as_ref()
756
+ .is_some_and(|(_, _, width, height, _, current, _)| {
757
+ *width == frame.width && *height == frame.height && current == &hashes
758
+ });
759
+ if !unchanged {
760
+ let seq = frame_sequence.fetch_add(1, Ordering::Relaxed) + 1;
761
+ let previous = held
762
+ .as_ref()
763
+ .map(|(_, _, _, _, _, current, _)| current.clone())
764
+ .unwrap_or_default();
765
+ let damage = dirty_regions(&hashes, &previous, frame.width, frame.height);
766
+ *held = Some((
767
+ seq,
768
+ Instant::now(),
769
+ frame.width,
770
+ frame.height,
771
+ raw,
772
+ hashes,
773
+ previous,
774
+ ));
775
+ frame_tx_signal.send_replace(seq);
776
+ let _ = frame_events.send(Notice {
777
+ session_id: frame_session_id.clone(),
778
+ event: SessionEvent::Frame { seq, damage },
779
+ });
780
+ }
781
+ }
566
782
  if let Ok(mut m) = captured.lock() {
567
783
  m.captured_frames += 1;
568
784
  }
@@ -584,15 +800,20 @@ impl Session {
584
800
  display.as_deref(),
585
801
  request.max_width,
586
802
  request.max_height,
587
- request.max_fps,
803
+ max_fps,
588
804
  metrics.clone(),
589
805
  sink,
806
+ Box::new({ let status = capture_status.clone(); move |reason| status(false, reason) }),
590
807
  )
591
808
  .map_err(|error| SessionError::new("source", format!("{error:#}")))?,
592
809
  _ => {
593
- let portal = portal::open(request.restore_token.as_deref())
594
- .await
595
- .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
810
+ let portal = match request.source.as_ref() {
811
+ Some(SourceRequest::Display { display_id }) => {
812
+ portal::open_display(*display_id).await
813
+ }
814
+ _ => portal::open(request.restore_token.as_deref()).await,
815
+ }
816
+ .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
596
817
  if let Some(token) = &portal.restore_token {
597
818
  let _ = events.send(Notice {
598
819
  session_id: id.clone(),
@@ -604,8 +825,53 @@ impl Session {
604
825
  let source_h = source.height.max(1) as usize;
605
826
  let (width, height) =
606
827
  fit(source_w, source_h, request.max_width, request.max_height);
607
- let capture = capture::start(portal, width, height, request.max_fps, sink)
608
- .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
828
+ #[cfg(target_os = "linux")]
829
+ if request.agent_indicator && wants_control {
830
+ indicator = Some(
831
+ crate::indicator::Indicator::start_wayland(source_w, source_h).map_err(
832
+ |error| {
833
+ SessionError::new("indicator-unavailable", format!("{error:#}"))
834
+ },
835
+ )?,
836
+ );
837
+ }
838
+ #[cfg(target_os = "macos")]
839
+ if request.agent_indicator && wants_control {
840
+ indicator = Some(
841
+ crate::indicator::Indicator::start(source.node_id, source_w, source_h)
842
+ .map_err(|error| {
843
+ SessionError::new("indicator", format!("{error:#}"))
844
+ })?,
845
+ );
846
+ }
847
+ #[cfg(target_os = "macos")]
848
+ let capture = capture::start(
849
+ portal,
850
+ width,
851
+ height,
852
+ max_fps,
853
+ sink,
854
+ indicator.as_ref().map(crate::indicator::Indicator::pid),
855
+ Box::new({ let status = capture_status.clone(); move |running, reason| status(running, reason) }),
856
+ )
857
+ .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
858
+ #[cfg(target_os = "linux")]
859
+ let capture = capture::start(
860
+ portal,
861
+ width,
862
+ height,
863
+ max_fps,
864
+ sink,
865
+ indicator
866
+ .as_ref()
867
+ .map(crate::indicator::Indicator::position),
868
+ Box::new({ let status = capture_status.clone(); move |reason| status(false, reason) }),
869
+ )
870
+ .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
871
+ #[cfg(target_os = "linux")]
872
+ if let Some(indicator) = indicator.as_mut() {
873
+ indicator.event('A', -1, -1);
874
+ }
609
875
  Selected {
610
876
  source,
611
877
  capture: FrameSource::Portal(capture),
@@ -616,6 +882,20 @@ impl Session {
616
882
 
617
883
  let source_w = source.width.max(1) as usize;
618
884
  let source_h = source.height.max(1) as usize;
885
+ #[cfg(target_os = "linux")]
886
+ if request.agent_indicator && wants_control && wants_x11 {
887
+ let display = match &request.source {
888
+ Some(SourceRequest::X11 {
889
+ display: Some(display),
890
+ }) => display.clone(),
891
+ _ => std::env::var("DISPLAY")
892
+ .map_err(|_| SessionError::new("indicator-unavailable", "no X display"))?,
893
+ };
894
+ indicator = Some(
895
+ crate::indicator::Indicator::start(&display, source_w, source_h)
896
+ .map_err(|error| SessionError::new("indicator", format!("{error:#}")))?,
897
+ );
898
+ }
619
899
  let (width, height) = fit(source_w, source_h, request.max_width, request.max_height);
620
900
 
621
901
  let bitrate_kbps = if request.bitrate_kbps == 0 {
@@ -627,7 +907,7 @@ impl Session {
627
907
  width,
628
908
  height,
629
909
  bitrate_kbps,
630
- request.max_fps,
910
+ max_fps,
631
911
  available_parallelism().min(8) as u32,
632
912
  )
633
913
  .map_err(|error| SessionError::new("encode", format!("{error:#}")))?;
@@ -647,11 +927,19 @@ impl Session {
647
927
  wheel_rest: (0.0, 0.0),
648
928
  },
649
929
  None => InputTarget {
650
- applier: Applier::Uinput(
651
- InputDevices::create(source_w as i32, source_h as i32).map_err(
652
- |error| SessionError::new("input-unavailable", format!("{error:#}")),
653
- )?,
654
- ),
930
+ applier: Applier::Uinput({
931
+ #[cfg(target_os = "linux")]
932
+ let devices = InputDevices::create(source_w as i32, source_h as i32);
933
+ #[cfg(target_os = "macos")]
934
+ let devices = InputDevices::create_for_display(
935
+ source_w as i32,
936
+ source_h as i32,
937
+ source.node_id,
938
+ );
939
+ devices.map_err(|error| {
940
+ SessionError::new("input-unavailable", format!("{error:#}"))
941
+ })?
942
+ }),
655
943
  held: HeldState::default(),
656
944
  explicit_modifiers: Vec::new(),
657
945
  chord_modifiers: Vec::new(),
@@ -694,9 +982,13 @@ impl Session {
694
982
  source,
695
983
  geometry,
696
984
  metrics,
985
+ latest,
986
+ capture_error,
987
+ frame_changes,
697
988
  peer: Arc::new(peer),
698
989
  encoder: Mutex::new(encoder),
699
990
  input: Mutex::new(input),
991
+ indicator: Mutex::new(indicator),
700
992
  capture: Mutex::new(Some(capture)),
701
993
  layout: Mutex::new(layout),
702
994
  last_seq: Mutex::new(0),
@@ -716,7 +1008,7 @@ impl Session {
716
1008
  first_frame: false,
717
1009
  });
718
1010
 
719
- spawn_pipeline(&inner, frame_rx, pipeline, request.max_fps, bitrate_kbps);
1011
+ spawn_pipeline(&inner, frame_rx, pipeline, max_fps, bitrate_kbps);
720
1012
  spawn_peer_events(&inner, peer_events_rx);
721
1013
  spawn_lease(&inner, request.ttl.unwrap_or(Duration::from_secs(3600)));
722
1014
 
@@ -739,6 +1031,106 @@ impl Session {
739
1031
  &self.inner.source
740
1032
  }
741
1033
 
1034
+ pub async fn wait_frame(
1035
+ &self,
1036
+ after_seq: Option<u64>,
1037
+ still_ms: Option<u64>,
1038
+ timeout_ms: u64,
1039
+ ) -> std::result::Result<(), SessionError> {
1040
+ let mut changes = self.inner.frame_changes.clone();
1041
+ let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms.min(120_000));
1042
+ loop {
1043
+ if let Some(reason) = lock(&self.inner.capture_error).clone() {
1044
+ return Err(SessionError::new("stream-stopped", reason));
1045
+ }
1046
+ let (seq, still) = self
1047
+ .inner
1048
+ .latest
1049
+ .lock()
1050
+ .ok()
1051
+ .and_then(|held| held.as_ref().map(|(seq, at, ..)| (*seq, at.elapsed())))
1052
+ .unwrap_or((0, Duration::ZERO));
1053
+ let target = still_ms.or(if after_seq.is_none() { Some(150) } else { None });
1054
+ let advanced = after_seq.is_none_or(|after| seq > after);
1055
+ if seq > 0 && advanced && target.is_none_or(|ms| still >= Duration::from_millis(ms)) {
1056
+ return Ok(());
1057
+ }
1058
+ let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1059
+ if remaining.is_zero() {
1060
+ return Err(SessionError::new(
1061
+ "frame-timeout",
1062
+ "frame condition not met before deadline",
1063
+ ));
1064
+ }
1065
+ let until_still = if seq > 0 && advanced {
1066
+ target.map_or(remaining, |ms| {
1067
+ Duration::from_millis(ms).saturating_sub(still)
1068
+ })
1069
+ } else {
1070
+ remaining
1071
+ };
1072
+ let _ = tokio::time::timeout(remaining.min(until_still), changes.changed()).await;
1073
+ }
1074
+ }
1075
+
1076
+ /// Write only the requested lossless frame bytes; JSON carries metadata, never pixels.
1077
+ pub fn frame(
1078
+ &self,
1079
+ since: Option<u64>,
1080
+ path: &str,
1081
+ region: Option<[usize; 4]>,
1082
+ ) -> std::result::Result<serde_json::Value, SessionError> {
1083
+ if !self.inner.permissions.contains(&Permission::View) {
1084
+ return Err(SessionError::new(
1085
+ "permission",
1086
+ "view permission is required",
1087
+ ));
1088
+ }
1089
+ if let Some(reason) = lock(&self.inner.capture_error).clone() {
1090
+ return Err(SessionError::new("stream-stopped", reason));
1091
+ }
1092
+ let held = lock(&self.inner.latest);
1093
+ let (seq, at, width, height, raw, hashes, previous) = held
1094
+ .as_ref()
1095
+ .ok_or_else(|| SessionError::new("frame", "the first frame has not arrived"))?;
1096
+ let [x, y, w, h] = region.unwrap_or([0, 0, *width, *height]);
1097
+ if w == 0
1098
+ || h == 0
1099
+ || x.checked_add(w).is_none_or(|end| end > *width)
1100
+ || y.checked_add(h).is_none_or(|end| end > *height)
1101
+ {
1102
+ return Err(SessionError::new(
1103
+ "coordinates",
1104
+ "region exceeds the encoded surface",
1105
+ ));
1106
+ }
1107
+ let damage = if since == Some(*seq) {
1108
+ Vec::new()
1109
+ } else if since == seq.checked_sub(1) && previous.len() == hashes.len() {
1110
+ dirty_regions(hashes, previous, *width, *height)
1111
+ } else {
1112
+ vec![[0, 0, *width, *height]]
1113
+ };
1114
+ // An empty path asks for metadata only; no pixels are copied or written.
1115
+ let pixels = if path.is_empty() {
1116
+ None
1117
+ } else {
1118
+ let mut pixels = Vec::with_capacity(w * h * 4);
1119
+ for row in y..y + h {
1120
+ pixels.extend_from_slice(&raw[(row * width + x) * 4..(row * width + x + w) * 4]);
1121
+ }
1122
+ Some(pixels)
1123
+ };
1124
+ let result = serde_json::json!({ "seq": seq, "still_ms": at.elapsed().as_millis() as u64,
1125
+ "width": w, "height": h, "format": "bgrx", "damage": damage, "written": !path.is_empty() });
1126
+ drop(held);
1127
+ if let Some(pixels) = pixels {
1128
+ std::fs::write(path, pixels)
1129
+ .map_err(|error| SessionError::new("frame", error.to_string()))?;
1130
+ }
1131
+ Ok(result)
1132
+ }
1133
+
742
1134
  pub fn metrics(&self) -> Metrics {
743
1135
  self.inner
744
1136
  .metrics
@@ -819,6 +1211,9 @@ impl Inner {
819
1211
  }
820
1212
  self.control_open.store(false, Ordering::SeqCst);
821
1213
  self.pipeline.store(false, Ordering::SeqCst);
1214
+ if let Ok(mut indicator) = self.indicator.lock() {
1215
+ indicator.take();
1216
+ }
822
1217
  // Release first, then tear down: a stuck modifier is the one failure the
823
1218
  // user cannot undo by reconnecting.
824
1219
  if let Ok(mut input) = self.input.lock() {
@@ -922,6 +1317,23 @@ impl Inner {
922
1317
  }
923
1318
  *last = seq;
924
1319
  }
1320
+ let feedback = match &message {
1321
+ ControlMessage::Pointer { phase, x, y, .. } => {
1322
+ let (x, y) = to_source_pixels(*x, *y, self.encoded_size(), &self.source);
1323
+ Some((
1324
+ if matches!(phase, PointerPhase::Down) {
1325
+ 'C'
1326
+ } else {
1327
+ 'M'
1328
+ },
1329
+ x,
1330
+ y,
1331
+ ))
1332
+ }
1333
+ ControlMessage::Text { text, .. } if !text.is_empty() => Some(('T', -1, -1)),
1334
+ ControlMessage::Key { down: true, .. } => Some(('T', -1, -1)),
1335
+ _ => None,
1336
+ };
925
1337
  let outcome = match message {
926
1338
  ControlMessage::Pointer {
927
1339
  phase,
@@ -958,6 +1370,13 @@ impl Inner {
958
1370
  };
959
1371
  match outcome {
960
1372
  Ok(()) => {
1373
+ if let Some((kind, x, y)) = feedback {
1374
+ if let Ok(mut indicator) = self.indicator.lock() {
1375
+ if let Some(indicator) = indicator.as_mut() {
1376
+ indicator.event(kind, x, y);
1377
+ }
1378
+ }
1379
+ }
961
1380
  if let Ok(mut m) = self.metrics.lock() {
962
1381
  m.input_applied += 1;
963
1382
  }
@@ -1051,10 +1470,16 @@ impl Inner {
1051
1470
  keymap::named_key(&name)
1052
1471
  .ok_or(("text-unsupported", format!("unknown key {name}")))?
1053
1472
  } else if let Some(character) = character {
1054
- let wanted = character
1055
- .chars()
1473
+ let mut characters = character.chars();
1474
+ let wanted = characters
1056
1475
  .next()
1057
1476
  .ok_or(("text-unsupported", String::from("empty character")))?;
1477
+ if characters.next().is_some() {
1478
+ return Err((
1479
+ "text-unsupported",
1480
+ String::from("character must contain exactly one Unicode scalar"),
1481
+ ));
1482
+ }
1058
1483
  layout.keystroke_for_char(wanted).ok_or((
1059
1484
  "text-unsupported",
1060
1485
  format!("the active layout cannot produce {wanted:?}"),
@@ -1080,42 +1505,60 @@ impl Inner {
1080
1505
  if text.len() > 4096 {
1081
1506
  return Err(("text-too-large", String::from("text exceeds 4096 bytes")));
1082
1507
  }
1083
- let layout = self
1084
- .layout
1085
- .lock()
1086
- .map_err(|_| ("session", String::from("no keyboard layout")))?;
1087
- let (plan, unreachable) = layout.plan_text(text);
1088
- drop(layout);
1089
- if !unreachable.is_empty() {
1090
- return Err((
1091
- "text-unsupported",
1092
- format!("the active layout cannot produce {unreachable:?}; use the clipboard"),
1093
- ));
1508
+ #[cfg(target_os = "macos")]
1509
+ {
1510
+ let _ = seq;
1511
+ return self.with_input(|target| target.unicode_text(text));
1094
1512
  }
1095
- let _ = seq;
1096
- self.with_input(|target| {
1097
- // Refuse an unsupported physical key before typing any prefix or
1098
- // holding a modifier. X11 already consumes evdev identities.
1099
- target.check_keys(
1100
- plan.iter()
1101
- .flatten()
1102
- .flat_map(|stroke| std::iter::once(stroke.code).chain(stroke.modifiers())),
1103
- )?;
1104
- for keystroke in plan {
1105
- for stroke in keystroke {
1106
- let modifiers = stroke.modifiers();
1107
- for modifier in &modifiers {
1108
- target.modifier(*modifier, true, false)?;
1513
+ #[cfg(target_os = "linux")]
1514
+ {
1515
+ let layout = self
1516
+ .layout
1517
+ .lock()
1518
+ .map_err(|_| ("session", String::from("no keyboard layout")))?;
1519
+ let (plan, unreachable) = layout.plan_text(text);
1520
+ drop(layout);
1521
+ if !unreachable.is_empty() {
1522
+ return Err((
1523
+ "text-unsupported",
1524
+ format!("the active layout cannot produce {unreachable:?}; use the clipboard"),
1525
+ ));
1526
+ }
1527
+ let _ = seq;
1528
+ self.with_input(|target| {
1529
+ // Uinput's immediate down/up pairs can be collapsed by a compositor's
1530
+ // event loop. XTest flushes each edge, so pace only the virtual device.
1531
+ let paced = matches!(&target.applier, Applier::Uinput(_));
1532
+ let pause = || {
1533
+ if paced {
1534
+ std::thread::sleep(std::time::Duration::from_millis(5));
1109
1535
  }
1110
- target.key(stroke.code, true)?;
1111
- target.key(stroke.code, false)?;
1112
- for modifier in modifiers.iter().rev() {
1113
- target.modifier(*modifier, false, false)?;
1536
+ };
1537
+ target.check_keys(
1538
+ plan.iter()
1539
+ .flatten()
1540
+ .flat_map(|stroke| std::iter::once(stroke.code).chain(stroke.modifiers())),
1541
+ )?;
1542
+ for keystroke in plan {
1543
+ for stroke in keystroke {
1544
+ let modifiers = stroke.modifiers();
1545
+ for modifier in &modifiers {
1546
+ target.modifier(*modifier, true, false)?;
1547
+ pause();
1548
+ }
1549
+ target.key(stroke.code, true)?;
1550
+ pause();
1551
+ target.key(stroke.code, false)?;
1552
+ pause();
1553
+ for modifier in modifiers.iter().rev() {
1554
+ target.modifier(*modifier, false, false)?;
1555
+ pause();
1556
+ }
1114
1557
  }
1115
1558
  }
1116
- }
1117
- Ok(())
1118
- })
1559
+ Ok(())
1560
+ })
1561
+ }
1119
1562
  }
1120
1563
 
1121
1564
  fn clipboard_read(self: &Arc<Self>, request: String) {
@@ -1639,6 +2082,14 @@ fn opaque_id() -> String {
1639
2082
  mod tests {
1640
2083
  use super::*;
1641
2084
 
2085
+ #[test]
2086
+ fn tile_damage_at_origin_does_not_underflow_or_hide_other_tiles() {
2087
+ assert_eq!(
2088
+ dirty_regions(&[1, 0, 0, 1], &[0; 4], 64, 64),
2089
+ vec![[0, 0, 32, 32], [32, 32, 32, 32]]
2090
+ );
2091
+ }
2092
+
1642
2093
  #[test]
1643
2094
  fn a_small_source_is_never_upscaled() {
1644
2095
  assert_eq!(fit(1000, 600, 1280, 800), (1000, 600));
@@ -1718,6 +2169,9 @@ mod tests {
1718
2169
  "origin": { "x": 0, "y": 0 },
1719
2170
  }),
1720
2171
  metrics: Arc::new(Mutex::new(Metrics::default())),
2172
+ latest: Arc::new(Mutex::new(None)),
2173
+ capture_error: Arc::new(Mutex::new(None)),
2174
+ frame_changes: tokio::sync::watch::channel(0).1,
1721
2175
  peer: Arc::new(peer),
1722
2176
  encoder: Mutex::new(Encoder::new(64, 64, 1000, 30, 1).expect("an encoder")),
1723
2177
  input: Mutex::new(Some(InputTarget {
@@ -1729,6 +2183,7 @@ mod tests {
1729
2183
  wheel_rest: (0.0, 0.0),
1730
2184
  })),
1731
2185
  capture: Mutex::new(None),
2186
+ indicator: Mutex::new(None),
1732
2187
  layout: Mutex::new(Layout::from_environment().expect("a keymap")),
1733
2188
  last_seq: Mutex::new(0),
1734
2189
  control_open: AtomicBool::new(true),
@@ -2015,6 +2470,18 @@ mod tests {
2015
2470
  assert_eq!(inner.metrics.lock().unwrap().input_rejected, 2);
2016
2471
  }
2017
2472
 
2473
+ #[tokio::test]
2474
+ async fn a_stopped_capture_never_serves_the_last_frame() {
2475
+ let (events, _) = tokio_mpsc::unbounded_channel();
2476
+ let (mut inner, _) = test_inner(events).await;
2477
+ Arc::get_mut(&mut inner).unwrap().permissions.push(Permission::View);
2478
+ let session = Session { inner };
2479
+ *lock(&session.inner.latest) = Some((1, Instant::now(), 2, 2, vec![0;16], vec![1], vec![]));
2480
+ *lock(&session.inner.capture_error) = Some(String::from("SCStreamErrorDomain -3821"));
2481
+ assert_eq!(session.frame(None, "", None).unwrap_err().code, "stream-stopped");
2482
+ assert_eq!(session.wait_frame(None, None, 10).await.unwrap_err().code, "stream-stopped");
2483
+ }
2484
+
2018
2485
  #[tokio::test]
2019
2486
  async fn a_frame_the_encoder_refuses_ends_the_session_with_a_reason() {
2020
2487
  let (events, mut received) = tokio_mpsc::unbounded_channel();
@@ -2052,6 +2519,33 @@ mod tests {
2052
2519
  );
2053
2520
  }
2054
2521
 
2522
+ #[tokio::test]
2523
+ async fn opening_without_view_permission_is_refused_before_capture() {
2524
+ let (events, _received) = tokio_mpsc::unbounded_channel();
2525
+ let result = Session::open(
2526
+ OpenRequest {
2527
+ source: None,
2528
+ permissions: vec![Permission::Control],
2529
+ max_width: 640,
2530
+ max_height: 480,
2531
+ bitrate_kbps: 0,
2532
+ max_fps: 30,
2533
+ ice_servers: Vec::new(),
2534
+ restore_token: None,
2535
+ ttl: None,
2536
+ loopback_tcp: false,
2537
+ agent_indicator: false,
2538
+ },
2539
+ events,
2540
+ )
2541
+ .await;
2542
+ let error = match result {
2543
+ Ok(_) => panic!("a viewless session must not open"),
2544
+ Err(error) => error,
2545
+ };
2546
+ assert_eq!(error.code, "permission");
2547
+ }
2548
+
2055
2549
  #[tokio::test]
2056
2550
  async fn a_lost_transport_releases_input_then_ends_the_session_without_sctp_close() {
2057
2551
  use webrtc::peer_connection::RTCPeerConnectionState as State;