@desklink/host 0.3.0 → 0.3.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.
@@ -202,12 +202,14 @@ struct Selected {
202
202
 
203
203
  /// Open an X display, start reading its root window, and hand back the same
204
204
  /// shape the portal path produces.
205
+ #[allow(clippy::too_many_arguments)]
205
206
  fn select_x11(
206
207
  display: Option<&str>,
207
208
  max_width: usize,
208
209
  max_height: usize,
209
210
  max_fps: u32,
210
211
  metrics: Arc<Mutex<Metrics>>,
212
+ #[cfg(target_os = "linux")] cursor_sink: Option<crate::cursor::CursorSink>,
211
213
  sink: capture::FrameSink,
212
214
  on_stop: Box<dyn Fn(String) + Send>,
213
215
  ) -> Result<Selected> {
@@ -238,15 +240,23 @@ fn select_x11(
238
240
  let interval = frame_interval(max_fps);
239
241
  let mut sequence = 0u64;
240
242
  let mut last_sent: Option<Instant> = None;
241
- // Without XDamage there is no change signal, so an unchanged
242
- // screen is recognised by its pixels exactly as before.
243
+ // XDamage wakes the loop, but pixels decide whether the root
244
+ // image actually changed; without XDamage, polling does so.
243
245
  let hashed = !lock(&desktop).damage_armed();
244
246
  let mut last_hash = None;
245
247
  // The first frame is always grabbed; afterwards Damage wakes
246
248
  // the loop, so a still screen costs no grab, no conversion
247
249
  // and no hash. The fps cap stays a minimum spacing.
248
250
  let mut first = true;
251
+ #[cfg(target_os = "linux")]
252
+ let mut cursor = cursor_sink.map(crate::cursor::Reporter::new);
249
253
  while !stop.load(Ordering::SeqCst) {
254
+ #[cfg(target_os = "linux")]
255
+ if let Some(cursor) = cursor.as_mut() {
256
+ if let Ok((x, y, visible)) = lock(&desktop).cursor_position() {
257
+ cursor.update(x, y, visible);
258
+ }
259
+ }
250
260
  if !first {
251
261
  let changed = lock(&desktop).take_damage();
252
262
  if !changed {
@@ -269,7 +279,7 @@ fn select_x11(
269
279
  let grab_micros = grabbed.elapsed().as_micros() as u64;
270
280
  match frame {
271
281
  Ok((frame, raw)) => {
272
- let changed = if hashed {
282
+ let changed = {
273
283
  use std::hash::{Hash, Hasher};
274
284
  let mut hasher = std::collections::hash_map::DefaultHasher::new();
275
285
  raw.hash(&mut hasher);
@@ -280,8 +290,6 @@ fn select_x11(
280
290
  last_hash = Some(hash);
281
291
  true
282
292
  }
283
- } else {
284
- true
285
293
  };
286
294
  if changed {
287
295
  // Handing an unchanged frame on would keep the
@@ -480,10 +488,10 @@ impl InputTarget {
480
488
  Ok(())
481
489
  }
482
490
 
483
- #[cfg(target_os = "macos")]
491
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
484
492
  fn unicode_text(&mut self, text: &str) -> Result<()> {
485
493
  match &mut self.applier {
486
- #[cfg(target_os = "macos")]
494
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
487
495
  Applier::Uinput(devices) => devices.unicode_text(text),
488
496
  #[cfg(target_os = "linux")]
489
497
  Applier::Uinput(_) => anyhow::bail!("Unicode text input is unavailable on Linux"),
@@ -582,6 +590,7 @@ impl SessionError {
582
590
  }
583
591
 
584
592
  pub struct OpenRequest {
593
+ pub cursor: crate::protocol::CursorMode,
585
594
  /** Which desktop to capture; absent means the portal. */
586
595
  pub source: Option<SourceRequest>,
587
596
  pub permissions: Vec<Permission>,
@@ -603,6 +612,9 @@ pub struct OpenRequest {
603
612
  /// Events a session raises for its consumer.
604
613
  #[derive(Debug)]
605
614
  pub enum SessionEvent {
615
+ Cursor {
616
+ position: crate::protocol::CursorPosition,
617
+ },
606
618
  Description {
607
619
  generation: u64,
608
620
  sdp: String,
@@ -619,6 +631,7 @@ pub enum SessionEvent {
619
631
  first_frame: bool,
620
632
  },
621
633
  Frame {
634
+ timestamp_us: u64,
622
635
  seq: u64,
623
636
  damage: Vec<[usize; 4]>,
624
637
  },
@@ -661,6 +674,13 @@ type FrameSnapshot = (u64, Instant, usize, usize, Vec<u8>, Vec<u64>, Vec<u64>);
661
674
 
662
675
  /// Everything a session's background tasks need, shared rather than borrowed so
663
676
  /// the consumer can own the `Session` handle while the pipeline runs.
677
+ #[cfg(target_os = "linux")]
678
+ enum PointDesktop {
679
+ X11(String),
680
+ Wayland,
681
+ Unavailable(&'static str),
682
+ }
683
+
664
684
  struct Inner {
665
685
  id: String,
666
686
  generation: u64,
@@ -679,6 +699,9 @@ struct Inner {
679
699
  encoder: Mutex<Option<VideoEncoder>>,
680
700
  input: Mutex<Option<InputTarget>>,
681
701
  indicator: Mutex<Option<crate::indicator::Indicator>>,
702
+ point_overlay: Mutex<Option<crate::indicator::Indicator>>,
703
+ #[cfg(target_os = "linux")]
704
+ point_desktop: PointDesktop,
682
705
  capture: Mutex<Option<FrameSource>>,
683
706
  /// Absent for an encoded source: there is no local keyboard to translate a
684
707
  /// character through, because the client's keys are forwarded instead.
@@ -694,6 +717,7 @@ struct Inner {
694
717
  encoded: Mutex<Option<Encoded>>,
695
718
  /// `OpenRequest::local_frames`: whether `latest` is kept at all.
696
719
  local_frames: bool,
720
+ cursor_info: serde_json::Value,
697
721
  }
698
722
 
699
723
  /// A source whose video arrives already encoded.
@@ -772,6 +796,8 @@ pub fn capabilities() -> serde_json::Value {
772
796
  "backends": ["portal-screencast+pipewire", "x11-root"],
773
797
  "formats": ["bgrx", "bgra", "rgbx", "rgba"],
774
798
  "cursor": "embedded",
799
+ "cursor_modes": ["embedded", "hidden"],
800
+ "cursor_positions": {"x11": true, "portal": "negotiated"},
775
801
  "audio": false,
776
802
  },
777
803
  "encode": encode_capabilities(),
@@ -824,6 +850,7 @@ pub fn capabilities() -> serde_json::Value {
824
850
  "backends": ["screencapturekit"],
825
851
  "formats": ["bgra"],
826
852
  "cursor": "embedded",
853
+ "cursor_modes": ["embedded"],
827
854
  "audio": false,
828
855
  "displays": crate::mac::displays(),
829
856
  "grant": if capture_granted { "granted" } else { "missing-screen-recording" },
@@ -849,6 +876,16 @@ pub fn capabilities() -> serde_json::Value {
849
876
  })
850
877
  }
851
878
 
879
+ #[cfg(target_os = "windows")]
880
+ pub fn capabilities() -> serde_json::Value {
881
+ crate::win::capabilities()
882
+ }
883
+
884
+ #[cfg(target_os = "windows")]
885
+ fn wayland_clipboard_available() -> bool {
886
+ false
887
+ }
888
+
852
889
  /// `capabilities.encode`: VP9 always, H.264 when an encoder for it starts
853
890
  /// here now. `hardware` describes VP9, the default.
854
891
  fn encode_capabilities() -> serde_json::Value {
@@ -878,6 +915,26 @@ impl Session {
878
915
  "session.open requires the view permission",
879
916
  ));
880
917
  }
918
+ #[cfg(target_os = "windows")]
919
+ if request.agent_indicator {
920
+ return Err(SessionError::new(
921
+ "indicator-unavailable",
922
+ "Windows agent indicator is not implemented in this build.",
923
+ ));
924
+ }
925
+ if request.cursor == crate::protocol::CursorMode::Hidden {
926
+ #[cfg(not(target_os = "linux"))]
927
+ return Err(SessionError::new(
928
+ "cursor-unavailable",
929
+ "hidden cursor capture is not implemented on this platform",
930
+ ));
931
+ if matches!(request.source, Some(SourceRequest::Encoded { .. })) {
932
+ return Err(SessionError::new(
933
+ "cursor-unavailable",
934
+ "the engine cannot remove cursor pixels from consumer-encoded video",
935
+ ));
936
+ }
937
+ }
881
938
  let id = opaque_id();
882
939
  let wants_control = request.permissions.contains(&Permission::Control);
883
940
  let wants_x11 = matches!(request.source, Some(SourceRequest::X11 { .. }));
@@ -975,7 +1032,7 @@ impl Session {
975
1032
  let damage = dirty_regions(&hashes, &previous, frame.width, frame.height);
976
1033
  *held = Some((
977
1034
  seq,
978
- Instant::now(),
1035
+ captured_at,
979
1036
  frame.width,
980
1037
  frame.height,
981
1038
  raw,
@@ -985,7 +1042,11 @@ impl Session {
985
1042
  frame_tx_signal.send_replace(seq);
986
1043
  let _ = frame_events.send(Notice {
987
1044
  session_id: frame_session_id.clone(),
988
- event: SessionEvent::Frame { seq, damage },
1045
+ event: SessionEvent::Frame {
1046
+ seq,
1047
+ damage,
1048
+ timestamp_us: crate::protocol::timestamp_us(captured_at),
1049
+ },
989
1050
  });
990
1051
  }
991
1052
  }
@@ -1002,6 +1063,21 @@ impl Session {
1002
1063
  }
1003
1064
  });
1004
1065
 
1066
+ #[cfg(target_os = "linux")]
1067
+ let cursor_sink: Option<crate::cursor::CursorSink> =
1068
+ if request.cursor == crate::protocol::CursorMode::Hidden {
1069
+ let events = events.clone();
1070
+ let id = id.clone();
1071
+ Some(Box::new(move |position| {
1072
+ let _ = events.send(Notice {
1073
+ session_id: id.clone(),
1074
+ event: SessionEvent::Cursor { position },
1075
+ });
1076
+ }))
1077
+ } else {
1078
+ None
1079
+ };
1080
+ let mut cursor_info = serde_json::json!({"mode":"embedded", "positions":false});
1005
1081
  let Selected {
1006
1082
  source,
1007
1083
  capture,
@@ -1013,6 +1089,8 @@ impl Session {
1013
1089
  request.max_height,
1014
1090
  max_fps,
1015
1091
  metrics.clone(),
1092
+ #[cfg(target_os = "linux")]
1093
+ cursor_sink,
1016
1094
  sink,
1017
1095
  Box::new({
1018
1096
  let status = capture_status.clone();
@@ -1025,9 +1103,30 @@ impl Session {
1025
1103
  Some(SourceRequest::Display { display_id }) => {
1026
1104
  portal::open_display(*display_id).await
1027
1105
  }
1028
- _ => portal::open(request.restore_token.as_deref()).await,
1106
+ _ => {
1107
+ #[cfg(target_os = "linux")]
1108
+ {
1109
+ portal::open(request.restore_token.as_deref(), request.cursor).await
1110
+ }
1111
+ #[cfg(not(target_os = "linux"))]
1112
+ {
1113
+ portal::open(request.restore_token.as_deref()).await
1114
+ }
1115
+ }
1116
+ }
1117
+ .map_err(|error| {
1118
+ let message = format!("{error:#}");
1119
+ let code = if message.contains("cursor_positions_unavailable:") {
1120
+ "cursor_positions_unavailable"
1121
+ } else {
1122
+ "source"
1123
+ };
1124
+ SessionError::new(code, message)
1125
+ })?;
1126
+ #[cfg(target_os = "linux")]
1127
+ {
1128
+ cursor_info = serde_json::json!({"mode":portal.cursor_mode, "positions":false});
1029
1129
  }
1030
- .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1031
1130
  if let Some(token) = &portal.restore_token {
1032
1131
  let _ = events.send(Notice {
1033
1132
  session_id: id.clone(),
@@ -1072,12 +1171,26 @@ impl Session {
1072
1171
  }),
1073
1172
  )
1074
1173
  .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1174
+ #[cfg(target_os = "windows")]
1175
+ let capture = capture::start(
1176
+ portal,
1177
+ width,
1178
+ height,
1179
+ max_fps,
1180
+ sink,
1181
+ Box::new({
1182
+ let status = capture_status.clone();
1183
+ move |running, reason| status(running, reason)
1184
+ }),
1185
+ )
1186
+ .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1075
1187
  #[cfg(target_os = "linux")]
1076
1188
  let capture = capture::start(
1077
1189
  portal,
1078
1190
  width,
1079
1191
  height,
1080
1192
  max_fps,
1193
+ cursor_sink,
1081
1194
  sink,
1082
1195
  indicator
1083
1196
  .as_ref()
@@ -1089,6 +1202,18 @@ impl Session {
1089
1202
  )
1090
1203
  .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1091
1204
  #[cfg(target_os = "linux")]
1205
+ {
1206
+ if request.cursor == crate::protocol::CursorMode::Hidden
1207
+ && !capture.cursor_positions()
1208
+ {
1209
+ return Err(SessionError::new(
1210
+ "cursor_positions_unavailable",
1211
+ "portal stream did not provide SPA cursor metadata",
1212
+ ));
1213
+ }
1214
+ cursor_info["positions"] = serde_json::json!(capture.cursor_positions());
1215
+ }
1216
+ #[cfg(target_os = "linux")]
1092
1217
  if let Some(indicator) = indicator.as_mut() {
1093
1218
  indicator.event('A', -1, -1);
1094
1219
  }
@@ -1099,6 +1224,9 @@ impl Session {
1099
1224
  }
1100
1225
  }
1101
1226
  };
1227
+ if wants_x11 {
1228
+ cursor_info = serde_json::json!({"mode":"hidden", "positions": request.cursor == crate::protocol::CursorMode::Hidden});
1229
+ }
1102
1230
 
1103
1231
  let source_w = source.width.max(1) as usize;
1104
1232
  let source_h = source.height.max(1) as usize;
@@ -1150,7 +1278,7 @@ impl Session {
1150
1278
  applier: Applier::Uinput({
1151
1279
  #[cfg(target_os = "linux")]
1152
1280
  let devices = InputDevices::create(source_w as i32, source_h as i32);
1153
- #[cfg(target_os = "macos")]
1281
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
1154
1282
  let devices = InputDevices::create_for_display(
1155
1283
  source_w as i32,
1156
1284
  source_h as i32,
@@ -1221,6 +1349,20 @@ impl Session {
1221
1349
  encoder: Mutex::new(Some(VideoEncoder::Vp9(encoder))),
1222
1350
  input: Mutex::new(input),
1223
1351
  indicator: Mutex::new(indicator),
1352
+ point_overlay: Mutex::new(None),
1353
+ #[cfg(target_os = "linux")]
1354
+ point_desktop: if wants_x11 {
1355
+ match &request.source {
1356
+ Some(SourceRequest::X11 { display }) => display
1357
+ .clone()
1358
+ .or_else(|| std::env::var("DISPLAY").ok())
1359
+ .map(PointDesktop::X11)
1360
+ .unwrap_or(PointDesktop::Unavailable("x11-unavailable")),
1361
+ _ => PointDesktop::Unavailable("x11-unavailable"),
1362
+ }
1363
+ } else {
1364
+ PointDesktop::Wayland
1365
+ },
1224
1366
  capture: Mutex::new(Some(capture)),
1225
1367
  layout: Mutex::new(Some(layout)),
1226
1368
  last_seq: Mutex::new(0),
@@ -1230,6 +1372,7 @@ impl Session {
1230
1372
  events: events.clone(),
1231
1373
  encoded: Mutex::new(None),
1232
1374
  local_frames: request.local_frames,
1375
+ cursor_info,
1233
1376
  });
1234
1377
 
1235
1378
  inner.notify(SessionEvent::Description {
@@ -1308,6 +1451,9 @@ impl Session {
1308
1451
  encoder: Mutex::new(None),
1309
1452
  input: Mutex::new(None),
1310
1453
  indicator: Mutex::new(None),
1454
+ point_overlay: Mutex::new(None),
1455
+ #[cfg(target_os = "linux")]
1456
+ point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
1311
1457
  capture: Mutex::new(None),
1312
1458
  layout: Mutex::new(None),
1313
1459
  last_seq: Mutex::new(0),
@@ -1328,6 +1474,7 @@ impl Session {
1328
1474
  feed,
1329
1475
  })),
1330
1476
  local_frames: false,
1477
+ cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
1331
1478
  });
1332
1479
  inner.notify(SessionEvent::State {
1333
1480
  capture: "consented",
@@ -1385,6 +1532,10 @@ impl Session {
1385
1532
  self.inner.generation
1386
1533
  }
1387
1534
 
1535
+ pub fn cursor_info(&self) -> &serde_json::Value {
1536
+ &self.inner.cursor_info
1537
+ }
1538
+
1388
1539
  pub fn geometry(&self) -> &serde_json::Value {
1389
1540
  &self.inner.geometry
1390
1541
  }
@@ -1393,6 +1544,128 @@ impl Session {
1393
1544
  &self.inner.source
1394
1545
  }
1395
1546
 
1547
+ pub fn point(
1548
+ &self,
1549
+ params: &crate::protocol::PointParams,
1550
+ ) -> std::result::Result<(), SessionError> {
1551
+ // Lock through validation and drawing, so a concurrent close cannot leave
1552
+ // a newly spawned marker alive after teardown.
1553
+ let mut overlay = lock(&self.inner.point_overlay);
1554
+ if self.inner.closed.load(Ordering::SeqCst) {
1555
+ return Err(SessionError::new("session", "the session is closed"));
1556
+ }
1557
+ if !self.inner.permissions.contains(&Permission::View) {
1558
+ return Err(SessionError::new(
1559
+ "permission",
1560
+ "point requires view permission",
1561
+ ));
1562
+ }
1563
+ if lock(&self.inner.encoded).is_some() {
1564
+ return Err(SessionError::new(
1565
+ "operation",
1566
+ "an encoded source has no host desktop",
1567
+ ));
1568
+ }
1569
+ #[cfg(target_os = "linux")]
1570
+ if let PointDesktop::Unavailable(reason) = &self.inner.point_desktop {
1571
+ return Err(SessionError::new(
1572
+ "point_unsupported",
1573
+ format!("reason: {reason}; point overlay is unavailable"),
1574
+ ));
1575
+ }
1576
+ if params.clear {
1577
+ if params.x.is_some()
1578
+ || params.y.is_some()
1579
+ || params.timeout_ms.is_some()
1580
+ || !params.label.is_empty()
1581
+ {
1582
+ return Err(SessionError::new(
1583
+ "malformed",
1584
+ "clear accepts no coordinates, label or timeout",
1585
+ ));
1586
+ }
1587
+ #[cfg(target_os = "linux")]
1588
+ if overlay.is_none() && matches!(self.inner.point_desktop, PointDesktop::Wayland) {
1589
+ let available = crate::indicator::Indicator::wayland_layer_shell_available()
1590
+ .map_err(|error| {
1591
+ SessionError::new("indicator-unavailable", format!("{error:#}"))
1592
+ })?;
1593
+ if !available {
1594
+ return Err(SessionError::new(
1595
+ "point_unsupported",
1596
+ "reason: layer_shell_unavailable; compositor has no layer-shell",
1597
+ ));
1598
+ }
1599
+ }
1600
+ if let Some(helper) = overlay.take() {
1601
+ helper.stop();
1602
+ }
1603
+ return Ok(());
1604
+ }
1605
+ let (x, y) = params
1606
+ .x
1607
+ .zip(params.y)
1608
+ .ok_or_else(|| SessionError::new("malformed", "point requires x and y"))?;
1609
+ let source = &self.inner.source;
1610
+ if x < 0 || y < 0 || x >= i64::from(source.width) || y >= i64::from(source.height) {
1611
+ return Err(SessionError::new(
1612
+ "coordinates",
1613
+ "point is outside the selected desktop",
1614
+ ));
1615
+ }
1616
+ let timeout = params.timeout_ms.unwrap_or(3000);
1617
+ if !(1..=120_000).contains(&timeout)
1618
+ || params.label.len() > 96
1619
+ || !params.label.bytes().all(|b| (32..=126).contains(&b))
1620
+ {
1621
+ return Err(SessionError::new(
1622
+ "malformed",
1623
+ "timeout must be 1..120000 ms; label must be at most 96 printable ASCII bytes",
1624
+ ));
1625
+ }
1626
+ #[cfg(not(target_os = "windows"))]
1627
+ if overlay.is_none() {
1628
+ #[cfg(target_os = "linux")]
1629
+ let helper = match &self.inner.point_desktop {
1630
+ PointDesktop::X11(display) => crate::indicator::Indicator::start_point(display),
1631
+ PointDesktop::Wayland => crate::indicator::Indicator::start_point_wayland(
1632
+ source.width,
1633
+ source.height,
1634
+ source.position,
1635
+ ),
1636
+ PointDesktop::Unavailable(_) => unreachable!("validated point target"),
1637
+ };
1638
+ #[cfg(target_os = "macos")]
1639
+ let helper = crate::indicator::Indicator::start_point(
1640
+ source.node_id,
1641
+ source.width as usize,
1642
+ source.height as usize,
1643
+ );
1644
+ *overlay = Some(helper.map_err(|error| {
1645
+ let message = format!("{error:#}");
1646
+ if message.contains("layer_shell_unavailable") {
1647
+ SessionError::new(
1648
+ "point_unsupported",
1649
+ "reason: layer_shell_unavailable; compositor has no layer-shell",
1650
+ )
1651
+ } else {
1652
+ SessionError::new("indicator-unavailable", message)
1653
+ }
1654
+ })?);
1655
+ }
1656
+ #[cfg(not(target_os = "windows"))]
1657
+ return overlay
1658
+ .as_mut()
1659
+ .unwrap()
1660
+ .point(x, y, timeout, &params.label)
1661
+ .map_err(|error| SessionError::new("indicator-unavailable", format!("{error:#}")));
1662
+ #[cfg(target_os = "windows")]
1663
+ return Err(SessionError::new(
1664
+ "point_unsupported",
1665
+ "Windows point overlays are unavailable in this build.",
1666
+ ));
1667
+ }
1668
+
1396
1669
  pub async fn wait_frame(
1397
1670
  &self,
1398
1671
  after_seq: Option<u64>,
@@ -1507,7 +1780,7 @@ impl Session {
1507
1780
  }
1508
1781
  Some(pixels)
1509
1782
  };
1510
- let result = serde_json::json!({ "seq": seq, "still_ms": at.elapsed().as_millis() as u64,
1783
+ let result = serde_json::json!({ "seq": seq, "timestamp_us": crate::protocol::timestamp_us(*at), "still_ms": at.elapsed().as_millis() as u64,
1511
1784
  "width": w, "height": h, "format": "bgrx", "damage": damage, "written": !path.is_empty() });
1512
1785
  drop(held);
1513
1786
  if let Some(pixels) = pixels {
@@ -1630,6 +1903,9 @@ impl Inner {
1630
1903
  if let Ok(mut indicator) = self.indicator.lock() {
1631
1904
  indicator.take();
1632
1905
  }
1906
+ if let Some(helper) = lock(&self.point_overlay).take() {
1907
+ helper.stop();
1908
+ }
1633
1909
  // Release first, then tear down: a stuck modifier is the one failure the
1634
1910
  // user cannot undo by reconnecting.
1635
1911
  if let Ok(mut input) = self.input.lock() {
@@ -1976,7 +2252,7 @@ impl Inner {
1976
2252
  if text.len() > 4096 {
1977
2253
  return Err(("text-too-large", String::from("text exceeds 4096 bytes")));
1978
2254
  }
1979
- #[cfg(target_os = "macos")]
2255
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
1980
2256
  {
1981
2257
  let _ = seq;
1982
2258
  return self.with_input(|target| target.unicode_text(text));
@@ -2973,6 +3249,9 @@ mod tests {
2973
3249
  encoder: Mutex::new(None),
2974
3250
  input: Mutex::new(None),
2975
3251
  indicator: Mutex::new(None),
3252
+ point_overlay: Mutex::new(None),
3253
+ #[cfg(target_os = "linux")]
3254
+ point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
2976
3255
  capture: Mutex::new(None),
2977
3256
  layout: Mutex::new(None),
2978
3257
  last_seq: Mutex::new(0),
@@ -2991,6 +3270,7 @@ mod tests {
2991
3270
  feed,
2992
3271
  })),
2993
3272
  local_frames: false,
3273
+ cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
2994
3274
  });
2995
3275
  spawn_peer_events(&inner, peer_events_rx);
2996
3276
  spawn_encoded(&inner, feed_rx);
@@ -3112,6 +3392,140 @@ mod tests {
3112
3392
  test_inner_with(events, peer, 64, 64)
3113
3393
  }
3114
3394
 
3395
+ #[cfg(target_os = "linux")]
3396
+ #[tokio::test]
3397
+ async fn a_wayland_portal_session_reports_typed_point_unsupported() {
3398
+ let (events, _) = tokio_mpsc::unbounded_channel();
3399
+ let (mut inner, _) = test_inner(events).await;
3400
+ Arc::get_mut(&mut inner)
3401
+ .expect("test session has one owner")
3402
+ .permissions = vec![Permission::View];
3403
+ let session = Session { inner };
3404
+
3405
+ let error = session
3406
+ .point(&crate::protocol::PointParams {
3407
+ session_id: String::from("test-session"),
3408
+ x: Some(10),
3409
+ y: Some(20),
3410
+ label: String::new(),
3411
+ timeout_ms: None,
3412
+ clear: false,
3413
+ })
3414
+ .expect_err("compositors without layer-shell do not support point overlays");
3415
+
3416
+ assert_eq!(error.code, "point_unsupported");
3417
+ assert!(error.message.contains("reason: layer_shell_unavailable"));
3418
+ }
3419
+
3420
+ // Driven only on the private headless compositor created by point-wayland-flow.py.
3421
+ #[cfg(target_os = "linux")]
3422
+ #[tokio::test]
3423
+ #[ignore = "requires the task-owned compositor and screenshot driver"]
3424
+ async fn point_wayland_private_flow() {
3425
+ let evidence = std::path::PathBuf::from(
3426
+ std::env::var_os("DESKLINK_POINT_TEST_DIR").expect("private evidence"),
3427
+ );
3428
+ let socket =
3429
+ std::path::PathBuf::from(std::env::var_os("WAYLAND_DISPLAY").expect("private socket"));
3430
+ assert!(
3431
+ socket.is_absolute() && socket.starts_with(evidence.join("runtime")),
3432
+ "never use an ambient desktop"
3433
+ );
3434
+ fn checkpoint(dir: &std::path::Path, name: &str) {
3435
+ std::fs::write(dir.join(format!("{name}.ready")), b"").unwrap();
3436
+ let deadline = Instant::now() + Duration::from_secs(15);
3437
+ while !dir.join(format!("{name}.continue")).exists() {
3438
+ assert!(
3439
+ Instant::now() < deadline,
3440
+ "screenshot driver timed out at {name}"
3441
+ );
3442
+ std::thread::sleep(Duration::from_millis(20));
3443
+ }
3444
+ }
3445
+ let (events, _) = tokio_mpsc::unbounded_channel();
3446
+ let (mut inner, _) = test_inner(events).await;
3447
+ let state = Arc::get_mut(&mut inner).unwrap();
3448
+ state.permissions = vec![Permission::View];
3449
+ state.point_desktop = PointDesktop::Wayland;
3450
+ state.source.width = 1280;
3451
+ state.source.height = 720;
3452
+ state.source.position = Some((1280, 0));
3453
+ let mut session = Session { inner };
3454
+ let mut params = crate::protocol::PointParams {
3455
+ session_id: String::from("test-session"),
3456
+ x: Some(400),
3457
+ y: Some(300),
3458
+ label: String::from("Here"),
3459
+ timeout_ms: Some(3000),
3460
+ clear: false,
3461
+ };
3462
+ let unsupported_socket = std::env::var_os("DESKLINK_POINT_UNSUPPORTED_SOCKET").unwrap();
3463
+ assert!(std::path::Path::new(&unsupported_socket).starts_with(evidence.join("runtime")));
3464
+ // This test runs alone, in a separate process. Only child helper startup
3465
+ // and the read-only clear probe read this private socket environment.
3466
+ std::env::set_var("WAYLAND_DISPLAY", &unsupported_socket);
3467
+ let refusal = session.point(&params).unwrap_err();
3468
+ assert_eq!(refusal.code, "point_unsupported");
3469
+ assert!(refusal.message.contains("reason: layer_shell_unavailable"));
3470
+ let clear = crate::protocol::PointParams {
3471
+ session_id: params.session_id.clone(),
3472
+ clear: true,
3473
+ x: None,
3474
+ y: None,
3475
+ label: String::new(),
3476
+ timeout_ms: None,
3477
+ };
3478
+ let refusal = session.point(&clear).unwrap_err();
3479
+ assert_eq!(refusal.code, "point_unsupported");
3480
+ std::env::set_var("WAYLAND_DISPLAY", &socket);
3481
+ Arc::get_mut(&mut session.inner).unwrap().source.position = None;
3482
+ let ambiguous = session.point(&params).unwrap_err();
3483
+ assert_eq!(ambiguous.code, "indicator-unavailable");
3484
+ assert!(ambiguous.message.contains("selected_output_unavailable"));
3485
+ Arc::get_mut(&mut session.inner).unwrap().source.position = Some((1280, 0));
3486
+ checkpoint(&evidence, "before");
3487
+ session.point(&params).unwrap();
3488
+ checkpoint(&evidence, "after");
3489
+ params.x = Some(1280);
3490
+ assert_eq!(session.point(&params).unwrap_err().code, "coordinates");
3491
+ params.x = Some(400);
3492
+ params.label = String::from("bad\nlabel");
3493
+ assert_eq!(session.point(&params).unwrap_err().code, "malformed");
3494
+ checkpoint(&evidence, "invalid");
3495
+ tokio::time::sleep(Duration::from_millis(3100)).await;
3496
+ checkpoint(&evidence, "expired");
3497
+ params.x = Some(700);
3498
+ params.y = Some(500);
3499
+ params.label.clear();
3500
+ params.timeout_ms = Some(10000);
3501
+ session.point(&params).unwrap();
3502
+ checkpoint(&evidence, "replaced");
3503
+ params.clear = true;
3504
+ params.x = None;
3505
+ params.y = None;
3506
+ params.timeout_ms = None;
3507
+ session.point(&params).unwrap();
3508
+ session.point(&params).unwrap();
3509
+ assert!(lock(&session.inner.point_overlay).is_none());
3510
+ checkpoint(&evidence, "cleared");
3511
+ params.clear = false;
3512
+ params.x = Some(400);
3513
+ params.y = Some(300);
3514
+ session.point(&params).unwrap();
3515
+ let pid = lock(&session.inner.point_overlay)
3516
+ .as_ref()
3517
+ .unwrap()
3518
+ .test_pid();
3519
+ checkpoint(&evidence, "clickthrough");
3520
+ session.close("test complete").await;
3521
+ assert_eq!(
3522
+ unsafe { libc::kill(pid as i32, 0) },
3523
+ -1,
3524
+ "helper reaped on session close"
3525
+ );
3526
+ checkpoint(&evidence, "closed");
3527
+ }
3528
+
3115
3529
  fn test_inner_with(
3116
3530
  events: tokio_mpsc::UnboundedSender<Notice>,
3117
3531
  peer: VideoPeer,
@@ -3157,6 +3571,9 @@ mod tests {
3157
3571
  })),
3158
3572
  capture: Mutex::new(None),
3159
3573
  indicator: Mutex::new(None),
3574
+ point_overlay: Mutex::new(None),
3575
+ #[cfg(target_os = "linux")]
3576
+ point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
3160
3577
  layout: Mutex::new(Some(Layout::from_environment().expect("a keymap"))),
3161
3578
  last_seq: Mutex::new(0),
3162
3579
  control_open: AtomicBool::new(true),
@@ -3165,6 +3582,7 @@ mod tests {
3165
3582
  events,
3166
3583
  encoded: Mutex::new(None),
3167
3584
  local_frames: true,
3585
+ cursor_info: serde_json::json!({"mode":"embedded", "positions":false}),
3168
3586
  });
3169
3587
  (inner, recorded)
3170
3588
  }
@@ -3174,6 +3592,11 @@ mod tests {
3174
3592
  let (events, mut received) = tokio_mpsc::unbounded_channel();
3175
3593
  let (inner, recorded) = test_inner(events).await;
3176
3594
 
3595
+ #[cfg(target_os = "windows")]
3596
+ inner
3597
+ .with_input(|target| target.chord(1, Vec::new(), true))
3598
+ .unwrap();
3599
+ #[cfg(not(target_os = "windows"))]
3177
3600
  inner.apply(ControlMessage::Key {
3178
3601
  name: None,
3179
3602
  character: Some(String::from("a")),
@@ -3227,6 +3650,7 @@ mod tests {
3227
3650
  );
3228
3651
  }
3229
3652
 
3653
+ #[cfg(not(target_os = "windows"))]
3230
3654
  #[tokio::test]
3231
3655
  async fn a_held_modifier_survives_character_named_and_text_chords() {
3232
3656
  use crate::input::keycode;
@@ -3526,6 +3950,7 @@ mod tests {
3526
3950
 
3527
3951
  fn encoded_open() -> OpenRequest {
3528
3952
  OpenRequest {
3953
+ cursor: crate::protocol::CursorMode::Embedded,
3529
3954
  source: Some(SourceRequest::Encoded {
3530
3955
  codec: EncodedCodec::H264,
3531
3956
  width: 486,
@@ -3552,6 +3977,18 @@ mod tests {
3552
3977
  .expect("the channel stays open")
3553
3978
  }
3554
3979
 
3980
+ #[tokio::test]
3981
+ async fn hidden_cursor_is_refused_for_consumer_encoded_pixels() {
3982
+ let (events, _) = tokio_mpsc::unbounded_channel();
3983
+ let mut request = encoded_open();
3984
+ request.cursor = crate::protocol::CursorMode::Hidden;
3985
+ let error = Session::open(request, events)
3986
+ .await
3987
+ .err()
3988
+ .expect("cannot alter encoded pixels");
3989
+ assert_eq!(error.code, "cursor-unavailable");
3990
+ }
3991
+
3555
3992
  #[tokio::test]
3556
3993
  async fn an_encoded_session_opens_without_capture_encoder_or_input() {
3557
3994
  let (events, _received) = tokio_mpsc::unbounded_channel();
@@ -3698,6 +4135,7 @@ mod tests {
3698
4135
  let (events, _received) = tokio_mpsc::unbounded_channel();
3699
4136
  let result = Session::open(
3700
4137
  OpenRequest {
4138
+ cursor: crate::protocol::CursorMode::Embedded,
3701
4139
  source: None,
3702
4140
  permissions: vec![Permission::Control],
3703
4141
  max_width: 640,
@@ -3726,6 +4164,11 @@ mod tests {
3726
4164
  use webrtc::peer_connection::RTCPeerConnectionState as State;
3727
4165
  let (events, mut received) = tokio_mpsc::unbounded_channel();
3728
4166
  let (inner, recorded) = test_inner(events).await;
4167
+ #[cfg(target_os = "windows")]
4168
+ inner
4169
+ .with_input(|target| target.chord(1, Vec::new(), true))
4170
+ .unwrap();
4171
+ #[cfg(not(target_os = "windows"))]
3729
4172
  inner.apply(ControlMessage::Key {
3730
4173
  name: Some(String::from("Control")),
3731
4174
  character: None,
@@ -3942,7 +4385,10 @@ mod tests {
3942
4385
  1000,
3943
4386
  );
3944
4387
 
3945
- // 40 frames over 200 ms is 200 fps; the requested 20 fps caps what leaves.
4388
+ // 40 frames nominally over 200 ms; Windows timers can round each 5 ms
4389
+ // sleep upward, so its cap assertion uses the actual burst duration.
4390
+ #[cfg(target_os = "windows")]
4391
+ let burst_started = Instant::now();
3946
4392
  for _ in 0..40 {
3947
4393
  frame_tx.put(
3948
4394
  I420 {
@@ -3955,9 +4401,13 @@ mod tests {
3955
4401
  tokio::time::sleep(Duration::from_millis(5)).await;
3956
4402
  }
3957
4403
  let motion = inner.metrics.lock().unwrap().encoded_frames;
4404
+ #[cfg(target_os = "windows")]
4405
+ let cap = (burst_started.elapsed().as_secs_f64() * 20.0).ceil() as u64 + 3;
4406
+ #[cfg(not(target_os = "windows"))]
4407
+ let cap = 7;
3958
4408
  assert!(
3959
- motion <= 7,
3960
- "the requested rate is a cap, got {motion} of 40"
4409
+ motion <= cap,
4410
+ "the requested rate is a cap, got {motion} of 40 (cap {cap})"
3961
4411
  );
3962
4412
  assert!(
3963
4413
  motion >= 2,