@desklink/host 0.3.1 → 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.
@@ -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,
@@ -210,6 +300,7 @@ fn select_x11(
210
300
  max_fps: u32,
211
301
  metrics: Arc<Mutex<Metrics>>,
212
302
  #[cfg(target_os = "linux")] cursor_sink: Option<crate::cursor::CursorSink>,
303
+ #[cfg(target_os = "linux")] cues: Option<crate::indicator::Cues>,
213
304
  sink: capture::FrameSink,
214
305
  on_stop: Box<dyn Fn(String) + Send>,
215
306
  ) -> Result<Selected> {
@@ -250,6 +341,8 @@ fn select_x11(
250
341
  let mut first = true;
251
342
  #[cfg(target_os = "linux")]
252
343
  let mut cursor = cursor_sink.map(crate::cursor::Reporter::new);
344
+ #[cfg(target_os = "linux")]
345
+ let mut mask = cues.map(CueMask::new);
253
346
  while !stop.load(Ordering::SeqCst) {
254
347
  #[cfg(target_os = "linux")]
255
348
  if let Some(cursor) = cursor.as_mut() {
@@ -272,10 +365,19 @@ fn select_x11(
272
365
  }
273
366
  let started = Instant::now();
274
367
  let grabbed = Instant::now();
368
+ #[cfg(target_os = "linux")]
275
369
  let frame = {
370
+ let before = mask.as_ref().map(CueMask::snapshot);
276
371
  let mut desktop = lock(&desktop);
277
- 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
+ })
278
378
  };
379
+ #[cfg(not(target_os = "linux"))]
380
+ let frame = lock(&desktop).capture_with_pixels(max_width, max_height);
279
381
  let grab_micros = grabbed.elapsed().as_micros() as u64;
280
382
  match frame {
281
383
  Ok((frame, raw)) => {
@@ -593,7 +695,6 @@ pub struct OpenRequest {
593
695
  pub cursor: crate::protocol::CursorMode,
594
696
  /** Which desktop to capture; absent means the portal. */
595
697
  pub source: Option<SourceRequest>,
596
- pub permissions: Vec<Permission>,
597
698
  pub max_width: usize,
598
699
  pub max_height: usize,
599
700
  pub bitrate_kbps: u32,
@@ -684,7 +785,6 @@ enum PointDesktop {
684
785
  struct Inner {
685
786
  id: String,
686
787
  generation: u64,
687
- permissions: Vec<Permission>,
688
788
  source: SelectedSource,
689
789
  geometry: serde_json::Value,
690
790
  metrics: Arc<Mutex<Metrics>>,
@@ -765,12 +865,10 @@ pub fn capabilities() -> serde_json::Value {
765
865
  } else {
766
866
  "none"
767
867
  };
768
- // Clipboard transfer needs Wayland; writes also need a selection server
769
- // that can outlive this engine. Do not offer it when that tool is absent.
770
- let clipboard = wayland_clipboard_available();
771
868
  let x11 = crate::x11::X11Desktop::connect(None)
772
869
  .map(|desktop| serde_json::json!([desktop.screen_size().0, desktop.screen_size().1]))
773
870
  .unwrap_or(serde_json::Value::Null);
871
+ let clipboard = wayland_clipboard_available();
774
872
  let (input, grant_state, unavailable) = match crate::input::probe() {
775
873
  Ok(()) => (serde_json::json!(true), "granted", serde_json::Value::Null),
776
874
  Err(unavailable) => (
@@ -909,19 +1007,6 @@ impl Session {
909
1007
  request: OpenRequest,
910
1008
  events: tokio_mpsc::UnboundedSender<Notice>,
911
1009
  ) -> std::result::Result<Self, SessionError> {
912
- if !request.permissions.contains(&Permission::View) {
913
- return Err(SessionError::new(
914
- "permission",
915
- "session.open requires the view permission",
916
- ));
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
1010
  if request.cursor == crate::protocol::CursorMode::Hidden {
926
1011
  #[cfg(not(target_os = "linux"))]
927
1012
  return Err(SessionError::new(
@@ -936,7 +1021,6 @@ impl Session {
936
1021
  }
937
1022
  }
938
1023
  let id = opaque_id();
939
- let wants_control = request.permissions.contains(&Permission::Control);
940
1024
  let wants_x11 = matches!(request.source, Some(SourceRequest::X11 { .. }));
941
1025
  let max_fps = request.max_fps.clamp(1, 60);
942
1026
  // A source the consumer feeds needs no capture backend, no encoder and
@@ -953,23 +1037,26 @@ impl Session {
953
1037
  // Which desktop decides which input path is even available: an X display
954
1038
  // takes XTest, which cannot reach any other session, while a portal
955
1039
  // desktop needs kernel input access.
956
- if wants_control && !wants_x11 {
957
- // Refuse up front rather than presenting a control surface that
958
- // silently does nothing.
959
- if crate::input::probe().is_err() {
960
- #[cfg(target_os = "macos")]
961
- let _ = crate::input::request_access();
962
- if let Err(unavailable) = crate::input::probe() {
963
- return Err(SessionError::new(
964
- "input-unavailable",
965
- format!("{}; {}", unavailable.reason, unavailable.remedy),
966
- ));
967
- }
968
- }
1040
+ #[cfg(target_os = "macos")]
1041
+ if crate::input::probe().is_err() {
1042
+ let _ = crate::input::request_access();
969
1043
  }
970
1044
 
971
1045
  let metrics = Arc::new(Mutex::new(Metrics::default()));
972
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
+ }
973
1060
  let (frame_tx, frame_rx) = latest_frame();
974
1061
  let latest = Arc::new(Mutex::new(None::<FrameSnapshot>));
975
1062
  let observed = latest.clone();
@@ -1091,6 +1178,8 @@ impl Session {
1091
1178
  metrics.clone(),
1092
1179
  #[cfg(target_os = "linux")]
1093
1180
  cursor_sink,
1181
+ #[cfg(target_os = "linux")]
1182
+ indicator.as_ref().map(crate::indicator::Indicator::cues),
1094
1183
  sink,
1095
1184
  Box::new({
1096
1185
  let status = capture_status.clone();
@@ -1139,23 +1228,13 @@ impl Session {
1139
1228
  let (width, height) =
1140
1229
  fit(source_w, source_h, request.max_width, request.max_height);
1141
1230
  #[cfg(target_os = "linux")]
1142
- if request.agent_indicator && wants_control {
1143
- indicator = Some(
1144
- crate::indicator::Indicator::start_wayland(source_w, source_h).map_err(
1145
- |error| {
1146
- SessionError::new("indicator-unavailable", format!("{error:#}"))
1147
- },
1148
- )?,
1149
- );
1231
+ if request.agent_indicator {
1232
+ indicator = crate::indicator::Indicator::start_wayland(source_w, source_h).ok();
1150
1233
  }
1151
1234
  #[cfg(target_os = "macos")]
1152
- if request.agent_indicator && wants_control {
1153
- indicator = Some(
1154
- crate::indicator::Indicator::start(source.node_id, source_w, source_h)
1155
- .map_err(|error| {
1156
- SessionError::new("indicator", format!("{error:#}"))
1157
- })?,
1158
- );
1235
+ if request.agent_indicator {
1236
+ indicator =
1237
+ crate::indicator::Indicator::start(source.node_id, source_w, source_h).ok();
1159
1238
  }
1160
1239
  #[cfg(target_os = "macos")]
1161
1240
  let capture = capture::start(
@@ -1230,20 +1309,6 @@ impl Session {
1230
1309
 
1231
1310
  let source_w = source.width.max(1) as usize;
1232
1311
  let source_h = source.height.max(1) as usize;
1233
- #[cfg(target_os = "linux")]
1234
- if request.agent_indicator && wants_control && wants_x11 {
1235
- let display = match &request.source {
1236
- Some(SourceRequest::X11 {
1237
- display: Some(display),
1238
- }) => display.clone(),
1239
- _ => std::env::var("DISPLAY")
1240
- .map_err(|_| SessionError::new("indicator-unavailable", "no X display"))?,
1241
- };
1242
- indicator = Some(
1243
- crate::indicator::Indicator::start(&display, source_w, source_h)
1244
- .map_err(|error| SessionError::new("indicator", format!("{error:#}")))?,
1245
- );
1246
- }
1247
1312
  let (width, height) = fit(source_w, source_h, request.max_width, request.max_height);
1248
1313
 
1249
1314
  let bitrate_kbps = if request.bitrate_kbps == 0 {
@@ -1262,40 +1327,30 @@ impl Session {
1262
1327
 
1263
1328
  // Everything that can still refuse the session comes before the peer:
1264
1329
  // a peer created and then abandoned keeps its socket and tasks alive.
1265
- let input = if !wants_control {
1266
- None
1267
- } else {
1268
- Some(match &x11 {
1269
- Some(desktop) => InputTarget {
1270
- applier: Applier::X11(desktop.clone()),
1271
- held: HeldState::default(),
1272
- explicit_modifiers: Vec::new(),
1273
- chord_modifiers: Vec::new(),
1274
- chord_keys: Vec::new(),
1275
- wheel_rest: (0.0, 0.0),
1276
- },
1277
- None => InputTarget {
1278
- applier: Applier::Uinput({
1279
- #[cfg(target_os = "linux")]
1280
- let devices = InputDevices::create(source_w as i32, source_h as i32);
1281
- #[cfg(any(target_os = "macos", target_os = "windows"))]
1282
- let devices = InputDevices::create_for_display(
1283
- source_w as i32,
1284
- source_h as i32,
1285
- source.node_id,
1286
- );
1287
- devices.map_err(|error| {
1288
- SessionError::new("input-unavailable", format!("{error:#}"))
1289
- })?
1290
- }),
1291
- held: HeldState::default(),
1292
- explicit_modifiers: Vec::new(),
1293
- chord_modifiers: Vec::new(),
1294
- chord_keys: Vec::new(),
1295
- wheel_rest: (0.0, 0.0),
1296
- },
1297
- })
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
+ }
1298
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
+ });
1299
1354
 
1300
1355
  let layout = Layout::from_environment().map_err(|error| {
1301
1356
  SessionError::new("input", format!("no keyboard layout: {error:#}"))
@@ -1338,7 +1393,6 @@ impl Session {
1338
1393
  let inner = Arc::new(Inner {
1339
1394
  id,
1340
1395
  generation,
1341
- permissions: request.permissions,
1342
1396
  source,
1343
1397
  geometry,
1344
1398
  metrics,
@@ -1432,7 +1486,6 @@ impl Session {
1432
1486
  let inner = Arc::new(Inner {
1433
1487
  id,
1434
1488
  generation,
1435
- permissions: request.permissions,
1436
1489
  source: SelectedSource {
1437
1490
  node_id: 0,
1438
1491
  width: width as i32,
@@ -1532,6 +1585,10 @@ impl Session {
1532
1585
  self.inner.generation
1533
1586
  }
1534
1587
 
1588
+ pub fn agent_indicator(&self) -> bool {
1589
+ lock(&self.inner.indicator).is_some()
1590
+ }
1591
+
1535
1592
  pub fn cursor_info(&self) -> &serde_json::Value {
1536
1593
  &self.inner.cursor_info
1537
1594
  }
@@ -1554,12 +1611,6 @@ impl Session {
1554
1611
  if self.inner.closed.load(Ordering::SeqCst) {
1555
1612
  return Err(SessionError::new("session", "the session is closed"));
1556
1613
  }
1557
- if !self.inner.permissions.contains(&Permission::View) {
1558
- return Err(SessionError::new(
1559
- "permission",
1560
- "point requires view permission",
1561
- ));
1562
- }
1563
1614
  if lock(&self.inner.encoded).is_some() {
1564
1615
  return Err(SessionError::new(
1565
1616
  "operation",
@@ -1732,12 +1783,6 @@ impl Session {
1732
1783
  path: &str,
1733
1784
  region: Option<[usize; 4]>,
1734
1785
  ) -> std::result::Result<serde_json::Value, SessionError> {
1735
- if !self.inner.permissions.contains(&Permission::View) {
1736
- return Err(SessionError::new(
1737
- "permission",
1738
- "view permission is required",
1739
- ));
1740
- }
1741
1786
  if lock(&self.inner.encoded).is_some() {
1742
1787
  return Err(SessionError::new(
1743
1788
  "operation",
@@ -1843,20 +1888,18 @@ impl Session {
1843
1888
  if let Some((_, reason)) = self.inner.clipboard_refusal() {
1844
1889
  return Err(reason.to_owned());
1845
1890
  }
1846
- clipboard_task(move || clipboard::read_or_explain()).await
1891
+ let inner = self.inner.clone();
1892
+ clipboard_task(move || inner.clipboard_read_text()).await
1847
1893
  }
1848
1894
 
1849
1895
  pub async fn write_clipboard(&self, text: String) -> std::result::Result<(), String> {
1850
1896
  if let Some((_, reason)) = self.inner.clipboard_refusal() {
1851
1897
  return Err(reason.to_owned());
1852
1898
  }
1853
- clipboard_task(move || {
1854
- clipboard::write(&text)
1855
- .map(|()| String::new())
1856
- .map_err(|error| format!("{error:#}"))
1857
- })
1858
- .await
1859
- .map(|_written| ())
1899
+ let inner = self.inner.clone();
1900
+ clipboard_task(move || inner.clipboard_write_text(&text))
1901
+ .await
1902
+ .map(|_written| ())
1860
1903
  }
1861
1904
 
1862
1905
  pub fn mark_closed_reason(&self) -> Option<String> {
@@ -1945,24 +1988,40 @@ impl Inner {
1945
1988
  }
1946
1989
  }
1947
1990
 
1948
- fn has(&self, permission: Permission) -> bool {
1949
- 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:#}"))
1950
2009
  }
1951
2010
 
1952
2011
  fn clipboard_refusal(&self) -> Option<(&'static str, &'static str)> {
1953
- if matches!(
1954
- self.source.source_type.as_deref(),
1955
- Some("x11-root") | Some("encoded")
1956
- ) || !wayland_clipboard_available()
1957
- {
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() {
1958
2020
  return Some((
1959
2021
  "clipboard-unsupported",
1960
2022
  "clipboard is unavailable for this desktop source",
1961
2023
  ));
1962
2024
  }
1963
- if !self.has(Permission::Clipboard) {
1964
- return Some(("permission", "this session has no clipboard permission"));
1965
- }
1966
2025
  None
1967
2026
  }
1968
2027
 
@@ -1990,7 +2049,7 @@ impl Inner {
1990
2049
  Ok(())
1991
2050
  }
1992
2051
 
1993
- /// Admit one client action. Validation, permission and ordering checks all
2052
+ /// Admit one client action. Validation and ordering checks all
1994
2053
  /// happen before any physical effect, so a refused action leaves the desktop
1995
2054
  /// exactly as it was.
1996
2055
  fn apply(self: &Arc<Self>, message: ControlMessage) {
@@ -2003,10 +2062,6 @@ impl Inner {
2003
2062
  self.reject(seq, "session", "the control channel is not open");
2004
2063
  return;
2005
2064
  }
2006
- if !self.has(Permission::Control) {
2007
- self.reject(seq, "permission", "this session is view-only");
2008
- return;
2009
- }
2010
2065
  if seq != 0 {
2011
2066
  let mut last = match self.last_seq.lock() {
2012
2067
  Ok(last) => last,
@@ -2043,6 +2098,14 @@ impl Inner {
2043
2098
  self.forward(message);
2044
2099
  return;
2045
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
+ }
2046
2109
  let outcome = match message {
2047
2110
  ControlMessage::Pointer {
2048
2111
  phase,
@@ -2318,10 +2381,12 @@ impl Inner {
2318
2381
  }
2319
2382
  let inner = Arc::clone(self);
2320
2383
  tokio::spawn(async move {
2321
- let (text, truncated, error) = match clipboard_task(clipboard::read_or_explain).await {
2322
- Ok((text, truncated)) => (text, truncated, None),
2323
- Err(reason) => (String::new(), false, Some(reason)),
2324
- };
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
+ };
2325
2390
  let _ = inner.reply(&ControlReply::Clipboard {
2326
2391
  request: &request,
2327
2392
  text,
@@ -2338,13 +2403,10 @@ impl Inner {
2338
2403
  }
2339
2404
  let inner = Arc::clone(self);
2340
2405
  tokio::spawn(async move {
2341
- let error = clipboard_task(move || {
2342
- clipboard::write(&text)
2343
- .map(|()| String::new())
2344
- .map_err(|error| format!("{error:#}"))
2345
- })
2346
- .await
2347
- .err();
2406
+ let writer = inner.clone();
2407
+ let error = clipboard_task(move || writer.clipboard_write_text(&text))
2408
+ .await
2409
+ .err();
2348
2410
  let _ = inner.reply(&ControlReply::Clipboard {
2349
2411
  request: &request,
2350
2412
  text: String::new(),
@@ -3216,6 +3278,60 @@ fn opaque_id() -> String {
3216
3278
 
3217
3279
  #[cfg(test)]
3218
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
+
3219
3335
  use super::*;
3220
3336
 
3221
3337
  #[tokio::test]
@@ -3226,7 +3342,6 @@ mod tests {
3226
3342
  let inner = Arc::new(Inner {
3227
3343
  id: String::from("encoded-session"),
3228
3344
  generation: 1,
3229
- permissions: Vec::new(),
3230
3345
  source: SelectedSource {
3231
3346
  node_id: 0,
3232
3347
  width: 640,
@@ -3396,10 +3511,7 @@ mod tests {
3396
3511
  #[tokio::test]
3397
3512
  async fn a_wayland_portal_session_reports_typed_point_unsupported() {
3398
3513
  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];
3514
+ let (inner, _) = test_inner(events).await;
3403
3515
  let session = Session { inner };
3404
3516
 
3405
3517
  let error = session
@@ -3445,7 +3557,6 @@ mod tests {
3445
3557
  let (events, _) = tokio_mpsc::unbounded_channel();
3446
3558
  let (mut inner, _) = test_inner(events).await;
3447
3559
  let state = Arc::get_mut(&mut inner).unwrap();
3448
- state.permissions = vec![Permission::View];
3449
3560
  state.point_desktop = PointDesktop::Wayland;
3450
3561
  state.source.width = 1280;
3451
3562
  state.source.height = 720;
@@ -3538,7 +3649,6 @@ mod tests {
3538
3649
  let inner = Arc::new(Inner {
3539
3650
  id: String::from("test-session"),
3540
3651
  generation: 1,
3541
- permissions: vec![Permission::Control],
3542
3652
  source: SelectedSource {
3543
3653
  node_id: 0,
3544
3654
  width: 640,
@@ -3587,6 +3697,36 @@ mod tests {
3587
3697
  (inner, recorded)
3588
3698
  }
3589
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
+
3590
3730
  #[tokio::test]
3591
3731
  async fn a_lease_that_expires_ends_the_session_and_releases_held_input() {
3592
3732
  let (events, mut received) = tokio_mpsc::unbounded_channel();
@@ -3845,38 +3985,24 @@ mod tests {
3845
3985
  }
3846
3986
 
3847
3987
  #[tokio::test]
3848
- async fn x11_capture_refuses_both_clipboard_directions() {
3988
+ async fn x11_capture_does_not_refuse_clipboard() {
3849
3989
  let (events, _received) = tokio_mpsc::unbounded_channel();
3850
3990
  let (mut inner, _recorded) = test_inner(events).await;
3851
3991
  let owned = Arc::get_mut(&mut inner).unwrap();
3852
3992
  owned.source.source_type = Some(String::from("x11-root"));
3853
- owned.permissions.push(Permission::Clipboard);
3854
- let session = Session {
3855
- inner: inner.clone(),
3856
- };
3857
- assert!(session
3858
- .read_clipboard()
3859
- .await
3860
- .unwrap_err()
3861
- .contains("unavailable"));
3862
- assert!(session
3863
- .write_clipboard(String::from("secret"))
3864
- .await
3865
- .unwrap_err()
3866
- .contains("unavailable"));
3867
- inner.clipboard_read(String::from("read"));
3868
- inner.clipboard_write(String::from("write"), String::from("secret"));
3869
- 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
+ );
3870
4000
  }
3871
4001
 
3872
4002
  #[tokio::test]
3873
4003
  async fn a_stopped_capture_never_serves_the_last_frame() {
3874
4004
  let (events, _) = tokio_mpsc::unbounded_channel();
3875
- let (mut inner, _) = test_inner(events).await;
3876
- Arc::get_mut(&mut inner)
3877
- .unwrap()
3878
- .permissions
3879
- .push(Permission::View);
4005
+ let (inner, _) = test_inner(events).await;
3880
4006
  let session = Session { inner };
3881
4007
  *lock(&session.inner.latest) =
3882
4008
  Some((1, Instant::now(), 2, 2, vec![0; 16], vec![1], vec![]));
@@ -3956,7 +4082,6 @@ mod tests {
3956
4082
  width: 486,
3957
4083
  height: 1080,
3958
4084
  }),
3959
- permissions: vec![Permission::View, Permission::Control],
3960
4085
  max_width: 3840,
3961
4086
  max_height: 2160,
3962
4087
  bitrate_kbps: 0,
@@ -4130,35 +4255,6 @@ mod tests {
4130
4255
  );
4131
4256
  }
4132
4257
 
4133
- #[tokio::test]
4134
- async fn opening_without_view_permission_is_refused_before_capture() {
4135
- let (events, _received) = tokio_mpsc::unbounded_channel();
4136
- let result = Session::open(
4137
- OpenRequest {
4138
- cursor: crate::protocol::CursorMode::Embedded,
4139
- source: None,
4140
- permissions: vec![Permission::Control],
4141
- max_width: 640,
4142
- max_height: 480,
4143
- bitrate_kbps: 0,
4144
- max_fps: 30,
4145
- ice_servers: Vec::new(),
4146
- restore_token: None,
4147
- ttl: None,
4148
- loopback_tcp: false,
4149
- agent_indicator: false,
4150
- local_frames: true,
4151
- },
4152
- events,
4153
- )
4154
- .await;
4155
- let error = match result {
4156
- Ok(_) => panic!("a viewless session must not open"),
4157
- Err(error) => error,
4158
- };
4159
- assert_eq!(error.code, "permission");
4160
- }
4161
-
4162
4258
  #[tokio::test]
4163
4259
  async fn a_lost_transport_releases_input_then_ends_the_session_without_sctp_close() {
4164
4260
  use webrtc::peer_connection::RTCPeerConnectionState as State;