@desklink/host 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +92 -23
  3. package/bin/desklink-host.mjs +14 -5
  4. package/dist/bridge.d.ts +3 -4
  5. package/dist/bridge.d.ts.map +1 -1
  6. package/dist/bridge.js +18 -7
  7. package/dist/bridge.js.map +1 -1
  8. package/dist/engineProcess.d.ts +6 -1
  9. package/dist/engineProcess.d.ts.map +1 -1
  10. package/dist/engineProcess.js +34 -5
  11. package/dist/engineProcess.js.map +1 -1
  12. package/dist/protocol.d.ts +35 -2
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/protocol.js.map +1 -1
  15. package/dist/resolveEngine.d.ts +6 -0
  16. package/dist/resolveEngine.d.ts.map +1 -1
  17. package/dist/resolveEngine.js +45 -6
  18. package/dist/resolveEngine.js.map +1 -1
  19. package/docs/BOUNDARY.md +8 -8
  20. package/docs/PROTOCOL.md +185 -45
  21. package/engine/Cargo.lock +2 -2
  22. package/engine/Cargo.toml +7 -12
  23. package/engine/build.rs +62 -5
  24. package/engine/native/agent_indicator.h +57 -0
  25. package/engine/native/agent_overlay_mac.m +51 -4
  26. package/engine/native/agent_overlay_wayland.c +189 -35
  27. package/engine/native/agent_overlay_x11.c +186 -55
  28. package/engine/src/capture.rs +96 -5
  29. package/engine/src/capture_win.rs +23 -0
  30. package/engine/src/clipboard.rs +174 -0
  31. package/engine/src/clipboard_win.rs +8 -0
  32. package/engine/src/cursor.rs +118 -0
  33. package/engine/src/indicator.rs +271 -5
  34. package/engine/src/input_win.rs +91 -0
  35. package/engine/src/keymap_win.rs +39 -0
  36. package/engine/src/mac.rs +28 -6
  37. package/engine/src/main.rs +167 -25
  38. package/engine/src/peer.rs +10 -4
  39. package/engine/src/portal.rs +42 -4
  40. package/engine/src/portal_win.rs +37 -0
  41. package/engine/src/protocol.rs +56 -10
  42. package/engine/src/session.rs +749 -203
  43. package/engine/src/win.rs +110 -0
  44. package/engine/src/x11.rs +44 -29
  45. package/engine/src/x11_mac.rs +4 -0
  46. package/examples/reference-client.html +3 -5
  47. package/package.json +4 -4
@@ -1,9 +1,9 @@
1
1
  //! One authorized desktop session: capture in, VP9 out, control channel in,
2
2
  //! desktop input out, and a bounded teardown.
3
3
  //!
4
- //! The engine trusts the consumer's local permission decision and enforces the
5
- //! resulting scope. It has no second identity system, no account and no notion
6
- //! of which application asked.
4
+ //! The consumer's pairing is the authority for all available access. The engine
5
+ //! has no second identity system, no account and no notion of which application
6
+ //! asked.
7
7
 
8
8
  use crate::capture::{self, Capture};
9
9
  use crate::clipboard;
@@ -15,9 +15,7 @@ use crate::peer::{
15
15
  h264_profile_level_id, PathReport, PeerEvent, TransportOptions, VideoCodec, VideoPeer,
16
16
  };
17
17
  use crate::portal::{self, SelectedSource};
18
- use crate::protocol::{
19
- ControlMessage, ControlReply, EncodedCodec, Permission, PointerPhase, SourceRequest,
20
- };
18
+ use crate::protocol::{ControlMessage, ControlReply, EncodedCodec, PointerPhase, SourceRequest};
21
19
  use crate::x11::X11Desktop;
22
20
  use anyhow::{Context, Result};
23
21
  use std::sync::atomic::{AtomicBool, Ordering};
@@ -192,6 +190,98 @@ impl Drop for X11Capture {
192
190
  }
193
191
  }
194
192
 
193
+ #[cfg(target_os = "linux")]
194
+ unsafe extern "C" {
195
+ fn desklink_indicator_covered(
196
+ x: f64,
197
+ y: f64,
198
+ opacity: f64,
199
+ click: f64,
200
+ typed: f64,
201
+ threshold: u32,
202
+ reveal: f64,
203
+ ) -> i32;
204
+ }
205
+
206
+ /// The X11 agent cue is a real window, so root grabs contain it. Restore
207
+ /// exactly the pixels the announced cues cover (one pixel wider for edge
208
+ /// rounding) from the last clean frame; everything else, including what lies
209
+ /// inside the halo, stays live.
210
+ #[cfg(target_os = "linux")]
211
+ struct CueMask {
212
+ cues: crate::indicator::Cues,
213
+ clean: Option<Vec<u8>>,
214
+ }
215
+
216
+ #[cfg(target_os = "linux")]
217
+ impl CueMask {
218
+ fn new(cues: crate::indicator::Cues) -> Self {
219
+ Self { cues, clean: None }
220
+ }
221
+
222
+ fn snapshot(&self) -> Vec<crate::indicator::Cue> {
223
+ self.cues
224
+ .lock()
225
+ .map(|mut feed| feed.drain())
226
+ .unwrap_or_default()
227
+ }
228
+
229
+ /// `before` is the snapshot taken when the grab started: with the states
230
+ /// announced by the time it returned, it covers whatever the grab saw.
231
+ fn apply(
232
+ &mut self,
233
+ raw: &mut [u8],
234
+ width: usize,
235
+ height: usize,
236
+ mut cues: Vec<crate::indicator::Cue>,
237
+ ) {
238
+ cues.extend(self.snapshot());
239
+ if let Some(clean) = self.clean.as_ref().filter(|clean| clean.len() == raw.len()) {
240
+ for cue in cues {
241
+ let side = cue.size as usize + 2;
242
+ let mut covered = vec![false; side * side];
243
+ for row in 0..cue.size as usize {
244
+ for col in 0..cue.size as usize {
245
+ let half = cue.size as f64 / 2.0;
246
+ covered[(row + 1) * side + col + 1] = unsafe {
247
+ desklink_indicator_covered(
248
+ (col as f64 - half) / cue.unit,
249
+ (row as f64 - half) / cue.unit,
250
+ cue.opacity,
251
+ cue.click,
252
+ cue.typed,
253
+ cue.threshold,
254
+ cue.reveal,
255
+ )
256
+ } != 0;
257
+ }
258
+ }
259
+ for row in 0..side {
260
+ for col in 0..side {
261
+ let near =
262
+ |r: usize, c: usize| r < side && c < side && covered[r * side + c];
263
+ if !(near(row, col)
264
+ || near(row.wrapping_sub(1), col)
265
+ || near(row + 1, col)
266
+ || near(row, col.wrapping_sub(1))
267
+ || near(row, col + 1))
268
+ {
269
+ continue;
270
+ }
271
+ let (x, y) = (cue.x - 1 + col as i64, cue.y - 1 + row as i64);
272
+ if x < 0 || y < 0 || x >= width as i64 || y >= height as i64 {
273
+ continue;
274
+ }
275
+ let at = (y as usize * width + x as usize) * 4;
276
+ raw[at..at + 4].copy_from_slice(&clean[at..at + 4]);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ self.clean = Some(raw.to_vec());
282
+ }
283
+ }
284
+
195
285
  /// What a chosen capture backend gives the session.
196
286
  struct Selected {
197
287
  source: SelectedSource,
@@ -202,12 +292,15 @@ struct Selected {
202
292
 
203
293
  /// Open an X display, start reading its root window, and hand back the same
204
294
  /// shape the portal path produces.
295
+ #[allow(clippy::too_many_arguments)]
205
296
  fn select_x11(
206
297
  display: Option<&str>,
207
298
  max_width: usize,
208
299
  max_height: usize,
209
300
  max_fps: u32,
210
301
  metrics: Arc<Mutex<Metrics>>,
302
+ #[cfg(target_os = "linux")] cursor_sink: Option<crate::cursor::CursorSink>,
303
+ #[cfg(target_os = "linux")] cues: Option<crate::indicator::Cues>,
211
304
  sink: capture::FrameSink,
212
305
  on_stop: Box<dyn Fn(String) + Send>,
213
306
  ) -> Result<Selected> {
@@ -238,15 +331,25 @@ fn select_x11(
238
331
  let interval = frame_interval(max_fps);
239
332
  let mut sequence = 0u64;
240
333
  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.
334
+ // XDamage wakes the loop, but pixels decide whether the root
335
+ // image actually changed; without XDamage, polling does so.
243
336
  let hashed = !lock(&desktop).damage_armed();
244
337
  let mut last_hash = None;
245
338
  // The first frame is always grabbed; afterwards Damage wakes
246
339
  // the loop, so a still screen costs no grab, no conversion
247
340
  // and no hash. The fps cap stays a minimum spacing.
248
341
  let mut first = true;
342
+ #[cfg(target_os = "linux")]
343
+ let mut cursor = cursor_sink.map(crate::cursor::Reporter::new);
344
+ #[cfg(target_os = "linux")]
345
+ let mut mask = cues.map(CueMask::new);
249
346
  while !stop.load(Ordering::SeqCst) {
347
+ #[cfg(target_os = "linux")]
348
+ if let Some(cursor) = cursor.as_mut() {
349
+ if let Ok((x, y, visible)) = lock(&desktop).cursor_position() {
350
+ cursor.update(x, y, visible);
351
+ }
352
+ }
250
353
  if !first {
251
354
  let changed = lock(&desktop).take_damage();
252
355
  if !changed {
@@ -262,14 +365,23 @@ fn select_x11(
262
365
  }
263
366
  let started = Instant::now();
264
367
  let grabbed = Instant::now();
368
+ #[cfg(target_os = "linux")]
265
369
  let frame = {
370
+ let before = mask.as_ref().map(CueMask::snapshot);
266
371
  let mut desktop = lock(&desktop);
267
- desktop.capture_with_pixels(max_width, max_height)
372
+ desktop.capture_raw().and_then(|mut raw| {
373
+ if let (Some(mask), Some(before)) = (mask.as_mut(), before) {
374
+ mask.apply(&mut raw, width as usize, height as usize, before);
375
+ }
376
+ Ok((desktop.convert(&raw, max_width, max_height)?, raw))
377
+ })
268
378
  };
379
+ #[cfg(not(target_os = "linux"))]
380
+ let frame = lock(&desktop).capture_with_pixels(max_width, max_height);
269
381
  let grab_micros = grabbed.elapsed().as_micros() as u64;
270
382
  match frame {
271
383
  Ok((frame, raw)) => {
272
- let changed = if hashed {
384
+ let changed = {
273
385
  use std::hash::{Hash, Hasher};
274
386
  let mut hasher = std::collections::hash_map::DefaultHasher::new();
275
387
  raw.hash(&mut hasher);
@@ -280,8 +392,6 @@ fn select_x11(
280
392
  last_hash = Some(hash);
281
393
  true
282
394
  }
283
- } else {
284
- true
285
395
  };
286
396
  if changed {
287
397
  // Handing an unchanged frame on would keep the
@@ -480,10 +590,10 @@ impl InputTarget {
480
590
  Ok(())
481
591
  }
482
592
 
483
- #[cfg(target_os = "macos")]
593
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
484
594
  fn unicode_text(&mut self, text: &str) -> Result<()> {
485
595
  match &mut self.applier {
486
- #[cfg(target_os = "macos")]
596
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
487
597
  Applier::Uinput(devices) => devices.unicode_text(text),
488
598
  #[cfg(target_os = "linux")]
489
599
  Applier::Uinput(_) => anyhow::bail!("Unicode text input is unavailable on Linux"),
@@ -582,9 +692,9 @@ impl SessionError {
582
692
  }
583
693
 
584
694
  pub struct OpenRequest {
695
+ pub cursor: crate::protocol::CursorMode,
585
696
  /** Which desktop to capture; absent means the portal. */
586
697
  pub source: Option<SourceRequest>,
587
- pub permissions: Vec<Permission>,
588
698
  pub max_width: usize,
589
699
  pub max_height: usize,
590
700
  pub bitrate_kbps: u32,
@@ -603,6 +713,9 @@ pub struct OpenRequest {
603
713
  /// Events a session raises for its consumer.
604
714
  #[derive(Debug)]
605
715
  pub enum SessionEvent {
716
+ Cursor {
717
+ position: crate::protocol::CursorPosition,
718
+ },
606
719
  Description {
607
720
  generation: u64,
608
721
  sdp: String,
@@ -619,6 +732,7 @@ pub enum SessionEvent {
619
732
  first_frame: bool,
620
733
  },
621
734
  Frame {
735
+ timestamp_us: u64,
622
736
  seq: u64,
623
737
  damage: Vec<[usize; 4]>,
624
738
  },
@@ -661,10 +775,16 @@ type FrameSnapshot = (u64, Instant, usize, usize, Vec<u8>, Vec<u64>, Vec<u64>);
661
775
 
662
776
  /// Everything a session's background tasks need, shared rather than borrowed so
663
777
  /// the consumer can own the `Session` handle while the pipeline runs.
778
+ #[cfg(target_os = "linux")]
779
+ enum PointDesktop {
780
+ X11(String),
781
+ Wayland,
782
+ Unavailable(&'static str),
783
+ }
784
+
664
785
  struct Inner {
665
786
  id: String,
666
787
  generation: u64,
667
- permissions: Vec<Permission>,
668
788
  source: SelectedSource,
669
789
  geometry: serde_json::Value,
670
790
  metrics: Arc<Mutex<Metrics>>,
@@ -679,6 +799,9 @@ struct Inner {
679
799
  encoder: Mutex<Option<VideoEncoder>>,
680
800
  input: Mutex<Option<InputTarget>>,
681
801
  indicator: Mutex<Option<crate::indicator::Indicator>>,
802
+ point_overlay: Mutex<Option<crate::indicator::Indicator>>,
803
+ #[cfg(target_os = "linux")]
804
+ point_desktop: PointDesktop,
682
805
  capture: Mutex<Option<FrameSource>>,
683
806
  /// Absent for an encoded source: there is no local keyboard to translate a
684
807
  /// character through, because the client's keys are forwarded instead.
@@ -694,6 +817,7 @@ struct Inner {
694
817
  encoded: Mutex<Option<Encoded>>,
695
818
  /// `OpenRequest::local_frames`: whether `latest` is kept at all.
696
819
  local_frames: bool,
820
+ cursor_info: serde_json::Value,
697
821
  }
698
822
 
699
823
  /// A source whose video arrives already encoded.
@@ -741,12 +865,10 @@ pub fn capabilities() -> serde_json::Value {
741
865
  } else {
742
866
  "none"
743
867
  };
744
- // Clipboard transfer needs Wayland; writes also need a selection server
745
- // that can outlive this engine. Do not offer it when that tool is absent.
746
- let clipboard = wayland_clipboard_available();
747
868
  let x11 = crate::x11::X11Desktop::connect(None)
748
869
  .map(|desktop| serde_json::json!([desktop.screen_size().0, desktop.screen_size().1]))
749
870
  .unwrap_or(serde_json::Value::Null);
871
+ let clipboard = wayland_clipboard_available();
750
872
  let (input, grant_state, unavailable) = match crate::input::probe() {
751
873
  Ok(()) => (serde_json::json!(true), "granted", serde_json::Value::Null),
752
874
  Err(unavailable) => (
@@ -772,6 +894,8 @@ pub fn capabilities() -> serde_json::Value {
772
894
  "backends": ["portal-screencast+pipewire", "x11-root"],
773
895
  "formats": ["bgrx", "bgra", "rgbx", "rgba"],
774
896
  "cursor": "embedded",
897
+ "cursor_modes": ["embedded", "hidden"],
898
+ "cursor_positions": {"x11": true, "portal": "negotiated"},
775
899
  "audio": false,
776
900
  },
777
901
  "encode": encode_capabilities(),
@@ -824,6 +948,7 @@ pub fn capabilities() -> serde_json::Value {
824
948
  "backends": ["screencapturekit"],
825
949
  "formats": ["bgra"],
826
950
  "cursor": "embedded",
951
+ "cursor_modes": ["embedded"],
827
952
  "audio": false,
828
953
  "displays": crate::mac::displays(),
829
954
  "grant": if capture_granted { "granted" } else { "missing-screen-recording" },
@@ -849,6 +974,16 @@ pub fn capabilities() -> serde_json::Value {
849
974
  })
850
975
  }
851
976
 
977
+ #[cfg(target_os = "windows")]
978
+ pub fn capabilities() -> serde_json::Value {
979
+ crate::win::capabilities()
980
+ }
981
+
982
+ #[cfg(target_os = "windows")]
983
+ fn wayland_clipboard_available() -> bool {
984
+ false
985
+ }
986
+
852
987
  /// `capabilities.encode`: VP9 always, H.264 when an encoder for it starts
853
988
  /// here now. `hardware` describes VP9, the default.
854
989
  fn encode_capabilities() -> serde_json::Value {
@@ -872,14 +1007,20 @@ impl Session {
872
1007
  request: OpenRequest,
873
1008
  events: tokio_mpsc::UnboundedSender<Notice>,
874
1009
  ) -> std::result::Result<Self, SessionError> {
875
- if !request.permissions.contains(&Permission::View) {
1010
+ if request.cursor == crate::protocol::CursorMode::Hidden {
1011
+ #[cfg(not(target_os = "linux"))]
876
1012
  return Err(SessionError::new(
877
- "permission",
878
- "session.open requires the view permission",
1013
+ "cursor-unavailable",
1014
+ "hidden cursor capture is not implemented on this platform",
879
1015
  ));
1016
+ if matches!(request.source, Some(SourceRequest::Encoded { .. })) {
1017
+ return Err(SessionError::new(
1018
+ "cursor-unavailable",
1019
+ "the engine cannot remove cursor pixels from consumer-encoded video",
1020
+ ));
1021
+ }
880
1022
  }
881
1023
  let id = opaque_id();
882
- let wants_control = request.permissions.contains(&Permission::Control);
883
1024
  let wants_x11 = matches!(request.source, Some(SourceRequest::X11 { .. }));
884
1025
  let max_fps = request.max_fps.clamp(1, 60);
885
1026
  // A source the consumer feeds needs no capture backend, no encoder and
@@ -896,23 +1037,26 @@ impl Session {
896
1037
  // Which desktop decides which input path is even available: an X display
897
1038
  // takes XTest, which cannot reach any other session, while a portal
898
1039
  // desktop needs kernel input access.
899
- if wants_control && !wants_x11 {
900
- // Refuse up front rather than presenting a control surface that
901
- // silently does nothing.
902
- if crate::input::probe().is_err() {
903
- #[cfg(target_os = "macos")]
904
- let _ = crate::input::request_access();
905
- if let Err(unavailable) = crate::input::probe() {
906
- return Err(SessionError::new(
907
- "input-unavailable",
908
- format!("{}; {}", unavailable.reason, unavailable.remedy),
909
- ));
910
- }
911
- }
1040
+ #[cfg(target_os = "macos")]
1041
+ if crate::input::probe().is_err() {
1042
+ let _ = crate::input::request_access();
912
1043
  }
913
1044
 
914
1045
  let metrics = Arc::new(Mutex::new(Metrics::default()));
915
1046
  let mut indicator = None;
1047
+ // The X11 cue starts before capture: its window is in root pixels, so
1048
+ // the capture thread needs the announced cue states from the first grab.
1049
+ #[cfg(target_os = "linux")]
1050
+ if request.agent_indicator && wants_x11 {
1051
+ let display = match &request.source {
1052
+ Some(SourceRequest::X11 { display }) => display.clone(),
1053
+ _ => None,
1054
+ }
1055
+ .or_else(|| std::env::var("DISPLAY").ok());
1056
+ if let Some(display) = display {
1057
+ indicator = crate::indicator::Indicator::start(&display, 0, 0).ok();
1058
+ }
1059
+ }
916
1060
  let (frame_tx, frame_rx) = latest_frame();
917
1061
  let latest = Arc::new(Mutex::new(None::<FrameSnapshot>));
918
1062
  let observed = latest.clone();
@@ -975,7 +1119,7 @@ impl Session {
975
1119
  let damage = dirty_regions(&hashes, &previous, frame.width, frame.height);
976
1120
  *held = Some((
977
1121
  seq,
978
- Instant::now(),
1122
+ captured_at,
979
1123
  frame.width,
980
1124
  frame.height,
981
1125
  raw,
@@ -985,7 +1129,11 @@ impl Session {
985
1129
  frame_tx_signal.send_replace(seq);
986
1130
  let _ = frame_events.send(Notice {
987
1131
  session_id: frame_session_id.clone(),
988
- event: SessionEvent::Frame { seq, damage },
1132
+ event: SessionEvent::Frame {
1133
+ seq,
1134
+ damage,
1135
+ timestamp_us: crate::protocol::timestamp_us(captured_at),
1136
+ },
989
1137
  });
990
1138
  }
991
1139
  }
@@ -1002,6 +1150,21 @@ impl Session {
1002
1150
  }
1003
1151
  });
1004
1152
 
1153
+ #[cfg(target_os = "linux")]
1154
+ let cursor_sink: Option<crate::cursor::CursorSink> =
1155
+ if request.cursor == crate::protocol::CursorMode::Hidden {
1156
+ let events = events.clone();
1157
+ let id = id.clone();
1158
+ Some(Box::new(move |position| {
1159
+ let _ = events.send(Notice {
1160
+ session_id: id.clone(),
1161
+ event: SessionEvent::Cursor { position },
1162
+ });
1163
+ }))
1164
+ } else {
1165
+ None
1166
+ };
1167
+ let mut cursor_info = serde_json::json!({"mode":"embedded", "positions":false});
1005
1168
  let Selected {
1006
1169
  source,
1007
1170
  capture,
@@ -1013,6 +1176,10 @@ impl Session {
1013
1176
  request.max_height,
1014
1177
  max_fps,
1015
1178
  metrics.clone(),
1179
+ #[cfg(target_os = "linux")]
1180
+ cursor_sink,
1181
+ #[cfg(target_os = "linux")]
1182
+ indicator.as_ref().map(crate::indicator::Indicator::cues),
1016
1183
  sink,
1017
1184
  Box::new({
1018
1185
  let status = capture_status.clone();
@@ -1025,9 +1192,30 @@ impl Session {
1025
1192
  Some(SourceRequest::Display { display_id }) => {
1026
1193
  portal::open_display(*display_id).await
1027
1194
  }
1028
- _ => portal::open(request.restore_token.as_deref()).await,
1195
+ _ => {
1196
+ #[cfg(target_os = "linux")]
1197
+ {
1198
+ portal::open(request.restore_token.as_deref(), request.cursor).await
1199
+ }
1200
+ #[cfg(not(target_os = "linux"))]
1201
+ {
1202
+ portal::open(request.restore_token.as_deref()).await
1203
+ }
1204
+ }
1205
+ }
1206
+ .map_err(|error| {
1207
+ let message = format!("{error:#}");
1208
+ let code = if message.contains("cursor_positions_unavailable:") {
1209
+ "cursor_positions_unavailable"
1210
+ } else {
1211
+ "source"
1212
+ };
1213
+ SessionError::new(code, message)
1214
+ })?;
1215
+ #[cfg(target_os = "linux")]
1216
+ {
1217
+ cursor_info = serde_json::json!({"mode":portal.cursor_mode, "positions":false});
1029
1218
  }
1030
- .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1031
1219
  if let Some(token) = &portal.restore_token {
1032
1220
  let _ = events.send(Notice {
1033
1221
  session_id: id.clone(),
@@ -1040,23 +1228,13 @@ impl Session {
1040
1228
  let (width, height) =
1041
1229
  fit(source_w, source_h, request.max_width, request.max_height);
1042
1230
  #[cfg(target_os = "linux")]
1043
- if request.agent_indicator && wants_control {
1044
- indicator = Some(
1045
- crate::indicator::Indicator::start_wayland(source_w, source_h).map_err(
1046
- |error| {
1047
- SessionError::new("indicator-unavailable", format!("{error:#}"))
1048
- },
1049
- )?,
1050
- );
1231
+ if request.agent_indicator {
1232
+ indicator = crate::indicator::Indicator::start_wayland(source_w, source_h).ok();
1051
1233
  }
1052
1234
  #[cfg(target_os = "macos")]
1053
- if request.agent_indicator && wants_control {
1054
- indicator = Some(
1055
- crate::indicator::Indicator::start(source.node_id, source_w, source_h)
1056
- .map_err(|error| {
1057
- SessionError::new("indicator", format!("{error:#}"))
1058
- })?,
1059
- );
1235
+ if request.agent_indicator {
1236
+ indicator =
1237
+ crate::indicator::Indicator::start(source.node_id, source_w, source_h).ok();
1060
1238
  }
1061
1239
  #[cfg(target_os = "macos")]
1062
1240
  let capture = capture::start(
@@ -1072,12 +1250,26 @@ impl Session {
1072
1250
  }),
1073
1251
  )
1074
1252
  .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1253
+ #[cfg(target_os = "windows")]
1254
+ let capture = capture::start(
1255
+ portal,
1256
+ width,
1257
+ height,
1258
+ max_fps,
1259
+ sink,
1260
+ Box::new({
1261
+ let status = capture_status.clone();
1262
+ move |running, reason| status(running, reason)
1263
+ }),
1264
+ )
1265
+ .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1075
1266
  #[cfg(target_os = "linux")]
1076
1267
  let capture = capture::start(
1077
1268
  portal,
1078
1269
  width,
1079
1270
  height,
1080
1271
  max_fps,
1272
+ cursor_sink,
1081
1273
  sink,
1082
1274
  indicator
1083
1275
  .as_ref()
@@ -1089,6 +1281,18 @@ impl Session {
1089
1281
  )
1090
1282
  .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1091
1283
  #[cfg(target_os = "linux")]
1284
+ {
1285
+ if request.cursor == crate::protocol::CursorMode::Hidden
1286
+ && !capture.cursor_positions()
1287
+ {
1288
+ return Err(SessionError::new(
1289
+ "cursor_positions_unavailable",
1290
+ "portal stream did not provide SPA cursor metadata",
1291
+ ));
1292
+ }
1293
+ cursor_info["positions"] = serde_json::json!(capture.cursor_positions());
1294
+ }
1295
+ #[cfg(target_os = "linux")]
1092
1296
  if let Some(indicator) = indicator.as_mut() {
1093
1297
  indicator.event('A', -1, -1);
1094
1298
  }
@@ -1099,23 +1303,12 @@ impl Session {
1099
1303
  }
1100
1304
  }
1101
1305
  };
1306
+ if wants_x11 {
1307
+ cursor_info = serde_json::json!({"mode":"hidden", "positions": request.cursor == crate::protocol::CursorMode::Hidden});
1308
+ }
1102
1309
 
1103
1310
  let source_w = source.width.max(1) as usize;
1104
1311
  let source_h = source.height.max(1) as usize;
1105
- #[cfg(target_os = "linux")]
1106
- if request.agent_indicator && wants_control && wants_x11 {
1107
- let display = match &request.source {
1108
- Some(SourceRequest::X11 {
1109
- display: Some(display),
1110
- }) => display.clone(),
1111
- _ => std::env::var("DISPLAY")
1112
- .map_err(|_| SessionError::new("indicator-unavailable", "no X display"))?,
1113
- };
1114
- indicator = Some(
1115
- crate::indicator::Indicator::start(&display, source_w, source_h)
1116
- .map_err(|error| SessionError::new("indicator", format!("{error:#}")))?,
1117
- );
1118
- }
1119
1312
  let (width, height) = fit(source_w, source_h, request.max_width, request.max_height);
1120
1313
 
1121
1314
  let bitrate_kbps = if request.bitrate_kbps == 0 {
@@ -1134,40 +1327,30 @@ impl Session {
1134
1327
 
1135
1328
  // Everything that can still refuse the session comes before the peer:
1136
1329
  // a peer created and then abandoned keeps its socket and tasks alive.
1137
- let input = if !wants_control {
1138
- None
1139
- } else {
1140
- Some(match &x11 {
1141
- Some(desktop) => InputTarget {
1142
- applier: Applier::X11(desktop.clone()),
1143
- held: HeldState::default(),
1144
- explicit_modifiers: Vec::new(),
1145
- chord_modifiers: Vec::new(),
1146
- chord_keys: Vec::new(),
1147
- wheel_rest: (0.0, 0.0),
1148
- },
1149
- None => InputTarget {
1150
- applier: Applier::Uinput({
1151
- #[cfg(target_os = "linux")]
1152
- let devices = InputDevices::create(source_w as i32, source_h as i32);
1153
- #[cfg(target_os = "macos")]
1154
- let devices = InputDevices::create_for_display(
1155
- source_w as i32,
1156
- source_h as i32,
1157
- source.node_id,
1158
- );
1159
- devices.map_err(|error| {
1160
- SessionError::new("input-unavailable", format!("{error:#}"))
1161
- })?
1162
- }),
1163
- held: HeldState::default(),
1164
- explicit_modifiers: Vec::new(),
1165
- chord_modifiers: Vec::new(),
1166
- chord_keys: Vec::new(),
1167
- wheel_rest: (0.0, 0.0),
1168
- },
1169
- })
1330
+ let applier = match &x11 {
1331
+ Some(desktop) => lock(desktop)
1332
+ .input_available()
1333
+ .then(|| Applier::X11(desktop.clone())),
1334
+ None => {
1335
+ #[cfg(target_os = "linux")]
1336
+ let devices = InputDevices::create(source_w as i32, source_h as i32);
1337
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
1338
+ let devices = InputDevices::create_for_display(
1339
+ source_w as i32,
1340
+ source_h as i32,
1341
+ source.node_id,
1342
+ );
1343
+ devices.ok().map(Applier::Uinput)
1344
+ }
1170
1345
  };
1346
+ let input = applier.map(|applier| InputTarget {
1347
+ applier,
1348
+ held: HeldState::default(),
1349
+ explicit_modifiers: Vec::new(),
1350
+ chord_modifiers: Vec::new(),
1351
+ chord_keys: Vec::new(),
1352
+ wheel_rest: (0.0, 0.0),
1353
+ });
1171
1354
 
1172
1355
  let layout = Layout::from_environment().map_err(|error| {
1173
1356
  SessionError::new("input", format!("no keyboard layout: {error:#}"))
@@ -1210,7 +1393,6 @@ impl Session {
1210
1393
  let inner = Arc::new(Inner {
1211
1394
  id,
1212
1395
  generation,
1213
- permissions: request.permissions,
1214
1396
  source,
1215
1397
  geometry,
1216
1398
  metrics,
@@ -1221,6 +1403,20 @@ impl Session {
1221
1403
  encoder: Mutex::new(Some(VideoEncoder::Vp9(encoder))),
1222
1404
  input: Mutex::new(input),
1223
1405
  indicator: Mutex::new(indicator),
1406
+ point_overlay: Mutex::new(None),
1407
+ #[cfg(target_os = "linux")]
1408
+ point_desktop: if wants_x11 {
1409
+ match &request.source {
1410
+ Some(SourceRequest::X11 { display }) => display
1411
+ .clone()
1412
+ .or_else(|| std::env::var("DISPLAY").ok())
1413
+ .map(PointDesktop::X11)
1414
+ .unwrap_or(PointDesktop::Unavailable("x11-unavailable")),
1415
+ _ => PointDesktop::Unavailable("x11-unavailable"),
1416
+ }
1417
+ } else {
1418
+ PointDesktop::Wayland
1419
+ },
1224
1420
  capture: Mutex::new(Some(capture)),
1225
1421
  layout: Mutex::new(Some(layout)),
1226
1422
  last_seq: Mutex::new(0),
@@ -1230,6 +1426,7 @@ impl Session {
1230
1426
  events: events.clone(),
1231
1427
  encoded: Mutex::new(None),
1232
1428
  local_frames: request.local_frames,
1429
+ cursor_info,
1233
1430
  });
1234
1431
 
1235
1432
  inner.notify(SessionEvent::Description {
@@ -1289,7 +1486,6 @@ impl Session {
1289
1486
  let inner = Arc::new(Inner {
1290
1487
  id,
1291
1488
  generation,
1292
- permissions: request.permissions,
1293
1489
  source: SelectedSource {
1294
1490
  node_id: 0,
1295
1491
  width: width as i32,
@@ -1308,6 +1504,9 @@ impl Session {
1308
1504
  encoder: Mutex::new(None),
1309
1505
  input: Mutex::new(None),
1310
1506
  indicator: Mutex::new(None),
1507
+ point_overlay: Mutex::new(None),
1508
+ #[cfg(target_os = "linux")]
1509
+ point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
1311
1510
  capture: Mutex::new(None),
1312
1511
  layout: Mutex::new(None),
1313
1512
  last_seq: Mutex::new(0),
@@ -1328,6 +1527,7 @@ impl Session {
1328
1527
  feed,
1329
1528
  })),
1330
1529
  local_frames: false,
1530
+ cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
1331
1531
  });
1332
1532
  inner.notify(SessionEvent::State {
1333
1533
  capture: "consented",
@@ -1385,6 +1585,14 @@ impl Session {
1385
1585
  self.inner.generation
1386
1586
  }
1387
1587
 
1588
+ pub fn agent_indicator(&self) -> bool {
1589
+ lock(&self.inner.indicator).is_some()
1590
+ }
1591
+
1592
+ pub fn cursor_info(&self) -> &serde_json::Value {
1593
+ &self.inner.cursor_info
1594
+ }
1595
+
1388
1596
  pub fn geometry(&self) -> &serde_json::Value {
1389
1597
  &self.inner.geometry
1390
1598
  }
@@ -1393,6 +1601,122 @@ impl Session {
1393
1601
  &self.inner.source
1394
1602
  }
1395
1603
 
1604
+ pub fn point(
1605
+ &self,
1606
+ params: &crate::protocol::PointParams,
1607
+ ) -> std::result::Result<(), SessionError> {
1608
+ // Lock through validation and drawing, so a concurrent close cannot leave
1609
+ // a newly spawned marker alive after teardown.
1610
+ let mut overlay = lock(&self.inner.point_overlay);
1611
+ if self.inner.closed.load(Ordering::SeqCst) {
1612
+ return Err(SessionError::new("session", "the session is closed"));
1613
+ }
1614
+ if lock(&self.inner.encoded).is_some() {
1615
+ return Err(SessionError::new(
1616
+ "operation",
1617
+ "an encoded source has no host desktop",
1618
+ ));
1619
+ }
1620
+ #[cfg(target_os = "linux")]
1621
+ if let PointDesktop::Unavailable(reason) = &self.inner.point_desktop {
1622
+ return Err(SessionError::new(
1623
+ "point_unsupported",
1624
+ format!("reason: {reason}; point overlay is unavailable"),
1625
+ ));
1626
+ }
1627
+ if params.clear {
1628
+ if params.x.is_some()
1629
+ || params.y.is_some()
1630
+ || params.timeout_ms.is_some()
1631
+ || !params.label.is_empty()
1632
+ {
1633
+ return Err(SessionError::new(
1634
+ "malformed",
1635
+ "clear accepts no coordinates, label or timeout",
1636
+ ));
1637
+ }
1638
+ #[cfg(target_os = "linux")]
1639
+ if overlay.is_none() && matches!(self.inner.point_desktop, PointDesktop::Wayland) {
1640
+ let available = crate::indicator::Indicator::wayland_layer_shell_available()
1641
+ .map_err(|error| {
1642
+ SessionError::new("indicator-unavailable", format!("{error:#}"))
1643
+ })?;
1644
+ if !available {
1645
+ return Err(SessionError::new(
1646
+ "point_unsupported",
1647
+ "reason: layer_shell_unavailable; compositor has no layer-shell",
1648
+ ));
1649
+ }
1650
+ }
1651
+ if let Some(helper) = overlay.take() {
1652
+ helper.stop();
1653
+ }
1654
+ return Ok(());
1655
+ }
1656
+ let (x, y) = params
1657
+ .x
1658
+ .zip(params.y)
1659
+ .ok_or_else(|| SessionError::new("malformed", "point requires x and y"))?;
1660
+ let source = &self.inner.source;
1661
+ if x < 0 || y < 0 || x >= i64::from(source.width) || y >= i64::from(source.height) {
1662
+ return Err(SessionError::new(
1663
+ "coordinates",
1664
+ "point is outside the selected desktop",
1665
+ ));
1666
+ }
1667
+ let timeout = params.timeout_ms.unwrap_or(3000);
1668
+ if !(1..=120_000).contains(&timeout)
1669
+ || params.label.len() > 96
1670
+ || !params.label.bytes().all(|b| (32..=126).contains(&b))
1671
+ {
1672
+ return Err(SessionError::new(
1673
+ "malformed",
1674
+ "timeout must be 1..120000 ms; label must be at most 96 printable ASCII bytes",
1675
+ ));
1676
+ }
1677
+ #[cfg(not(target_os = "windows"))]
1678
+ if overlay.is_none() {
1679
+ #[cfg(target_os = "linux")]
1680
+ let helper = match &self.inner.point_desktop {
1681
+ PointDesktop::X11(display) => crate::indicator::Indicator::start_point(display),
1682
+ PointDesktop::Wayland => crate::indicator::Indicator::start_point_wayland(
1683
+ source.width,
1684
+ source.height,
1685
+ source.position,
1686
+ ),
1687
+ PointDesktop::Unavailable(_) => unreachable!("validated point target"),
1688
+ };
1689
+ #[cfg(target_os = "macos")]
1690
+ let helper = crate::indicator::Indicator::start_point(
1691
+ source.node_id,
1692
+ source.width as usize,
1693
+ source.height as usize,
1694
+ );
1695
+ *overlay = Some(helper.map_err(|error| {
1696
+ let message = format!("{error:#}");
1697
+ if message.contains("layer_shell_unavailable") {
1698
+ SessionError::new(
1699
+ "point_unsupported",
1700
+ "reason: layer_shell_unavailable; compositor has no layer-shell",
1701
+ )
1702
+ } else {
1703
+ SessionError::new("indicator-unavailable", message)
1704
+ }
1705
+ })?);
1706
+ }
1707
+ #[cfg(not(target_os = "windows"))]
1708
+ return overlay
1709
+ .as_mut()
1710
+ .unwrap()
1711
+ .point(x, y, timeout, &params.label)
1712
+ .map_err(|error| SessionError::new("indicator-unavailable", format!("{error:#}")));
1713
+ #[cfg(target_os = "windows")]
1714
+ return Err(SessionError::new(
1715
+ "point_unsupported",
1716
+ "Windows point overlays are unavailable in this build.",
1717
+ ));
1718
+ }
1719
+
1396
1720
  pub async fn wait_frame(
1397
1721
  &self,
1398
1722
  after_seq: Option<u64>,
@@ -1459,12 +1783,6 @@ impl Session {
1459
1783
  path: &str,
1460
1784
  region: Option<[usize; 4]>,
1461
1785
  ) -> std::result::Result<serde_json::Value, SessionError> {
1462
- if !self.inner.permissions.contains(&Permission::View) {
1463
- return Err(SessionError::new(
1464
- "permission",
1465
- "view permission is required",
1466
- ));
1467
- }
1468
1786
  if lock(&self.inner.encoded).is_some() {
1469
1787
  return Err(SessionError::new(
1470
1788
  "operation",
@@ -1507,7 +1825,7 @@ impl Session {
1507
1825
  }
1508
1826
  Some(pixels)
1509
1827
  };
1510
- let result = serde_json::json!({ "seq": seq, "still_ms": at.elapsed().as_millis() as u64,
1828
+ let result = serde_json::json!({ "seq": seq, "timestamp_us": crate::protocol::timestamp_us(*at), "still_ms": at.elapsed().as_millis() as u64,
1511
1829
  "width": w, "height": h, "format": "bgrx", "damage": damage, "written": !path.is_empty() });
1512
1830
  drop(held);
1513
1831
  if let Some(pixels) = pixels {
@@ -1570,20 +1888,18 @@ impl Session {
1570
1888
  if let Some((_, reason)) = self.inner.clipboard_refusal() {
1571
1889
  return Err(reason.to_owned());
1572
1890
  }
1573
- clipboard_task(move || clipboard::read_or_explain()).await
1891
+ let inner = self.inner.clone();
1892
+ clipboard_task(move || inner.clipboard_read_text()).await
1574
1893
  }
1575
1894
 
1576
1895
  pub async fn write_clipboard(&self, text: String) -> std::result::Result<(), String> {
1577
1896
  if let Some((_, reason)) = self.inner.clipboard_refusal() {
1578
1897
  return Err(reason.to_owned());
1579
1898
  }
1580
- clipboard_task(move || {
1581
- clipboard::write(&text)
1582
- .map(|()| String::new())
1583
- .map_err(|error| format!("{error:#}"))
1584
- })
1585
- .await
1586
- .map(|_written| ())
1899
+ let inner = self.inner.clone();
1900
+ clipboard_task(move || inner.clipboard_write_text(&text))
1901
+ .await
1902
+ .map(|_written| ())
1587
1903
  }
1588
1904
 
1589
1905
  pub fn mark_closed_reason(&self) -> Option<String> {
@@ -1630,6 +1946,9 @@ impl Inner {
1630
1946
  if let Ok(mut indicator) = self.indicator.lock() {
1631
1947
  indicator.take();
1632
1948
  }
1949
+ if let Some(helper) = lock(&self.point_overlay).take() {
1950
+ helper.stop();
1951
+ }
1633
1952
  // Release first, then tear down: a stuck modifier is the one failure the
1634
1953
  // user cannot undo by reconnecting.
1635
1954
  if let Ok(mut input) = self.input.lock() {
@@ -1669,24 +1988,40 @@ impl Inner {
1669
1988
  }
1670
1989
  }
1671
1990
 
1672
- fn has(&self, permission: Permission) -> bool {
1673
- self.permissions.contains(&permission)
1991
+ fn clipboard_read_text(&self) -> std::result::Result<(String, bool), String> {
1992
+ #[cfg(target_os = "linux")]
1993
+ if let PointDesktop::X11(display) = &self.point_desktop {
1994
+ return clipboard::x11_read(display).map_err(|error| format!("{error:#}"));
1995
+ }
1996
+ clipboard::read_or_explain()
1997
+ }
1998
+
1999
+ fn clipboard_write_text(&self, text: &str) -> std::result::Result<String, String> {
2000
+ #[cfg(target_os = "linux")]
2001
+ if let PointDesktop::X11(display) = &self.point_desktop {
2002
+ return clipboard::x11_write(display, text)
2003
+ .map(|()| String::new())
2004
+ .map_err(|error| format!("{error:#}"));
2005
+ }
2006
+ clipboard::write(text)
2007
+ .map(|()| String::new())
2008
+ .map_err(|error| format!("{error:#}"))
1674
2009
  }
1675
2010
 
1676
2011
  fn clipboard_refusal(&self) -> Option<(&'static str, &'static str)> {
1677
- if matches!(
1678
- self.source.source_type.as_deref(),
1679
- Some("x11-root") | Some("encoded")
1680
- ) || !wayland_clipboard_available()
1681
- {
2012
+ if self.closed.load(Ordering::Relaxed) {
2013
+ return Some(("session", "the session has ended"));
2014
+ }
2015
+ #[cfg(target_os = "linux")]
2016
+ if self.source.source_type.as_deref() == Some("x11-root") {
2017
+ return None;
2018
+ }
2019
+ if self.source.source_type.as_deref() == Some("encoded") || !wayland_clipboard_available() {
1682
2020
  return Some((
1683
2021
  "clipboard-unsupported",
1684
2022
  "clipboard is unavailable for this desktop source",
1685
2023
  ));
1686
2024
  }
1687
- if !self.has(Permission::Clipboard) {
1688
- return Some(("permission", "this session has no clipboard permission"));
1689
- }
1690
2025
  None
1691
2026
  }
1692
2027
 
@@ -1714,7 +2049,7 @@ impl Inner {
1714
2049
  Ok(())
1715
2050
  }
1716
2051
 
1717
- /// Admit one client action. Validation, permission and ordering checks all
2052
+ /// Admit one client action. Validation and ordering checks all
1718
2053
  /// happen before any physical effect, so a refused action leaves the desktop
1719
2054
  /// exactly as it was.
1720
2055
  fn apply(self: &Arc<Self>, message: ControlMessage) {
@@ -1727,10 +2062,6 @@ impl Inner {
1727
2062
  self.reject(seq, "session", "the control channel is not open");
1728
2063
  return;
1729
2064
  }
1730
- if !self.has(Permission::Control) {
1731
- self.reject(seq, "permission", "this session is view-only");
1732
- return;
1733
- }
1734
2065
  if seq != 0 {
1735
2066
  let mut last = match self.last_seq.lock() {
1736
2067
  Ok(last) => last,
@@ -1767,6 +2098,14 @@ impl Inner {
1767
2098
  self.forward(message);
1768
2099
  return;
1769
2100
  }
2101
+ if !matches!(
2102
+ &message,
2103
+ ControlMessage::ClipboardRead { .. } | ControlMessage::ClipboardWrite { .. }
2104
+ ) && lock(&self.input).is_none()
2105
+ {
2106
+ self.reject(seq, "input-unavailable", "no input backend");
2107
+ return;
2108
+ }
1770
2109
  let outcome = match message {
1771
2110
  ControlMessage::Pointer {
1772
2111
  phase,
@@ -1976,7 +2315,7 @@ impl Inner {
1976
2315
  if text.len() > 4096 {
1977
2316
  return Err(("text-too-large", String::from("text exceeds 4096 bytes")));
1978
2317
  }
1979
- #[cfg(target_os = "macos")]
2318
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
1980
2319
  {
1981
2320
  let _ = seq;
1982
2321
  return self.with_input(|target| target.unicode_text(text));
@@ -2042,10 +2381,12 @@ impl Inner {
2042
2381
  }
2043
2382
  let inner = Arc::clone(self);
2044
2383
  tokio::spawn(async move {
2045
- let (text, truncated, error) = match clipboard_task(clipboard::read_or_explain).await {
2046
- Ok((text, truncated)) => (text, truncated, None),
2047
- Err(reason) => (String::new(), false, Some(reason)),
2048
- };
2384
+ let reader = inner.clone();
2385
+ let (text, truncated, error) =
2386
+ match clipboard_task(move || reader.clipboard_read_text()).await {
2387
+ Ok((text, truncated)) => (text, truncated, None),
2388
+ Err(reason) => (String::new(), false, Some(reason)),
2389
+ };
2049
2390
  let _ = inner.reply(&ControlReply::Clipboard {
2050
2391
  request: &request,
2051
2392
  text,
@@ -2062,13 +2403,10 @@ impl Inner {
2062
2403
  }
2063
2404
  let inner = Arc::clone(self);
2064
2405
  tokio::spawn(async move {
2065
- let error = clipboard_task(move || {
2066
- clipboard::write(&text)
2067
- .map(|()| String::new())
2068
- .map_err(|error| format!("{error:#}"))
2069
- })
2070
- .await
2071
- .err();
2406
+ let writer = inner.clone();
2407
+ let error = clipboard_task(move || writer.clipboard_write_text(&text))
2408
+ .await
2409
+ .err();
2072
2410
  let _ = inner.reply(&ControlReply::Clipboard {
2073
2411
  request: &request,
2074
2412
  text: String::new(),
@@ -2940,6 +3278,60 @@ fn opaque_id() -> String {
2940
3278
 
2941
3279
  #[cfg(test)]
2942
3280
  mod tests {
3281
+ #[cfg(target_os = "linux")]
3282
+ #[test]
3283
+ fn x11_cue_mask_restores_only_the_cue_pixels() {
3284
+ use crate::indicator::Cue;
3285
+ let (w, h) = (200usize, 120usize);
3286
+ let at = |x: usize, y: usize| (y * w + x) * 4;
3287
+ let (cx, cy) = (20 + 55, 10 + 55);
3288
+ // Shaped (128) and composited (1) helpers announce different thresholds.
3289
+ for threshold in [128, 1] {
3290
+ let cue = Cue {
3291
+ x: 20,
3292
+ y: 10,
3293
+ size: 110,
3294
+ unit: 1.0,
3295
+ opacity: 1.0,
3296
+ click: -1.0,
3297
+ typed: -1.0,
3298
+ threshold,
3299
+ reveal: 99.0,
3300
+ };
3301
+ let mut mask = CueMask::new(Default::default());
3302
+ let clean: Vec<u8> = (0..w * h * 4).map(|i| (i % 251) as u8).collect();
3303
+ mask.apply(&mut clean.clone(), w, h, vec![]);
3304
+ // Paint the cue's keyline and live content inside the halo.
3305
+ let mut frame = clean.clone();
3306
+ frame[at(cx - 24, cy)..at(cx - 24, cy) + 4].fill(255);
3307
+ frame[at(cx - 5, cy)..at(cx - 5, cy) + 4].fill(7);
3308
+ frame[at(190, 110)..at(190, 110) + 4].fill(9);
3309
+ mask.apply(&mut frame, w, h, vec![cue]);
3310
+ assert_eq!(
3311
+ &frame[at(cx - 24, cy)..at(cx - 24, cy) + 4],
3312
+ &clean[at(cx - 24, cy)..at(cx - 24, cy) + 4]
3313
+ );
3314
+ assert_eq!(
3315
+ &frame[at(cx - 5, cy)..at(cx - 5, cy) + 4],
3316
+ &[7; 4],
3317
+ "the halo interior stays live"
3318
+ );
3319
+ assert_eq!(
3320
+ &frame[at(190, 110)..at(190, 110) + 4],
3321
+ &[9; 4],
3322
+ "pixels away from the cue stay live"
3323
+ );
3324
+ let mut after = clean.clone();
3325
+ after[at(cx - 24, cy)..at(cx - 24, cy) + 4].fill(3);
3326
+ mask.apply(&mut after, w, h, vec![]);
3327
+ assert_eq!(
3328
+ &after[at(cx - 24, cy)..at(cx - 24, cy) + 4],
3329
+ &[3; 4],
3330
+ "a hidden cue masks nothing"
3331
+ );
3332
+ }
3333
+ }
3334
+
2943
3335
  use super::*;
2944
3336
 
2945
3337
  #[tokio::test]
@@ -2950,7 +3342,6 @@ mod tests {
2950
3342
  let inner = Arc::new(Inner {
2951
3343
  id: String::from("encoded-session"),
2952
3344
  generation: 1,
2953
- permissions: Vec::new(),
2954
3345
  source: SelectedSource {
2955
3346
  node_id: 0,
2956
3347
  width: 640,
@@ -2973,6 +3364,9 @@ mod tests {
2973
3364
  encoder: Mutex::new(None),
2974
3365
  input: Mutex::new(None),
2975
3366
  indicator: Mutex::new(None),
3367
+ point_overlay: Mutex::new(None),
3368
+ #[cfg(target_os = "linux")]
3369
+ point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
2976
3370
  capture: Mutex::new(None),
2977
3371
  layout: Mutex::new(None),
2978
3372
  last_seq: Mutex::new(0),
@@ -2991,6 +3385,7 @@ mod tests {
2991
3385
  feed,
2992
3386
  })),
2993
3387
  local_frames: false,
3388
+ cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
2994
3389
  });
2995
3390
  spawn_peer_events(&inner, peer_events_rx);
2996
3391
  spawn_encoded(&inner, feed_rx);
@@ -3112,6 +3507,136 @@ mod tests {
3112
3507
  test_inner_with(events, peer, 64, 64)
3113
3508
  }
3114
3509
 
3510
+ #[cfg(target_os = "linux")]
3511
+ #[tokio::test]
3512
+ async fn a_wayland_portal_session_reports_typed_point_unsupported() {
3513
+ let (events, _) = tokio_mpsc::unbounded_channel();
3514
+ let (inner, _) = test_inner(events).await;
3515
+ let session = Session { inner };
3516
+
3517
+ let error = session
3518
+ .point(&crate::protocol::PointParams {
3519
+ session_id: String::from("test-session"),
3520
+ x: Some(10),
3521
+ y: Some(20),
3522
+ label: String::new(),
3523
+ timeout_ms: None,
3524
+ clear: false,
3525
+ })
3526
+ .expect_err("compositors without layer-shell do not support point overlays");
3527
+
3528
+ assert_eq!(error.code, "point_unsupported");
3529
+ assert!(error.message.contains("reason: layer_shell_unavailable"));
3530
+ }
3531
+
3532
+ // Driven only on the private headless compositor created by point-wayland-flow.py.
3533
+ #[cfg(target_os = "linux")]
3534
+ #[tokio::test]
3535
+ #[ignore = "requires the task-owned compositor and screenshot driver"]
3536
+ async fn point_wayland_private_flow() {
3537
+ let evidence = std::path::PathBuf::from(
3538
+ std::env::var_os("DESKLINK_POINT_TEST_DIR").expect("private evidence"),
3539
+ );
3540
+ let socket =
3541
+ std::path::PathBuf::from(std::env::var_os("WAYLAND_DISPLAY").expect("private socket"));
3542
+ assert!(
3543
+ socket.is_absolute() && socket.starts_with(evidence.join("runtime")),
3544
+ "never use an ambient desktop"
3545
+ );
3546
+ fn checkpoint(dir: &std::path::Path, name: &str) {
3547
+ std::fs::write(dir.join(format!("{name}.ready")), b"").unwrap();
3548
+ let deadline = Instant::now() + Duration::from_secs(15);
3549
+ while !dir.join(format!("{name}.continue")).exists() {
3550
+ assert!(
3551
+ Instant::now() < deadline,
3552
+ "screenshot driver timed out at {name}"
3553
+ );
3554
+ std::thread::sleep(Duration::from_millis(20));
3555
+ }
3556
+ }
3557
+ let (events, _) = tokio_mpsc::unbounded_channel();
3558
+ let (mut inner, _) = test_inner(events).await;
3559
+ let state = Arc::get_mut(&mut inner).unwrap();
3560
+ state.point_desktop = PointDesktop::Wayland;
3561
+ state.source.width = 1280;
3562
+ state.source.height = 720;
3563
+ state.source.position = Some((1280, 0));
3564
+ let mut session = Session { inner };
3565
+ let mut params = crate::protocol::PointParams {
3566
+ session_id: String::from("test-session"),
3567
+ x: Some(400),
3568
+ y: Some(300),
3569
+ label: String::from("Here"),
3570
+ timeout_ms: Some(3000),
3571
+ clear: false,
3572
+ };
3573
+ let unsupported_socket = std::env::var_os("DESKLINK_POINT_UNSUPPORTED_SOCKET").unwrap();
3574
+ assert!(std::path::Path::new(&unsupported_socket).starts_with(evidence.join("runtime")));
3575
+ // This test runs alone, in a separate process. Only child helper startup
3576
+ // and the read-only clear probe read this private socket environment.
3577
+ std::env::set_var("WAYLAND_DISPLAY", &unsupported_socket);
3578
+ let refusal = session.point(&params).unwrap_err();
3579
+ assert_eq!(refusal.code, "point_unsupported");
3580
+ assert!(refusal.message.contains("reason: layer_shell_unavailable"));
3581
+ let clear = crate::protocol::PointParams {
3582
+ session_id: params.session_id.clone(),
3583
+ clear: true,
3584
+ x: None,
3585
+ y: None,
3586
+ label: String::new(),
3587
+ timeout_ms: None,
3588
+ };
3589
+ let refusal = session.point(&clear).unwrap_err();
3590
+ assert_eq!(refusal.code, "point_unsupported");
3591
+ std::env::set_var("WAYLAND_DISPLAY", &socket);
3592
+ Arc::get_mut(&mut session.inner).unwrap().source.position = None;
3593
+ let ambiguous = session.point(&params).unwrap_err();
3594
+ assert_eq!(ambiguous.code, "indicator-unavailable");
3595
+ assert!(ambiguous.message.contains("selected_output_unavailable"));
3596
+ Arc::get_mut(&mut session.inner).unwrap().source.position = Some((1280, 0));
3597
+ checkpoint(&evidence, "before");
3598
+ session.point(&params).unwrap();
3599
+ checkpoint(&evidence, "after");
3600
+ params.x = Some(1280);
3601
+ assert_eq!(session.point(&params).unwrap_err().code, "coordinates");
3602
+ params.x = Some(400);
3603
+ params.label = String::from("bad\nlabel");
3604
+ assert_eq!(session.point(&params).unwrap_err().code, "malformed");
3605
+ checkpoint(&evidence, "invalid");
3606
+ tokio::time::sleep(Duration::from_millis(3100)).await;
3607
+ checkpoint(&evidence, "expired");
3608
+ params.x = Some(700);
3609
+ params.y = Some(500);
3610
+ params.label.clear();
3611
+ params.timeout_ms = Some(10000);
3612
+ session.point(&params).unwrap();
3613
+ checkpoint(&evidence, "replaced");
3614
+ params.clear = true;
3615
+ params.x = None;
3616
+ params.y = None;
3617
+ params.timeout_ms = None;
3618
+ session.point(&params).unwrap();
3619
+ session.point(&params).unwrap();
3620
+ assert!(lock(&session.inner.point_overlay).is_none());
3621
+ checkpoint(&evidence, "cleared");
3622
+ params.clear = false;
3623
+ params.x = Some(400);
3624
+ params.y = Some(300);
3625
+ session.point(&params).unwrap();
3626
+ let pid = lock(&session.inner.point_overlay)
3627
+ .as_ref()
3628
+ .unwrap()
3629
+ .test_pid();
3630
+ checkpoint(&evidence, "clickthrough");
3631
+ session.close("test complete").await;
3632
+ assert_eq!(
3633
+ unsafe { libc::kill(pid as i32, 0) },
3634
+ -1,
3635
+ "helper reaped on session close"
3636
+ );
3637
+ checkpoint(&evidence, "closed");
3638
+ }
3639
+
3115
3640
  fn test_inner_with(
3116
3641
  events: tokio_mpsc::UnboundedSender<Notice>,
3117
3642
  peer: VideoPeer,
@@ -3124,7 +3649,6 @@ mod tests {
3124
3649
  let inner = Arc::new(Inner {
3125
3650
  id: String::from("test-session"),
3126
3651
  generation: 1,
3127
- permissions: vec![Permission::Control],
3128
3652
  source: SelectedSource {
3129
3653
  node_id: 0,
3130
3654
  width: 640,
@@ -3157,6 +3681,9 @@ mod tests {
3157
3681
  })),
3158
3682
  capture: Mutex::new(None),
3159
3683
  indicator: Mutex::new(None),
3684
+ point_overlay: Mutex::new(None),
3685
+ #[cfg(target_os = "linux")]
3686
+ point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
3160
3687
  layout: Mutex::new(Some(Layout::from_environment().expect("a keymap"))),
3161
3688
  last_seq: Mutex::new(0),
3162
3689
  control_open: AtomicBool::new(true),
@@ -3165,15 +3692,51 @@ mod tests {
3165
3692
  events,
3166
3693
  encoded: Mutex::new(None),
3167
3694
  local_frames: true,
3695
+ cursor_info: serde_json::json!({"mode":"embedded", "positions":false}),
3168
3696
  });
3169
3697
  (inner, recorded)
3170
3698
  }
3171
3699
 
3700
+ #[tokio::test]
3701
+ async fn paired_input_without_a_backend_is_an_action_failure() {
3702
+ let (events, _) = tokio_mpsc::unbounded_channel();
3703
+ let (inner, recorded) = test_inner(events).await;
3704
+ *lock(&inner.input) = None;
3705
+ assert_eq!(
3706
+ inner.with_input(|_| Ok(())).unwrap_err().0,
3707
+ "input-unavailable"
3708
+ );
3709
+ inner.apply(ControlMessage::Pointer {
3710
+ phase: PointerPhase::Move,
3711
+ x: 10,
3712
+ y: 20,
3713
+ button: 1,
3714
+ seq: 1,
3715
+ });
3716
+ assert_eq!(
3717
+ *lock(&inner.last_seq),
3718
+ 1,
3719
+ "paired action passes admission, never a permission refusal"
3720
+ );
3721
+ assert_eq!(lock(&inner.metrics).input_rejected, 1);
3722
+ assert_eq!(lock(&inner.metrics).input_applied, 0);
3723
+ assert!(lock(&recorded).is_empty());
3724
+ assert!(
3725
+ !inner.closed.load(Ordering::Relaxed),
3726
+ "missing input does not close view access"
3727
+ );
3728
+ }
3729
+
3172
3730
  #[tokio::test]
3173
3731
  async fn a_lease_that_expires_ends_the_session_and_releases_held_input() {
3174
3732
  let (events, mut received) = tokio_mpsc::unbounded_channel();
3175
3733
  let (inner, recorded) = test_inner(events).await;
3176
3734
 
3735
+ #[cfg(target_os = "windows")]
3736
+ inner
3737
+ .with_input(|target| target.chord(1, Vec::new(), true))
3738
+ .unwrap();
3739
+ #[cfg(not(target_os = "windows"))]
3177
3740
  inner.apply(ControlMessage::Key {
3178
3741
  name: None,
3179
3742
  character: Some(String::from("a")),
@@ -3227,6 +3790,7 @@ mod tests {
3227
3790
  );
3228
3791
  }
3229
3792
 
3793
+ #[cfg(not(target_os = "windows"))]
3230
3794
  #[tokio::test]
3231
3795
  async fn a_held_modifier_survives_character_named_and_text_chords() {
3232
3796
  use crate::input::keycode;
@@ -3421,38 +3985,24 @@ mod tests {
3421
3985
  }
3422
3986
 
3423
3987
  #[tokio::test]
3424
- async fn x11_capture_refuses_both_clipboard_directions() {
3988
+ async fn x11_capture_does_not_refuse_clipboard() {
3425
3989
  let (events, _received) = tokio_mpsc::unbounded_channel();
3426
3990
  let (mut inner, _recorded) = test_inner(events).await;
3427
3991
  let owned = Arc::get_mut(&mut inner).unwrap();
3428
3992
  owned.source.source_type = Some(String::from("x11-root"));
3429
- owned.permissions.push(Permission::Clipboard);
3430
- let session = Session {
3431
- inner: inner.clone(),
3432
- };
3433
- assert!(session
3434
- .read_clipboard()
3435
- .await
3436
- .unwrap_err()
3437
- .contains("unavailable"));
3438
- assert!(session
3439
- .write_clipboard(String::from("secret"))
3440
- .await
3441
- .unwrap_err()
3442
- .contains("unavailable"));
3443
- inner.clipboard_read(String::from("read"));
3444
- inner.clipboard_write(String::from("write"), String::from("secret"));
3445
- assert_eq!(inner.metrics.lock().unwrap().input_rejected, 2);
3993
+ #[cfg(target_os = "linux")]
3994
+ assert_eq!(inner.clipboard_refusal(), None);
3995
+ Arc::get_mut(&mut inner).unwrap().source.source_type = Some(String::from("encoded"));
3996
+ assert_eq!(
3997
+ inner.clipboard_refusal().unwrap().0,
3998
+ "clipboard-unsupported"
3999
+ );
3446
4000
  }
3447
4001
 
3448
4002
  #[tokio::test]
3449
4003
  async fn a_stopped_capture_never_serves_the_last_frame() {
3450
4004
  let (events, _) = tokio_mpsc::unbounded_channel();
3451
- let (mut inner, _) = test_inner(events).await;
3452
- Arc::get_mut(&mut inner)
3453
- .unwrap()
3454
- .permissions
3455
- .push(Permission::View);
4005
+ let (inner, _) = test_inner(events).await;
3456
4006
  let session = Session { inner };
3457
4007
  *lock(&session.inner.latest) =
3458
4008
  Some((1, Instant::now(), 2, 2, vec![0; 16], vec![1], vec![]));
@@ -3526,12 +4076,12 @@ mod tests {
3526
4076
 
3527
4077
  fn encoded_open() -> OpenRequest {
3528
4078
  OpenRequest {
4079
+ cursor: crate::protocol::CursorMode::Embedded,
3529
4080
  source: Some(SourceRequest::Encoded {
3530
4081
  codec: EncodedCodec::H264,
3531
4082
  width: 486,
3532
4083
  height: 1080,
3533
4084
  }),
3534
- permissions: vec![Permission::View, Permission::Control],
3535
4085
  max_width: 3840,
3536
4086
  max_height: 2160,
3537
4087
  bitrate_kbps: 0,
@@ -3552,6 +4102,18 @@ mod tests {
3552
4102
  .expect("the channel stays open")
3553
4103
  }
3554
4104
 
4105
+ #[tokio::test]
4106
+ async fn hidden_cursor_is_refused_for_consumer_encoded_pixels() {
4107
+ let (events, _) = tokio_mpsc::unbounded_channel();
4108
+ let mut request = encoded_open();
4109
+ request.cursor = crate::protocol::CursorMode::Hidden;
4110
+ let error = Session::open(request, events)
4111
+ .await
4112
+ .err()
4113
+ .expect("cannot alter encoded pixels");
4114
+ assert_eq!(error.code, "cursor-unavailable");
4115
+ }
4116
+
3555
4117
  #[tokio::test]
3556
4118
  async fn an_encoded_session_opens_without_capture_encoder_or_input() {
3557
4119
  let (events, _received) = tokio_mpsc::unbounded_channel();
@@ -3693,39 +4255,16 @@ mod tests {
3693
4255
  );
3694
4256
  }
3695
4257
 
3696
- #[tokio::test]
3697
- async fn opening_without_view_permission_is_refused_before_capture() {
3698
- let (events, _received) = tokio_mpsc::unbounded_channel();
3699
- let result = Session::open(
3700
- OpenRequest {
3701
- source: None,
3702
- permissions: vec![Permission::Control],
3703
- max_width: 640,
3704
- max_height: 480,
3705
- bitrate_kbps: 0,
3706
- max_fps: 30,
3707
- ice_servers: Vec::new(),
3708
- restore_token: None,
3709
- ttl: None,
3710
- loopback_tcp: false,
3711
- agent_indicator: false,
3712
- local_frames: true,
3713
- },
3714
- events,
3715
- )
3716
- .await;
3717
- let error = match result {
3718
- Ok(_) => panic!("a viewless session must not open"),
3719
- Err(error) => error,
3720
- };
3721
- assert_eq!(error.code, "permission");
3722
- }
3723
-
3724
4258
  #[tokio::test]
3725
4259
  async fn a_lost_transport_releases_input_then_ends_the_session_without_sctp_close() {
3726
4260
  use webrtc::peer_connection::RTCPeerConnectionState as State;
3727
4261
  let (events, mut received) = tokio_mpsc::unbounded_channel();
3728
4262
  let (inner, recorded) = test_inner(events).await;
4263
+ #[cfg(target_os = "windows")]
4264
+ inner
4265
+ .with_input(|target| target.chord(1, Vec::new(), true))
4266
+ .unwrap();
4267
+ #[cfg(not(target_os = "windows"))]
3729
4268
  inner.apply(ControlMessage::Key {
3730
4269
  name: Some(String::from("Control")),
3731
4270
  character: None,
@@ -3942,7 +4481,10 @@ mod tests {
3942
4481
  1000,
3943
4482
  );
3944
4483
 
3945
- // 40 frames over 200 ms is 200 fps; the requested 20 fps caps what leaves.
4484
+ // 40 frames nominally over 200 ms; Windows timers can round each 5 ms
4485
+ // sleep upward, so its cap assertion uses the actual burst duration.
4486
+ #[cfg(target_os = "windows")]
4487
+ let burst_started = Instant::now();
3946
4488
  for _ in 0..40 {
3947
4489
  frame_tx.put(
3948
4490
  I420 {
@@ -3955,9 +4497,13 @@ mod tests {
3955
4497
  tokio::time::sleep(Duration::from_millis(5)).await;
3956
4498
  }
3957
4499
  let motion = inner.metrics.lock().unwrap().encoded_frames;
4500
+ #[cfg(target_os = "windows")]
4501
+ let cap = (burst_started.elapsed().as_secs_f64() * 20.0).ceil() as u64 + 3;
4502
+ #[cfg(not(target_os = "windows"))]
4503
+ let cap = 7;
3958
4504
  assert!(
3959
- motion <= 7,
3960
- "the requested rate is a cap, got {motion} of 40"
4505
+ motion <= cap,
4506
+ "the requested rate is a cap, got {motion} of 40 (cap {cap})"
3961
4507
  );
3962
4508
  assert!(
3963
4509
  motion >= 2,