@desklink/host 0.5.14 → 0.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/src/mac.rs CHANGED
@@ -592,6 +592,152 @@ fn capture_probe(seconds: u64) -> Result<Value, String> {
592
592
  )
593
593
  }
594
594
 
595
+ /// One real ScreenCaptureKit screenshot of a screen-point rectangle, converted
596
+ /// to packed BGRA with its row stride. `capture-probe --frame/--record` uses it
597
+ /// to keep a real desktop view for proof; it is not a render.
598
+ fn capture_rect_bgra(area: CGRect) -> Result<(Vec<u8>, usize, usize, usize), String> {
599
+ use block2::RcBlock;
600
+ use objc2_core_foundation::{
601
+ CGPoint as NativePoint, CGRect as NativeRect, CGSize as NativeSize,
602
+ };
603
+ use objc2_screen_capture_kit::SCScreenshotManager;
604
+
605
+ let rect = NativeRect {
606
+ origin: NativePoint {
607
+ x: area.origin.x,
608
+ y: area.origin.y,
609
+ },
610
+ size: NativeSize {
611
+ width: area.size.width,
612
+ height: area.size.height,
613
+ },
614
+ };
615
+ let ready = Arc::new((
616
+ Mutex::new(None::<Result<(Vec<u8>, usize, usize, usize), String>>),
617
+ Condvar::new(),
618
+ ));
619
+ let callback_state = ready.clone();
620
+ let callback = RcBlock::new(
621
+ move |image: *mut objc2_core_graphics::CGImage, error: *mut objc2_foundation::NSError| {
622
+ let frame = if !error.is_null() || image.is_null() {
623
+ Err(String::from(
624
+ "ScreenCaptureKit returned an error or no image",
625
+ ))
626
+ } else {
627
+ let image = unsafe { &*image };
628
+ let width = objc2_core_graphics::CGImage::width(Some(image));
629
+ let height = objc2_core_graphics::CGImage::height(Some(image));
630
+ let mut length = 0usize;
631
+ let mut stride = 0usize;
632
+ let pointer = unsafe {
633
+ desklink_image_bgra(
634
+ image as *const _ as *const c_void,
635
+ &mut length,
636
+ &mut stride,
637
+ )
638
+ };
639
+ if pointer.is_null() || width == 0 || height == 0 {
640
+ Err(String::from(
641
+ "ScreenCaptureKit image could not be converted to BGRA",
642
+ ))
643
+ } else {
644
+ let pixels = unsafe { std::slice::from_raw_parts(pointer, length) }.to_vec();
645
+ unsafe { libc::free(pointer.cast()) };
646
+ Ok((pixels, width, height, stride))
647
+ }
648
+ };
649
+ let (lock, wake) = &*callback_state;
650
+ if let Ok(mut result) = lock.lock() {
651
+ *result = Some(frame);
652
+ wake.notify_one();
653
+ }
654
+ },
655
+ );
656
+ unsafe {
657
+ SCScreenshotManager::captureImageInRect_completionHandler(rect, Some(&callback));
658
+ }
659
+ let deadline = Instant::now() + Duration::from_secs(5);
660
+ let (lock, wake) = &*ready;
661
+ let mut result = lock
662
+ .lock()
663
+ .map_err(|_| String::from("capture lock poisoned"))?;
664
+ while result.is_none() && Instant::now() < deadline {
665
+ let (next, _) = wake
666
+ .wait_timeout(result, deadline.saturating_duration_since(Instant::now()))
667
+ .map_err(|_| String::from("capture wait failed"))?;
668
+ result = next;
669
+ }
670
+ result
671
+ .take()
672
+ .unwrap_or_else(|| Err(String::from("ScreenCaptureKit timed out")))
673
+ }
674
+
675
+ /// Write one real desktop frame, or a short real desktop sequence, to raw BGRA
676
+ /// files. The proof harness turns these into stills and recordings; no pixels
677
+ /// are ever rendered by the engine itself.
678
+ fn capture_rects(
679
+ seconds: u64,
680
+ frame_path: Option<String>,
681
+ record_dir: Option<String>,
682
+ fps: u64,
683
+ area: Option<CGRect>,
684
+ ) -> Result<Value, String> {
685
+ let area = area.unwrap_or_else(|| unsafe { CGDisplayBounds(CGMainDisplayID()) });
686
+ let rect = serde_json::json!({
687
+ "x": area.origin.x, "y": area.origin.y,
688
+ "width": area.size.width, "height": area.size.height
689
+ });
690
+ let mut written = Vec::new();
691
+ let mut dimensions = None;
692
+ if let Some(path) = frame_path {
693
+ let (pixels, width, height, stride) = capture_rect_bgra(area)?;
694
+ std::fs::write(&path, &pixels)
695
+ .map_err(|error| format!("could not write {path}: {error}"))?;
696
+ dimensions = Some((width, height));
697
+ written.push(serde_json::json!({
698
+ "kind": "frame", "path": path, "width": width, "height": height,
699
+ "stride": stride, "format": "bgra"
700
+ }));
701
+ }
702
+ if let Some(dir) = record_dir {
703
+ std::fs::create_dir_all(&dir)
704
+ .map_err(|error| format!("could not create {dir}: {error}"))?;
705
+ let fps = fps.clamp(1, 30);
706
+ let count = seconds.saturating_mul(fps).max(1);
707
+ for index in 0..count {
708
+ let (pixels, width, height, stride) = capture_rect_bgra(area)?;
709
+ dimensions = Some((width, height));
710
+ let path = std::path::Path::new(&dir).join(format!("frame-{index:04}.bgra"));
711
+ std::fs::write(&path, &pixels)
712
+ .map_err(|error| format!("could not write {}: {error}", path.display()))?;
713
+ if index == 0 {
714
+ written.push(serde_json::json!({
715
+ "kind": "record", "dir": dir, "width": width, "height": height,
716
+ "stride": stride, "format": "bgra", "fps": fps, "frames": count
717
+ }));
718
+ }
719
+ std::thread::sleep(Duration::from_millis(1000 / fps));
720
+ }
721
+ let manifest = serde_json::json!({
722
+ "format": "bgra", "fps": fps, "frames": count,
723
+ "width": dimensions.map(|d| d.0), "height": dimensions.map(|d| d.1),
724
+ "rect": rect
725
+ });
726
+ std::fs::write(
727
+ std::path::Path::new(&dir).join("manifest.json"),
728
+ serde_json::to_vec_pretty(&manifest).unwrap(),
729
+ )
730
+ .map_err(|error| format!("could not write manifest: {error}"))?;
731
+ }
732
+ Ok(serde_json::json!({
733
+ "source": {
734
+ "kind": "screencapturekit", "rect": rect.clone(),
735
+ "width": dimensions.map(|d| d.0), "height": dimensions.map(|d| d.1)
736
+ },
737
+ "written": written
738
+ }))
739
+ }
740
+
595
741
  pub fn run() -> i32 {
596
742
  let mut args = std::env::args().skip(1);
597
743
  let command = args.next().unwrap_or_else(|| "help".into());
@@ -600,6 +746,7 @@ pub fn run() -> i32 {
600
746
  "serve"
601
747
  | "capabilities"
602
748
  | "capture-probe"
749
+ | "warp-pointer"
603
750
  | "indicator-demo"
604
751
  | "axi-record"
605
752
  | "setup-input"
@@ -668,6 +815,46 @@ pub fn run() -> i32 {
668
815
  let Some(path) = args.next() else { eprintln!("axi-record requires an output path"); return 2; };
669
816
  if let Err(error) = record_agent_overlay(&path) { eprintln!("axi-record: {error}"); return 1; }
670
817
  }
818
+ "warp-pointer" => {
819
+ let x = args.next().and_then(|value| value.parse::<i64>().ok());
820
+ let y = args.next().and_then(|value| value.parse::<i64>().ok());
821
+ let display = args
822
+ .next()
823
+ .and_then(|value| value.parse::<u32>().ok())
824
+ .unwrap_or_else(|| unsafe { CGMainDisplayID() });
825
+ let (Some(x), Some(y)) = (x, y) else {
826
+ eprintln!("usage: desklink-host warp-pointer <source x> <source y> [display]");
827
+ return 2;
828
+ };
829
+ let mode = unsafe { CGDisplayCopyDisplayMode(display) };
830
+ let (width, height) = if mode.is_null() {
831
+ (
832
+ unsafe { CGDisplayPixelsWide(display) },
833
+ unsafe { CGDisplayPixelsHigh(display) },
834
+ )
835
+ } else {
836
+ let pixels = unsafe {
837
+ (
838
+ CGDisplayModeGetPixelWidth(mode),
839
+ CGDisplayModeGetPixelHeight(mode),
840
+ )
841
+ };
842
+ unsafe { CGDisplayModeRelease(mode) };
843
+ pixels
844
+ };
845
+ let (width, height) = (width as i32, height as i32);
846
+ match crate::input::InputDevices::create_for_display(width, height, display) {
847
+ Ok(mut devices) => {
848
+ devices.move_absolute(x, y);
849
+ std::thread::sleep(Duration::from_millis(200));
850
+ println!("{}", json!({"warped": true, "x": x, "y": y, "display": display}));
851
+ }
852
+ Err(error) => {
853
+ eprintln!("warp-pointer: {error}");
854
+ return 1;
855
+ }
856
+ }
857
+ }
671
858
  #[cfg(all(desklink_vpx, not(desklink_macos_cli)))]
672
859
  "indicator-demo" => {
673
860
  let display = args.next().and_then(|value| value.parse().ok()).unwrap_or(0);
@@ -679,15 +866,51 @@ pub fn run() -> i32 {
679
866
  "capture-probe" => {
680
867
  let mut seconds = 3;
681
868
  let mut output_path = None;
869
+ let mut frame_path = None;
870
+ let mut record_dir = None;
871
+ let mut fps = 10;
872
+ let mut area = None;
682
873
  while let Some(argument) = args.next() {
683
- if argument == "--out" {
684
- output_path = args.next();
685
- } else if let Ok(value) = argument.parse() {
686
- seconds = value;
874
+ match argument.as_str() {
875
+ "--out" => output_path = args.next(),
876
+ "--frame" => frame_path = args.next(),
877
+ "--record" => record_dir = args.next(),
878
+ "--fps" => {
879
+ fps = args.next().and_then(|value| value.parse().ok()).unwrap_or(10);
880
+ }
881
+ "--rect" => {
882
+ let parts: Vec<f64> = args
883
+ .next()
884
+ .unwrap_or_default()
885
+ .split(',')
886
+ .filter_map(|value| value.trim().parse().ok())
887
+ .collect();
888
+ if parts.len() == 4 {
889
+ area = Some(CGRect {
890
+ origin: CGPoint {
891
+ x: parts[0],
892
+ y: parts[1],
893
+ },
894
+ size: CGSize {
895
+ width: parts[2],
896
+ height: parts[3],
897
+ },
898
+ });
899
+ }
900
+ }
901
+ other => {
902
+ if let Ok(value) = other.parse() {
903
+ seconds = value;
904
+ }
905
+ }
687
906
  }
688
907
  }
689
908
  let result = if unsafe { CGPreflightScreenCaptureAccess() } {
690
- capture_probe(seconds)
909
+ if frame_path.is_some() || record_dir.is_some() {
910
+ capture_rects(seconds, frame_path, record_dir, fps, area)
911
+ } else {
912
+ capture_probe(seconds)
913
+ }
691
914
  } else {
692
915
  request_screen_capture_content().and_then(|_| Err(format!("capture-permission: allow {} in System Settings › Privacy & Security › Screen & System Audio Recording, then reconnect", tcc_responsible_app_name())))
693
916
  };
@@ -722,7 +945,7 @@ pub fn run() -> i32 {
722
945
  println!("If the Input Monitoring alert did not open, allow {} in System Settings › Privacy & Security › Input Monitoring.", tcc_responsible_app_name());
723
946
  }
724
947
  }
725
- _ => println!("desklink-host {}\nUSAGE: desklink-host [serve|capabilities|capture-probe|encode-probe|setup-input|version]", env!("CARGO_PKG_VERSION")),
948
+ _ => println!("desklink-host {}\nUSAGE: desklink-host [serve|capabilities|capture-probe|warp-pointer|encode-probe|setup-input|version]", env!("CARGO_PKG_VERSION")),
726
949
  }
727
950
  0
728
951
  }
@@ -26,6 +26,8 @@ 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")]
30
+ mod diagnose;
29
31
  #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
30
32
  mod cursor;
31
33
  #[cfg(target_os = "linux")]
@@ -54,6 +56,8 @@ mod hyprland;
54
56
  mod indicator;
55
57
  #[cfg(target_os = "linux")]
56
58
  mod input;
59
+ #[cfg(target_os = "linux")]
60
+ mod input_portal;
57
61
  #[cfg(target_os = "macos")]
58
62
  #[path = "input_mac.rs"]
59
63
  mod input;
@@ -245,6 +249,8 @@ fn main() {
245
249
  );
246
250
  0
247
251
  }
252
+ #[cfg(target_os = "linux")]
253
+ "diagnose" => report(runtime.block_on(diagnose::run())),
248
254
  "serve" => report(runtime.block_on(serve())),
249
255
  "keep" => keeper::command(&args[1..]),
250
256
  "capture-probe" => report(runtime.block_on(probe(
@@ -293,6 +299,7 @@ USAGE:
293
299
  keep one private X screen: kiosk geometry, focus,
294
300
  and a JSON-lines window report on stdout
295
301
  desklink-host capabilities print what this machine can do right now
302
+ desklink-host diagnose print the non-prompting checks behind `desklink-host doctor`
296
303
  desklink-host capture-probe [seconds] [display]
297
304
  capture frames and report the stream; with a
298
305
  display argument it reads that X display's root
@@ -981,91 +988,3 @@ fn main() {
981
988
  fn main() {
982
989
  std::process::exit(win::run());
983
990
  }
984
-
985
- #[cfg(all(test, target_os = "linux"))]
986
- mod tests {
987
- use super::*;
988
-
989
- #[tokio::test]
990
- async fn a_consumer_speaking_another_protocol_version_is_refused() {
991
- let mut hello_seen = false;
992
- let mut current = None;
993
- let (events, _received) = tokio_mpsc::unbounded_channel();
994
- let request = Request {
995
- id: Some(1),
996
- method: String::from("hello"),
997
- params: serde_json::json!({ "protocol": 99, "client": "test" }),
998
- };
999
- let error = dispatch(&request, &mut hello_seen, &mut current, &events)
1000
- .await
1001
- .unwrap_err();
1002
- assert_eq!(error.code, "unsupported-protocol");
1003
- assert!(!hello_seen, "a refused handshake must not open the gate");
1004
- }
1005
-
1006
- #[test]
1007
- fn cursor_notice_preserves_source_coordinates_visibility_and_clock() {
1008
- let line = render_event(session::Notice {
1009
- session_id: String::from("cursor-session"),
1010
- event: session::SessionEvent::Cursor {
1011
- position: protocol::CursorPosition {
1012
- x: 32,
1013
- y: 64,
1014
- visible: false,
1015
- hotspot: None,
1016
- timestamp_us: 123456,
1017
- },
1018
- },
1019
- })
1020
- .unwrap();
1021
- let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
1022
- assert_eq!(
1023
- parsed,
1024
- serde_json::json!({"event":"session.cursor","params":{"sessionId":"cursor-session","x":32,"y":64,"visible":false,"timestamp_us":123456}})
1025
- );
1026
- }
1027
-
1028
- #[test]
1029
- fn the_skipped_ice_server_warning_names_the_url_once() {
1030
- let line = render_event(session::Notice {
1031
- session_id: String::from("session-1"),
1032
- event: session::SessionEvent::IceServerSkipped {
1033
- url: String::from("turn:turn.example.com:3478?transport=tcp"),
1034
- },
1035
- })
1036
- .expect("a skipped-URL warning is renderable");
1037
- let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
1038
- assert_eq!(parsed["event"], "session.iceServerSkipped");
1039
- assert_eq!(
1040
- parsed["params"],
1041
- serde_json::json!({
1042
- "sessionId": "session-1",
1043
- "url": "turn:turn.example.com:3478?transport=tcp"
1044
- })
1045
- );
1046
- }
1047
-
1048
- #[tokio::test]
1049
- async fn the_state_notification_carries_exactly_what_the_document_promises() {
1050
- let line = render_event(session::Notice {
1051
- session_id: String::from("session-1"),
1052
- event: session::SessionEvent::State {
1053
- capture: "streaming",
1054
- transport: String::from("connected"),
1055
- first_frame: true,
1056
- },
1057
- })
1058
- .expect("a state event is renderable");
1059
- let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
1060
- assert_eq!(parsed["event"], "session.state");
1061
- assert_eq!(
1062
- parsed["params"],
1063
- serde_json::json!({
1064
- "sessionId": "session-1",
1065
- "capture": "streaming",
1066
- "transport": "connected",
1067
- "firstFrame": true
1068
- })
1069
- );
1070
- }
1071
- }
@@ -59,36 +59,3 @@ pub fn status(result: Option<&Result<Tap, Unavailable>>) -> serde_json::Value {
59
59
  }),
60
60
  }
61
61
  }
62
-
63
- #[cfg(test)]
64
- mod tests {
65
- use super::*;
66
-
67
- #[test]
68
- #[cfg(not(target_os = "macos"))]
69
- fn a_platform_that_cannot_observe_says_so_and_delivers_nothing() {
70
- let delivered = Arc::new(std::sync::atomic::AtomicUsize::new(0));
71
- let counted = delivered.clone();
72
- let sink: Sink = Arc::new(move |_| {
73
- counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
74
- });
75
- let refused = start(0, (1920, 1080), sink);
76
- let status = status(Some(&refused));
77
- assert_eq!(status["observing"], false);
78
- assert_eq!(status["code"], "unsupported-platform");
79
- assert_eq!(delivered.load(std::sync::atomic::Ordering::SeqCst), 0);
80
- }
81
-
82
- #[test]
83
- fn a_refusal_carries_its_code_reason_and_remedy() {
84
- let refused: Result<Tap, Unavailable> = Err(Unavailable {
85
- code: "missing-input-monitoring",
86
- reason: String::from("reason"),
87
- remedy: String::from("remedy"),
88
- });
89
- assert_eq!(
90
- status(Some(&refused)),
91
- serde_json::json!({"observing": false, "code": "missing-input-monitoring", "reason": "reason", "remedy": "remedy"})
92
- );
93
- }
94
- }