@desklink/host 0.5.0 → 0.5.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.
package/README.md CHANGED
@@ -126,10 +126,13 @@ libvpx's licence and patent grant in `prefix/THIRD_PARTY_LICENSES.txt`.
126
126
  Without that prefix the compile seam still builds and refuses `serve`.
127
127
  Hosted Windows CI resolves the opt-in engine through the published Node API
128
128
  and completes a `serve` hello/capabilities handshake with clean shutdown; it
129
- also checks Chrome decode and input relay, and DLL
130
- dependencies. Dedicated Windows rig qualification of
131
- capture, input, mixed DPI, GPU encoding and elevation is pending; signing and
132
- packaging are later lanes.
129
+ also installs the packed packages into an empty project, checks the installed
130
+ `@desklink/host` tree against its pack manifest, starts the engine from that
131
+ install, and checks Chrome decode and input relay, and DLL
132
+ dependencies. That install proof is installation and startup on a hosted
133
+ machine: the engine still comes from a source build, because Windows platform
134
+ packages are not published, and capture, input, encoding quality, mixed DPI, GPU
135
+ encoding and elevation stay with the dedicated rig lanes.
133
136
 
134
137
  ### macOS (preview)
135
138
 
@@ -276,11 +279,13 @@ await client.addCandidate(session.sessionId, session.generation, candidate, sdpM
276
279
  ```
277
280
 
278
281
  For cursor-free recordings, open with `cursor: 'hidden'` and inspect
279
- `session.cursor`: Linux portals require Metadata for hidden capture;
280
- `positions` is true for accepted hidden sessions. Subscribe to
281
- `session.cursor` events for source-coordinate `x`, `y`, `visible`, and monotonic
282
- `timestamp_us` (the same clock as local frame metadata). Hidden-only portals
283
- return `cursor_positions_unavailable`. X11 reports positions even on still
282
+ `session.cursor`. On Wayland the portal leaves the cursor out of the pixels and
283
+ the compositor supplies its position through `ext-image-copy-capture-v1`;
284
+ `positions` is true once it has. Subscribe to `session.cursor` events for
285
+ source-coordinate `x`, `y`, `visible`, the cursor image's `hotspot` where
286
+ known, and monotonic `timestamp_us` (the same clock as local frame metadata).
287
+ A compositor without that protocol still captures, cursor hidden, and says so
288
+ in `cursor.limitation`. X11 reports positions even on still
284
289
  frames and already captures root pixels without a cursor. macOS, Windows, and consumer-encoded
285
290
  sources return `cursor-unavailable` for hidden requests. Omitting the option
286
291
  preserves existing behavior. See [the cursor protocol](docs/PROTOCOL.md#cursor-free-capture).
@@ -32,7 +32,7 @@ export interface EngineCapabilities {
32
32
  cursor_modes?: Array<'embedded' | 'hidden'>;
33
33
  cursor_positions?: {
34
34
  x11: boolean;
35
- portal: 'negotiated';
35
+ wayland: 'ext-image-copy-capture-v1';
36
36
  };
37
37
  audio: boolean;
38
38
  displays?: Array<{
@@ -110,6 +110,10 @@ export type SourceRequest = {
110
110
  export interface CursorInfo {
111
111
  mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable';
112
112
  positions: boolean;
113
+ /** Where hidden-cursor positions come from on Wayland. */
114
+ source?: 'ext-image-copy-capture-v1';
115
+ /** Why a hidden session has no cursor positions (yet). */
116
+ limitation?: string;
113
117
  }
114
118
  export interface OpenSessionRequest {
115
119
  /** Cursor-free frames with independent source-coordinate position events. */
@@ -164,6 +168,10 @@ export type EngineEvent = {
164
168
  y: number;
165
169
  visible: boolean;
166
170
  timestamp_us: number;
171
+ hotspot?: {
172
+ x: number;
173
+ y: number;
174
+ };
167
175
  };
168
176
  } | {
169
177
  event: 'session.description';
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC;AA6JlC,wEAAwE;AACxE,MAAM,OAAO,aAAc,SAAQ,KAAK;IAC3B,IAAI,CAAS;IAEtB,YAAY,IAAY,EAAE,OAAe;QACrC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;CACJ"}
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAiKlC,wEAAwE;AACxE,MAAM,OAAO,aAAc,SAAQ,KAAK;IAC3B,IAAI,CAAS;IAEtB,YAAY,IAAY,EAAE,OAAe;QACrC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;CACJ"}
package/docs/PROTOCOL.md CHANGED
@@ -126,7 +126,7 @@ require fresh consent. The macOS engine requires a VP9-enabled build
126
126
  "formats": ["bgrx", "bgra", "rgbx", "rgba"],
127
127
  "cursor": "embedded",
128
128
  "cursor_modes": ["embedded", "hidden"],
129
- "cursor_positions": {"x11": true, "portal": "negotiated"},
129
+ "cursor_positions": {"x11": true, "wayland": "ext-image-copy-capture-v1"},
130
130
  "audio": false
131
131
  },
132
132
  "encode": {"codecs": ["vp9", "h264"], "hardware": false,
@@ -450,8 +450,9 @@ runs at most about 60 ms of its rate target ahead of it, so frames never queue
450
450
  behind a burst. `refined_frames` counts refinement passes: a
451
451
  desktop that stops changing is coded once more at a fine quantizer, so it reads
452
452
  sharp. `halved_frames` counts motion frames coded at half width and height: when
453
- full-size frames that change much of the picture take longer to code than a
454
- frame interval while a newer frame is already waiting, several frames in a row,
453
+ full-size frames that change much of the picture take longer to code or to
454
+ drain at the current rate target than a frame interval while a newer frame is
455
+ already waiting or send-budget debt is held, several frames in a row,
455
456
  motion is coded at half size until the desktop stops or its changes shrink to
456
457
  small ones that code in time; the refinement pass is always full size. Key
457
458
  frames are sent only for the first frame, when the receiver asks (RTCP PLI or
@@ -459,7 +460,9 @@ FIR), and when the coded size changes that way, so a receiver never has to scale
459
460
  references across a size change. `target_kbps` is the current rate target after
460
461
  any back-off: for a standing queue on the path, measured from the receiver's
461
462
  transport-wide congestion feedback (RTP `transport-cc`), and for the loss
462
- its receiver reports carry. `codec` is what frames go out in
463
+ its receiver reports carry. It starts at the requested ceiling capped at
464
+ 3000 kbps on an unknown link, then grows toward the ceiling on clean
465
+ loss/delay feedback. `codec` is what frames go out in
463
466
  (`vp9` or `h264`) and `encoder` what codes them (`libvpx`, `videotoolbox`,
464
467
  `nvenc`, `vaapi` or `openh264`); both are empty for an encoded source.
465
468
 
@@ -490,40 +493,52 @@ whose accounting is wrong.
490
493
  ## Cursor-free capture
491
494
 
492
495
  `session.open` accepts `cursor: "hidden"` (default `"embedded"`). Existing
493
- callers keep their capture behavior. Linux hidden capture requires portal
494
- **Metadata** so cursor positions remain available while cursor pixels are
495
- omitted. A portal without Metadata returns the typed
496
- `cursor_positions_unavailable` refusal. Successful open returns
496
+ callers keep their capture behavior. Linux portal capture asks the portal for
497
+ **Hidden** (or **Metadata**, whose cursor data is ignored) so cursor pixels are
498
+ omitted; a portal that offers neither refuses with `cursor-unavailable`. Cursor
499
+ positions do not come from the portal: the engine asks the compositor directly
500
+ through the `ext-image-copy-capture-v1` pointer cursor session over the
501
+ selected output (Hyprland and wlroots compositors advertise it). Successful
502
+ open returns
497
503
  `cursor: {"mode":"metadata"|"hidden"|"embedded"|"unavailable",
498
- "positions":true|false}`; `mode` names the portal mode accepted by the portal,
499
- not just the requested option.
504
+ "positions":true|false, "source"?:"ext-image-copy-capture-v1", "limitation"?:string}`;
505
+ `mode` names the portal mode accepted by the portal, not just the requested
506
+ option.
500
507
 
501
508
  Linux capabilities advertise `capture.cursor_modes: ["embedded","hidden"]`
502
- and `capture.cursor_positions: {"x11":true,"portal":"negotiated"}`. Portal
503
- support is resolved at open: hidden capture requires Metadata and an observed
504
- SPA cursor metadata buffer. A portal that offers only Hidden refuses open with
505
- `cursor_positions_unavailable`; accepted hidden sessions report `positions:true`.
506
- Metadata-mode buffers that contain no new cursor data do not fabricate a move
507
- or a visibility change.
509
+ and `capture.cursor_positions: {"x11":true,"wayland":"ext-image-copy-capture-v1"}`.
510
+ A hidden session never fails for want of positions. `positions` is true once
511
+ the compositor delivered one within a second of open. Otherwise the stream
512
+ still runs, cursor hidden, and `limitation` says why: the compositor lacks the
513
+ protocol, or it has sent nothing yet (the cursor is off the captured output,
514
+ the seat has no pointer, or the compositor withholds positions; Hyprland
515
+ gates them on its cursor permission). The engine logs a warning in each case
516
+ and never reports a guessed or zero position.
517
+
518
+ The compositor sends a position only when it changes and only while the
519
+ cursor is over the source, at its own commit cadence. The engine keeps the last
520
+ one: a quiet compositor is a still cursor. Leaving the source reports the last
521
+ position with `visible:false`.
508
522
 
509
523
  Hidden sessions with positions emit local protocol events independently of
510
524
  pixel damage, including on a still X11 desktop:
511
525
 
512
526
  ```json
513
- {"event":"session.cursor","params":{"sessionId":"…","x":400,"y":300,"visible":true,"timestamp_us":123456}}
527
+ {"event":"session.cursor","params":{"sessionId":"…","x":400,"y":300,"visible":true,"hotspot":{"x":4,"y":6},"timestamp_us":123456}}
514
528
  ```
515
529
 
516
530
  `x,y` name the pointer hotspot in the selected source's coordinates
517
531
  (`geometry.source`), before video scaling, without the desktop origin added.
518
- Portal buffer coordinates are mapped to that geometry. `visible` is false
519
- outside the source or when SPA supplies an invisible cursor bitmap.
532
+ Compositor buffer coordinates are mapped to that geometry (Hyprland's, which
533
+ are already logical, are taken as they are). `hotspot`, where the compositor
534
+ reports it, is the hotspot's offset inside the cursor image, in the same
535
+ units. `visible` is false while the cursor is outside the source.
520
536
  `timestamp_us` is a monotonic engine-process clock in microseconds, sampled
521
- when the capture thread observes the pointer. The same clock stamps
537
+ when the engine observes the pointer. The same clock stamps
522
538
  `session.frame.changed` and `session.frame` metadata, so local recording
523
539
  consumers can compare cursor samples to frame handoff times. These are not
524
- wall-clock or RTP timestamps. SPA cursor updates accompany PipeWire buffers;
525
- X11 pointer queries run at the session frame cap even with no pixel damage.
526
- Repeated identical positions/visibility are suppressed.
540
+ wall-clock or RTP timestamps. X11 pointer queries run at the session frame cap
541
+ even with no pixel damage. Repeated identical samples are suppressed.
527
542
 
528
543
  X11 root GetImage/MIT-SHM never contains the server cursor and this engine
529
544
  never composites it, including for existing embedded-default callers. Its
package/engine/Cargo.lock CHANGED
@@ -536,7 +536,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
536
536
 
537
537
  [[package]]
538
538
  name = "desklink-host"
539
- version = "0.5.0"
539
+ version = "0.5.1"
540
540
  dependencies = [
541
541
  "anyhow",
542
542
  "ashpd",
@@ -563,6 +563,7 @@ dependencies = [
563
563
  "tokio",
564
564
  "wayland-client",
565
565
  "wayland-protocols",
566
+ "wayland-protocols-wlr",
566
567
  "webrtc",
567
568
  "windows-sys 0.61.2",
568
569
  "wl-clipboard-rs",
package/engine/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "desklink-host"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  edition = "2021"
5
5
  rust-version = "1.97.1"
6
6
  license = "Apache-2.0"
@@ -38,11 +38,14 @@ wl-clipboard-rs = "0.9"
38
38
  openh264-sys2 = { version = "0.9.8", default-features = false, features = ["libloading"] }
39
39
  sha2 = "0.10"
40
40
  bzip2-rs = "0.1.2"
41
+ # Hidden-cursor positions (ext-image-copy-capture-v1, a staging protocol);
42
+ # already in the tree through wl-clipboard-rs.
43
+ wayland-client = "0.31"
44
+ wayland-protocols = { version = "0.32", features = ["client", "staging"] }
41
45
 
42
46
  [target.'cfg(target_os = "linux")'.dev-dependencies]
43
- # The Wayland stamp example only; already in the tree through wl-clipboard-rs.
44
- wayland-client = "0.31"
45
- wayland-protocols = { version = "0.32", features = ["client"] }
47
+ # The Wayland stamp example's lab pointer; already in the tree through wl-clipboard-rs.
48
+ wayland-protocols-wlr = { version = "0.3", features = ["client"] }
46
49
 
47
50
  [target.'cfg(target_os = "windows")'.dependencies]
48
51
  windows-sys = "0.61.2"
@@ -13,7 +13,7 @@ use pw::spa;
13
13
  use pw::spa::pod::{serialize::PodSerializer, Pod};
14
14
  use std::io::Cursor;
15
15
  use std::os::fd::OwnedFd;
16
- use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16
+ use std::sync::atomic::{AtomicU64, Ordering};
17
17
  use std::sync::{mpsc, Arc, Mutex};
18
18
  use std::thread::JoinHandle;
19
19
  use std::time::{Duration, Instant};
@@ -55,7 +55,9 @@ pub struct Capture {
55
55
  quit: pw::channel::Sender<()>,
56
56
  thread: Option<JoinHandle<()>>,
57
57
  pub source: SelectedSource,
58
- cursor_positions: Arc<AtomicBool>,
58
+ /// Hidden-cursor positions, when the compositor supplies them; kept with
59
+ /// the capture so both stop together.
60
+ pub cursor: Option<crate::wl_cursor::Tracker>,
59
61
  geometry: Arc<Mutex<Option<StreamGeometry>>>,
60
62
  frames: Arc<AtomicU64>,
61
63
  dropped: Arc<AtomicU64>,
@@ -64,9 +66,6 @@ pub struct Capture {
64
66
  }
65
67
 
66
68
  impl Capture {
67
- pub fn cursor_positions(&self) -> bool {
68
- self.cursor_positions.load(Ordering::Relaxed)
69
- }
70
69
  pub fn geometry(&self) -> Option<StreamGeometry> {
71
70
  self.geometry.lock().ok().and_then(|g| g.clone())
72
71
  }
@@ -203,23 +202,11 @@ pub fn start(
203
202
  encoded_width: usize,
204
203
  encoded_height: usize,
205
204
  max_fps: u32,
206
- cursor_sink: Option<crate::cursor::CursorSink>,
207
205
  sink: FrameSink,
208
206
  indicator_position: Option<Arc<Mutex<Option<(i64, i64)>>>>,
209
207
  on_stop: Box<dyn Fn(String) + Send>,
210
208
  ) -> Result<Capture> {
211
- let PortalSession {
212
- fd,
213
- source,
214
- cursor_mode,
215
- ..
216
- } = session;
217
- let cursor_positions = Arc::new(AtomicBool::new(false));
218
- let cursor_sink = if cursor_mode == "metadata" {
219
- cursor_sink
220
- } else {
221
- None
222
- };
209
+ let PortalSession { fd, source, .. } = session;
223
210
  let geometry = Arc::new(Mutex::new(None));
224
211
  let frames = Arc::new(AtomicU64::new(0));
225
212
  let dropped = Arc::new(AtomicU64::new(0));
@@ -227,9 +214,7 @@ pub fn start(
227
214
  let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
228
215
 
229
216
  let node_id = source.node_id;
230
- let source_size = (source.width.max(1) as usize, source.height.max(1) as usize);
231
217
  let thread = {
232
- let cursor_positions = cursor_positions.clone();
233
218
  let geometry = geometry.clone();
234
219
  let frames = frames.clone();
235
220
  let dropped = dropped.clone();
@@ -240,12 +225,9 @@ pub fn start(
240
225
  let result = run_loop(
241
226
  fd,
242
227
  node_id,
243
- source_size,
244
228
  encoded_width,
245
229
  encoded_height,
246
230
  max_fps,
247
- cursor_sink,
248
- cursor_positions,
249
231
  sink,
250
232
  indicator_position,
251
233
  on_stop,
@@ -272,7 +254,7 @@ pub fn start(
272
254
  quit,
273
255
  thread: Some(thread),
274
256
  source,
275
- cursor_positions,
257
+ cursor: None,
276
258
  geometry,
277
259
  frames,
278
260
  dropped,
@@ -291,12 +273,9 @@ pub fn start(
291
273
  fn run_loop(
292
274
  fd: OwnedFd,
293
275
  node_id: u32,
294
- source_size: (usize, usize),
295
276
  encoded_width: usize,
296
277
  encoded_height: usize,
297
278
  max_fps: u32,
298
- cursor_sink: Option<crate::cursor::CursorSink>,
299
- cursor_positions: Arc<AtomicBool>,
300
279
  sink: FrameSink,
301
280
  indicator_position: Option<Arc<Mutex<Option<(i64, i64)>>>>,
302
281
  on_stop: Box<dyn Fn(String) + Send>,
@@ -328,8 +307,6 @@ fn run_loop(
328
307
  let stream = pw::stream::StreamBox::new(&core, "desklink-capture", props)
329
308
  .context("pw_stream_new failed")?;
330
309
 
331
- let wants_cursor_meta = cursor_sink.is_some();
332
- let mut cursor = cursor_sink.map(crate::cursor::Reporter::new);
333
310
  let mut state = StreamState {
334
311
  box_w: encoded_width,
335
312
  box_h: encoded_height,
@@ -395,31 +372,6 @@ fn run_loop(
395
372
  let Some(mut buffer) = stream.dequeue_buffer() else {
396
373
  return;
397
374
  };
398
- if let Some(cursor) = cursor.as_mut() {
399
- if let Some(meta) = buffer.find_meta::<spa::buffer::meta::MetaCursor>() {
400
- cursor_positions.store(true, Ordering::Relaxed);
401
- // The wrapper's size check bounds the bitmap header;
402
- // never follow an unchecked bitmap offset.
403
- let bitmap = buffer
404
- .find_meta::<CursorWithBitmap>()
405
- .filter(|m| {
406
- m.cursor.bitmap_offset as usize
407
- == std::mem::size_of::<spa::sys::spa_meta_cursor>()
408
- })
409
- .map(|m| &m.bitmap);
410
- if state.logical_w == 0 || state.logical_h == 0 {
411
- return;
412
- }
413
- let mut position = *meta.as_raw();
414
- position.position.x = (i64::from(position.position.x) * source_size.0 as i64)
415
- .div_euclid(state.logical_w as i64)
416
- as i32;
417
- position.position.y = (i64::from(position.position.y) * source_size.1 as i64)
418
- .div_euclid(state.logical_h as i64)
419
- as i32;
420
- cursor.update_spa(&position, bitmap, source_size.0, source_size.1);
421
- }
422
- }
423
375
  let Some(format) = state.format else { return };
424
376
  let datas = buffer.datas_mut();
425
377
  if datas.is_empty() {
@@ -516,15 +468,10 @@ fn run_loop(
516
468
  format_pod(&mut format_buf, max_fps)?;
517
469
  let mut buffer_buf = Vec::new();
518
470
  buffer_pod(&mut buffer_buf)?;
519
- let mut cursor_buf = Vec::new();
520
- cursor_pod(&mut cursor_buf)?;
521
471
  let mut params = vec![
522
472
  Pod::from_bytes(&format_buf).context("invalid EnumFormat pod")?,
523
473
  Pod::from_bytes(&buffer_buf).context("invalid Buffers pod")?,
524
474
  ];
525
- if wants_cursor_meta {
526
- params.push(Pod::from_bytes(&cursor_buf).context("invalid Cursor Meta pod")?);
527
- }
528
475
 
529
476
  stream
530
477
  .connect(
@@ -718,7 +665,7 @@ mod tests {
718
665
  origin_x: 0,
719
666
  origin_y: 0,
720
667
  },
721
- cursor_positions: Arc::new(AtomicBool::new(false)),
668
+ cursor: None,
722
669
  geometry: Arc::new(Mutex::new(None)),
723
670
  frames: Arc::new(AtomicU64::new(0)),
724
671
  dropped: Arc::new(AtomicU64::new(0)),
@@ -739,33 +686,3 @@ mod tests {
739
686
  .expect("capture stopped and joined");
740
687
  }
741
688
  }
742
-
743
- fn cursor_pod(buffer: &mut Vec<u8>) -> Result<()> {
744
- use spa::pod::{object, property, Value};
745
- let obj = object!(
746
- spa::utils::SpaTypes::ObjectParamMeta,
747
- spa::param::ParamType::Meta,
748
- property!(
749
- RawKey(spa::sys::SPA_PARAM_META_type),
750
- Id,
751
- RawKey(spa::sys::SPA_META_Cursor)
752
- ),
753
- property!(
754
- RawKey(spa::sys::SPA_PARAM_META_size),
755
- Int,
756
- (std::mem::size_of::<CursorWithBitmap>() + 256 * 256 * 4) as i32
757
- )
758
- );
759
- PodSerializer::serialize(Cursor::new(buffer), &Value::Object(obj))
760
- .map(|_| ())
761
- .map_err(|e| anyhow::anyhow!("failed to build Cursor Meta pod: {e}"))
762
- }
763
-
764
- #[repr(C)]
765
- struct CursorWithBitmap {
766
- cursor: spa::sys::spa_meta_cursor,
767
- bitmap: spa::sys::spa_meta_bitmap,
768
- }
769
- impl spa::buffer::meta::Metadata for CursorWithBitmap {
770
- const META_TYPE: u32 = spa::sys::SPA_META_Cursor;
771
- }
@@ -6,51 +6,28 @@ pub type CursorSink = Box<dyn Fn(CursorPosition) + Send + 'static>;
6
6
 
7
7
  pub struct Reporter {
8
8
  sink: CursorSink,
9
- last: Option<(i32, i32, bool)>,
10
- bitmap_visible: bool,
9
+ last: Option<(i32, i32, bool, Option<(i32, i32)>)>,
10
+ /// The cursor image's hotspot offset, where the source reports one.
11
+ pub hotspot: Option<(i32, i32)>,
11
12
  }
12
13
  impl Reporter {
13
14
  pub fn new(sink: CursorSink) -> Self {
14
15
  Self {
15
16
  sink,
16
17
  last: None,
17
- bitmap_visible: true,
18
+ hotspot: None,
18
19
  }
19
20
  }
20
- pub fn update_spa(
21
- &mut self,
22
- meta: &pipewire::spa::sys::spa_meta_cursor,
23
- bitmap: Option<&pipewire::spa::sys::spa_meta_bitmap>,
24
- width: usize,
25
- height: usize,
26
- ) {
27
- // SPA id zero is no new data, not a visibility change.
28
- if meta.id == 0 {
29
- return;
30
- }
31
- if let Some(bitmap) = bitmap.filter(|b| b.format != 0) {
32
- self.bitmap_visible = bitmap.offset != 0;
33
- }
34
- let (x, y) = (meta.position.x, meta.position.y);
35
- self.update(
36
- x,
37
- y,
38
- self.bitmap_visible
39
- && x >= 0
40
- && y >= 0
41
- && (x as usize) < width
42
- && (y as usize) < height,
43
- );
44
- }
45
21
  pub fn update(&mut self, x: i32, y: i32, visible: bool) {
46
- if self.last == Some((x, y, visible)) {
22
+ if self.last == Some((x, y, visible, self.hotspot)) {
47
23
  return;
48
24
  }
49
- self.last = Some((x, y, visible));
25
+ self.last = Some((x, y, visible, self.hotspot));
50
26
  (self.sink)(CursorPosition {
51
27
  x,
52
28
  y,
53
29
  visible,
30
+ hotspot: self.hotspot,
54
31
  timestamp_us: timestamp_us(Instant::now()),
55
32
  });
56
33
  }
@@ -59,44 +36,6 @@ impl Reporter {
59
36
  #[cfg(test)]
60
37
  mod tests {
61
38
  use super::*;
62
- #[test]
63
- fn spa_no_update_off_source_and_invisible_bitmap_are_distinct() {
64
- use pipewire::spa::sys::{spa_meta_bitmap, spa_meta_cursor, spa_point, spa_rectangle};
65
- let (tx, rx) = std::sync::mpsc::channel();
66
- let mut reporter = Reporter::new(Box::new(move |p| {
67
- tx.send(p).unwrap();
68
- }));
69
- let mut meta = spa_meta_cursor {
70
- id: 1,
71
- flags: 0,
72
- position: spa_point { x: 40, y: 60 },
73
- hotspot: spa_point { x: 2, y: 3 },
74
- bitmap_offset: 0,
75
- };
76
- reporter.update_spa(&meta, None, 800, 600);
77
- meta.id = 0;
78
- reporter.update_spa(&meta, None, 800, 600);
79
- assert_eq!(rx.try_iter().count(), 1);
80
- meta.id = 1;
81
- meta.position.x = -1;
82
- reporter.update_spa(&meta, None, 800, 600);
83
- assert!(!rx.try_recv().unwrap().visible);
84
- meta.position.x = 40;
85
- let bitmap = spa_meta_bitmap {
86
- format: 1,
87
- size: spa_rectangle {
88
- width: 16,
89
- height: 16,
90
- },
91
- stride: 64,
92
- offset: 0,
93
- };
94
- reporter.update_spa(&meta, Some(&bitmap), 800, 600);
95
- assert!(!rx.try_recv().unwrap().visible);
96
- reporter.update_spa(&meta, None, 800, 600);
97
- assert!(rx.try_recv().is_err(), "no new bitmap retains invisibility");
98
- }
99
-
100
39
  #[test]
101
40
  fn reports_initial_motion_and_visibility_but_not_unchanged_polls() {
102
41
  let (tx, rx) = std::sync::mpsc::channel();
@@ -93,6 +93,8 @@ mod session;
93
93
  #[cfg(target_os = "windows")]
94
94
  mod win;
95
95
  #[cfg(target_os = "linux")]
96
+ mod wl_cursor;
97
+ #[cfg(target_os = "linux")]
96
98
  mod x11;
97
99
  #[cfg(any(target_os = "macos", target_os = "windows"))]
98
100
  #[path = "x11_mac.rs"]
@@ -391,7 +393,6 @@ async fn probe_portal(seconds: u64) -> Result<()> {
391
393
  width,
392
394
  height,
393
395
  30,
394
- None,
395
396
  Box::new(move |frame, seq, _raw| {
396
397
  let _ = tx.send((frame.width, frame.height, seq));
397
398
  }),
@@ -558,10 +559,13 @@ fn render_event(notice: session::Notice) -> Option<String> {
558
559
  "session.state",
559
560
  serde_json::json!({ "sessionId": session_id, "capture": capture, "transport": transport, "firstFrame": first_frame }),
560
561
  ),
561
- session::SessionEvent::Cursor { position } => (
562
- "session.cursor",
563
- serde_json::json!({ "sessionId": session_id, "x": position.x, "y": position.y, "visible": position.visible, "timestamp_us": position.timestamp_us }),
564
- ),
562
+ session::SessionEvent::Cursor { position } => ("session.cursor", {
563
+ let mut params = serde_json::json!({ "sessionId": session_id, "x": position.x, "y": position.y, "visible": position.visible, "timestamp_us": position.timestamp_us });
564
+ if let Some((x, y)) = position.hotspot {
565
+ params["hotspot"] = serde_json::json!({ "x": x, "y": y });
566
+ }
567
+ params
568
+ }),
565
569
  session::SessionEvent::Frame {
566
570
  seq,
567
571
  damage,
@@ -892,6 +896,7 @@ mod tests {
892
896
  x: 32,
893
897
  y: 64,
894
898
  visible: false,
899
+ hotspot: None,
895
900
  timestamp_us: 123456,
896
901
  },
897
902
  },
@@ -2,8 +2,8 @@
2
2
  //!
3
3
  //! The portal is the only capture path this engine asks the compositor for: it
4
4
  //! is what carries the user's consent, works across Wayland compositors, and
5
- //! hands back a PipeWire remote that [`crate::capture`] consumes. No
6
- //! compositor-private protocol is used.
5
+ //! hands back a PipeWire remote that [`crate::capture`] consumes. Hidden-cursor
6
+ //! positions come from the compositor itself, through [`crate::wl_cursor`].
7
7
 
8
8
  use anyhow::{Context, Result};
9
9
  use ashpd::desktop::screencast::{CursorMode, Screencast, SourceType};
@@ -134,24 +134,29 @@ pub async fn open(
134
134
  })
135
135
  }
136
136
 
137
+ /// Either mode keeps cursor pixels out of the stream; positions never come
138
+ /// from the portal.
137
139
  fn choose_cursor_mode(available: BitFlags<CursorMode>) -> Result<CursorMode> {
138
- if available.contains(CursorMode::Metadata) {
139
- return Ok(CursorMode::Metadata);
140
- }
141
- anyhow::bail!("cursor_positions_unavailable: portal does not provide cursor metadata")
140
+ [CursorMode::Hidden, CursorMode::Metadata]
141
+ .into_iter()
142
+ .find(|mode| available.contains(*mode))
143
+ .context("cursor-unavailable: portal cannot leave the cursor out of capture")
142
144
  }
143
145
 
144
146
  #[cfg(test)]
145
147
  mod tests {
146
148
  use super::*;
147
149
  #[test]
148
- fn hidden_requires_metadata_and_refuses_hidden_only_portals() {
150
+ fn hidden_takes_any_cursor_free_mode_and_refuses_embedded_only_portals() {
149
151
  assert_eq!(
150
152
  choose_cursor_mode(CursorMode::Metadata | CursorMode::Hidden).unwrap(),
153
+ CursorMode::Hidden
154
+ );
155
+ assert_eq!(
156
+ choose_cursor_mode(CursorMode::Metadata.into()).unwrap(),
151
157
  CursorMode::Metadata
152
158
  );
153
- let error = choose_cursor_mode(CursorMode::Hidden.into()).unwrap_err();
154
- assert!(error.to_string().contains("cursor_positions_unavailable:"));
155
- assert!(choose_cursor_mode(CursorMode::Embedded.into()).is_err());
159
+ let error = choose_cursor_mode(CursorMode::Embedded.into()).unwrap_err();
160
+ assert!(error.to_string().contains("cursor-unavailable:"));
156
161
  }
157
162
  }
@@ -569,5 +569,6 @@ pub struct CursorPosition {
569
569
  pub x: i32,
570
570
  pub y: i32,
571
571
  pub visible: bool,
572
+ pub hotspot: Option<(i32, i32)>,
572
573
  pub timestamp_us: u64,
573
574
  }
@@ -895,7 +895,7 @@ pub fn capabilities() -> serde_json::Value {
895
895
  "formats": ["bgrx", "bgra", "rgbx", "rgba"],
896
896
  "cursor": "embedded",
897
897
  "cursor_modes": ["embedded", "hidden"],
898
- "cursor_positions": {"x11": true, "portal": "negotiated"},
898
+ "cursor_positions": {"x11": true, "wayland": "ext-image-copy-capture-v1"},
899
899
  "audio": false,
900
900
  },
901
901
  "encode": encode_capabilities(),
@@ -1205,8 +1205,8 @@ impl Session {
1205
1205
  }
1206
1206
  .map_err(|error| {
1207
1207
  let message = format!("{error:#}");
1208
- let code = if message.contains("cursor_positions_unavailable:") {
1209
- "cursor_positions_unavailable"
1208
+ let code = if message.contains("cursor-unavailable:") {
1209
+ "cursor-unavailable"
1210
1210
  } else {
1211
1211
  "source"
1212
1212
  };
@@ -1264,12 +1264,11 @@ impl Session {
1264
1264
  )
1265
1265
  .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1266
1266
  #[cfg(target_os = "linux")]
1267
- let capture = capture::start(
1267
+ let mut capture = capture::start(
1268
1268
  portal,
1269
1269
  width,
1270
1270
  height,
1271
1271
  max_fps,
1272
- cursor_sink,
1273
1272
  sink,
1274
1273
  indicator
1275
1274
  .as_ref()
@@ -1281,16 +1280,31 @@ impl Session {
1281
1280
  )
1282
1281
  .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
1283
1282
  #[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
- ));
1283
+ if let Some(cursor_sink) = cursor_sink {
1284
+ // Positions are an addition to hidden capture, never a
1285
+ // condition of it: without them the stream still runs and
1286
+ // the reply says why the receiver gets no cursor.
1287
+ cursor_info["source"] = serde_json::json!(crate::wl_cursor::PROTOCOL);
1288
+ let limitation = match crate::wl_cursor::start(
1289
+ (source.origin_x, source.origin_y),
1290
+ (source_w, source_h),
1291
+ cursor_sink,
1292
+ Duration::from_secs(1),
1293
+ ) {
1294
+ Ok((tracker, observed)) => {
1295
+ capture.cursor = Some(tracker);
1296
+ cursor_info["positions"] = serde_json::json!(observed);
1297
+ (!observed).then_some("the compositor has sent no cursor position yet: the cursor is off the captured output, the seat has no pointer, or the compositor withholds positions from this engine")
1298
+ }
1299
+ Err(error) => {
1300
+ log::warn!("hidden cursor capture has no cursor positions: {error:#}");
1301
+ Some("this compositor does not offer ext-image-copy-capture-v1 cursor sessions, so the cursor is hidden and its position is unknown")
1302
+ }
1303
+ };
1304
+ if let Some(limitation) = limitation {
1305
+ log::warn!("{limitation}");
1306
+ cursor_info["limitation"] = serde_json::json!(limitation);
1292
1307
  }
1293
- cursor_info["positions"] = serde_json::json!(capture.cursor_positions());
1294
1308
  }
1295
1309
  #[cfg(target_os = "linux")]
1296
1310
  if let Some(indicator) = indicator.as_mut() {
@@ -1319,7 +1333,7 @@ impl Session {
1319
1333
  let encoder = Encoder::new(
1320
1334
  width,
1321
1335
  height,
1322
- bitrate_kbps,
1336
+ RateControl::starting(bitrate_kbps).target,
1323
1337
  max_fps,
1324
1338
  available_parallelism().min(8) as u32,
1325
1339
  )
@@ -2563,6 +2577,15 @@ struct RateControl {
2563
2577
  }
2564
2578
 
2565
2579
  impl RateControl {
2580
+ /// Begin below an unknown weak link's capacity, then use the existing
2581
+ /// loss/delay feedback growth to approach the requested ceiling.
2582
+ fn starting(ceiling: u32) -> Self {
2583
+ Self {
2584
+ target: ceiling.min(3000),
2585
+ ..Self::new(ceiling)
2586
+ }
2587
+ }
2588
+
2566
2589
  fn new(ceiling: u32) -> Self {
2567
2590
  Self {
2568
2591
  ceiling,
@@ -2659,9 +2682,9 @@ impl SendBudget {
2659
2682
  }
2660
2683
  }
2661
2684
 
2662
- /// Whether motion is coded at half size. A full-size frame is slow when it
2663
- /// took longer to code than a frame interval and the source already had a
2664
- /// newer frame waiting: the encoder, not the source, set the pace. Content that
2685
+ /// Whether motion is coded at half size. A full-size frame is slow when its
2686
+ /// encoding or constrained-link drain time exceeds a frame interval and a
2687
+ /// newer frame or send-budget debt is waiting. Content that
2665
2688
  /// arrives slower than the frame cap (30 fps into a 60 fps session) is never
2666
2689
  /// slow however long a frame takes, as long as it is done before the next one.
2667
2690
  /// Several slow frames that each change much of the picture (a packet over a
@@ -2677,15 +2700,31 @@ impl SendBudget {
2677
2700
  struct MotionSize {
2678
2701
  half: bool,
2679
2702
  run: u32,
2703
+ link_run: u32,
2680
2704
  }
2681
2705
 
2682
2706
  impl MotionSize {
2683
2707
  const SLOW_RUN: u32 = 5;
2684
2708
  const SMALL_RUN: u32 = 30;
2685
2709
 
2686
- /// Account for a motion frame coded at the current size in `took`; `large`
2710
+ /// Persistent send debt needs a quicker response than a transient slow
2711
+ /// encode: every extra full-size motion frame extends the remote queue.
2712
+ fn queued(&mut self, drain: Duration, debt: bool, interval: Duration) {
2713
+ self.link_run = if !self.half && debt && drain > interval {
2714
+ self.link_run + 1
2715
+ } else {
2716
+ 0
2717
+ };
2718
+ if self.link_run >= 2 {
2719
+ self.half = true;
2720
+ self.run = 0;
2721
+ self.link_run = 0;
2722
+ }
2723
+ }
2724
+
2725
+ /// Account for a motion frame serviced at the current size in `took`; `large`
2687
2726
  /// when its packet was over a quarter of the per-frame rate budget, `behind`
2688
- /// when a newer frame was already waiting once it was coded.
2727
+ /// when a newer frame or send-budget debt was waiting once it was coded.
2689
2728
  fn coded(&mut self, took: Duration, large: bool, behind: bool, interval: Duration) {
2690
2729
  let slow = took > interval && behind;
2691
2730
  let (counts, needed) = if self.half {
@@ -2762,9 +2801,9 @@ fn spawn_pipeline(
2762
2801
  .name("desklink-encode".into())
2763
2802
  .spawn(move || {
2764
2803
  let interval = frame_interval(max_fps);
2765
- let mut rate = RateControl::new(bitrate_kbps);
2804
+ let mut rate = RateControl::starting(bitrate_kbps);
2766
2805
  if let Ok(mut m) = inner.metrics.lock() {
2767
- m.target_kbps = bitrate_kbps;
2806
+ m.target_kbps = rate.target;
2768
2807
  (m.codec, m.encoder) = ("vp9", "libvpx");
2769
2808
  }
2770
2809
  // Whether motion may be coded at half size: VP9's alone.
@@ -2944,16 +2983,26 @@ fn spawn_pipeline(
2944
2983
  Pass::Motion { half: false, .. } | Pass::Keepalive => {}
2945
2984
  }
2946
2985
  }
2986
+ budget.sent(now, packet.data.len(), rate.target);
2947
2987
  match pass {
2948
2988
  Pass::Motion { .. } => {
2949
2989
  pending = false;
2950
2990
  keyframe = false;
2951
2991
  refined = false;
2952
2992
  still_since = now;
2953
- let budget = rate.target as u64 * 1000 / 8 / max_fps.max(1) as u64;
2954
- let large = packet.data.len() as u64 * 4 > budget;
2993
+ let frame_budget = rate.target as u64 * 1000 / 8 / max_fps.max(1) as u64;
2994
+ let large = packet.data.len() as u64 * 4 > frame_budget;
2955
2995
  if halves {
2996
+ // A fast encoder can still outrun a constrained link.
2997
+ // Include the packet's drain time once the send
2998
+ // budget is holding frames, so sustained motion
2999
+ // reduces its size before a backlog builds.
3000
+ let drain = Duration::from_secs_f64(
3001
+ packet.data.len() as f64 * 8.0 / SendBudget::rate(rate.target),
3002
+ );
3003
+ let link_behind = !budget.wait(Instant::now(), rate.target).is_zero();
2956
3004
  size.coded(took, large, frame_rx.waiting(), interval);
3005
+ size.queued(drain, link_behind, interval);
2957
3006
  }
2958
3007
  }
2959
3008
  Pass::Refine => {
@@ -2964,7 +3013,6 @@ fn spawn_pipeline(
2964
3013
  }
2965
3014
  last_sent = Some(now);
2966
3015
  cadence.sent(now);
2967
- budget.sent(now, packet.data.len(), rate.target);
2968
3016
  // A new picture carries the moment it was captured, so the
2969
3017
  // receiver paces playout by the desktop's clock rather than by
2970
3018
  // encode time; a re-coded one (refinement, keepalive) is new
@@ -0,0 +1,367 @@
1
+ //! Real cursor positions for hidden Wayland capture, from the compositor's
2
+ //! `ext-image-copy-capture-v1` pointer cursor session.
3
+ //!
4
+ //! The portal stream carries the pixels with the cursor left out; this module
5
+ //! asks the compositor directly where that cursor's hotspot is over the same
6
+ //! output. Nothing is captured through this connection and nothing is drawn: it
7
+ //! holds a seat pointer only because the protocol names the cursor by one.
8
+ //!
9
+ //! The compositor sends `position` only when the position changed and only
10
+ //! while the cursor is over the source, at its own commit cadence. So the last
11
+ //! position is latched and stays true until the compositor says otherwise; a
12
+ //! quiet compositor is a still cursor, not a missing one.
13
+
14
+ use crate::cursor::{CursorSink, Reporter};
15
+ use anyhow::{bail, Context, Result};
16
+ use std::os::fd::AsFd;
17
+ use std::sync::atomic::{AtomicBool, Ordering};
18
+ use std::sync::{mpsc, Arc};
19
+ use std::thread::JoinHandle;
20
+ use std::time::{Duration, Instant};
21
+ use wayland_client::protocol::{wl_output, wl_pointer, wl_registry, wl_seat};
22
+ use wayland_client::{Connection, Dispatch, QueueHandle, WEnum};
23
+ use wayland_protocols::ext::image_capture_source::v1::client::{
24
+ ext_image_capture_source_v1, ext_output_image_capture_source_manager_v1 as source_manager,
25
+ };
26
+ use wayland_protocols::ext::image_copy_capture::v1::client::{
27
+ ext_image_copy_capture_cursor_session_v1 as cursor_session,
28
+ ext_image_copy_capture_manager_v1 as capture_manager,
29
+ };
30
+
31
+ pub const PROTOCOL: &str = "ext-image-copy-capture-v1";
32
+
33
+ /// A live cursor session. Dropping it disconnects and joins its thread.
34
+ pub struct Tracker {
35
+ stop: Arc<AtomicBool>,
36
+ thread: Option<JoinHandle<()>>,
37
+ }
38
+
39
+ impl Drop for Tracker {
40
+ fn drop(&mut self) {
41
+ self.stop.store(true, Ordering::Relaxed);
42
+ if let Some(thread) = self.thread.take() {
43
+ let _ = thread.join();
44
+ }
45
+ }
46
+ }
47
+
48
+ /// Follow the cursor over the output at `origin` in the desktop layout, whose
49
+ /// portal source is `source_size` logical pixels. Waits up to `settle` for the
50
+ /// first delivered position: `Ok((tracker, true))` once one arrived, `Ok((tracker,
51
+ /// false))` while none has (the cursor may be off this output, or the compositor
52
+ /// may be withholding positions), and `Err` when the protocol is not available.
53
+ pub fn start(
54
+ origin: (i32, i32),
55
+ source_size: (usize, usize),
56
+ sink: CursorSink,
57
+ settle: Duration,
58
+ ) -> Result<(Tracker, bool)> {
59
+ let connection = Connection::connect_to_env().context("no Wayland compositor to ask")?;
60
+ let mut queue = connection.new_event_queue();
61
+ let qh = queue.handle();
62
+ connection.display().get_registry(&qh, ());
63
+ let mut state = State {
64
+ reporter: Some(Reporter::new(sink)),
65
+ source_size,
66
+ // Hyprland sends logical coordinates relative to the source's logical
67
+ // box (src/protocols/ImageCopyCapture.cpp, sendCursorEvents); the
68
+ // protocol text, and wlroots, use the transformed buffer's pixels.
69
+ logical: std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some(),
70
+ ..State::default()
71
+ };
72
+ // The globals, then each output's geometry and mode.
73
+ queue.roundtrip(&mut state)?;
74
+ queue.roundtrip(&mut state)?;
75
+ let (Some(_), Some(_), Some(_)) = (&state.capture, &state.sources, &state.seat) else {
76
+ bail!("compositor does not offer {PROTOCOL} output cursor sessions");
77
+ };
78
+ let output = match state.outputs.as_slice() {
79
+ [only] => only,
80
+ many => many
81
+ .iter()
82
+ .find(|o| o.position == origin)
83
+ .context("no Wayland output sits at the captured source's origin")?,
84
+ };
85
+ state.output = Some(output.output.clone());
86
+ state.buffer_size = match output.transform {
87
+ wl_output::Transform::_90
88
+ | wl_output::Transform::_270
89
+ | wl_output::Transform::Flipped90
90
+ | wl_output::Transform::Flipped270 => (output.mode.1, output.mode.0),
91
+ _ => output.mode,
92
+ };
93
+ if !state.logical && (state.buffer_size.0 == 0 || state.buffer_size.1 == 0) {
94
+ bail!("compositor sent no current mode for the captured output");
95
+ }
96
+ let (first_tx, first_rx) = mpsc::channel();
97
+ state.first = Some(first_tx);
98
+ // The seat's capabilities arrive with the outputs; the session opens as
99
+ // soon as there is a pointer, including one that only appears later.
100
+ state.open(&qh);
101
+ queue.roundtrip(&mut state)?;
102
+
103
+ let stop = Arc::new(AtomicBool::new(false));
104
+ if state.session.is_none() {
105
+ log::warn!(
106
+ "{PROTOCOL}: the seat has no pointer yet; cursor positions start when it has one"
107
+ );
108
+ }
109
+ let thread = {
110
+ let stop = stop.clone();
111
+ std::thread::Builder::new()
112
+ .name("desklink-wl-cursor".into())
113
+ .spawn(move || {
114
+ if let Err(error) = run(&connection, &mut queue, &mut state, &stop) {
115
+ state.entered = false;
116
+ state.report();
117
+ log::warn!("{PROTOCOL}: cursor session ended: {error:#}");
118
+ }
119
+ })
120
+ .context("failed to spawn cursor thread")?
121
+ };
122
+ let observed = first_rx.recv_timeout(settle).is_ok();
123
+ Ok((
124
+ Tracker {
125
+ stop,
126
+ thread: Some(thread),
127
+ },
128
+ observed,
129
+ ))
130
+ }
131
+
132
+ fn run(
133
+ connection: &Connection,
134
+ queue: &mut wayland_client::EventQueue<State>,
135
+ state: &mut State,
136
+ stop: &AtomicBool,
137
+ ) -> Result<()> {
138
+ let mut warned = false;
139
+ while !stop.load(Ordering::Relaxed) {
140
+ queue.dispatch_pending(state)?;
141
+ connection.flush()?;
142
+ if let Some(guard) = queue.prepare_read() {
143
+ let mut fds = [libc::pollfd {
144
+ fd: std::os::fd::AsRawFd::as_raw_fd(&guard.connection_fd().as_fd()),
145
+ events: libc::POLLIN,
146
+ revents: 0,
147
+ }];
148
+ // SAFETY: one valid pollfd for the connection's own socket.
149
+ if unsafe { libc::poll(fds.as_mut_ptr(), 1, 100) } > 0 {
150
+ guard.read()?;
151
+ }
152
+ }
153
+ // Hyprland, for one, answers enter and hotspot but withholds position
154
+ // from a client its permission rules do not allow.
155
+ if !warned
156
+ && state.position.is_none()
157
+ && state
158
+ .entered_at
159
+ .is_some_and(|at| at.elapsed() > Duration::from_secs(3))
160
+ {
161
+ warned = true;
162
+ log::warn!("{PROTOCOL}: the cursor is over the source but the compositor sends no position; check its cursor permission for this engine");
163
+ }
164
+ }
165
+ Ok(())
166
+ }
167
+
168
+ struct Output {
169
+ output: wl_output::WlOutput,
170
+ position: (i32, i32),
171
+ mode: (usize, usize),
172
+ transform: wl_output::Transform,
173
+ }
174
+
175
+ #[derive(Default)]
176
+ struct State {
177
+ reporter: Option<Reporter>,
178
+ capture: Option<capture_manager::ExtImageCopyCaptureManagerV1>,
179
+ sources: Option<source_manager::ExtOutputImageCaptureSourceManagerV1>,
180
+ seat: Option<wl_seat::WlSeat>,
181
+ pointer: Option<wl_pointer::WlPointer>,
182
+ outputs: Vec<Output>,
183
+ output: Option<wl_output::WlOutput>,
184
+ session: Option<cursor_session::ExtImageCopyCaptureCursorSessionV1>,
185
+ source_size: (usize, usize),
186
+ buffer_size: (usize, usize),
187
+ logical: bool,
188
+ entered: bool,
189
+ entered_at: Option<Instant>,
190
+ /// Last delivered hotspot position, already in source coordinates.
191
+ position: Option<(i32, i32)>,
192
+ first: Option<mpsc::Sender<()>>,
193
+ }
194
+
195
+ impl State {
196
+ fn open(&mut self, qh: &QueueHandle<Self>) {
197
+ let (Some(capture), Some(sources), Some(output), Some(pointer)) =
198
+ (&self.capture, &self.sources, &self.output, &self.pointer)
199
+ else {
200
+ return;
201
+ };
202
+ if self.session.is_some() {
203
+ return;
204
+ }
205
+ let source = sources.create_source(output, qh, ());
206
+ self.session = Some(capture.create_pointer_cursor_session(&source, pointer, qh, ()));
207
+ }
208
+
209
+ fn map(&self, x: i32, y: i32) -> (i32, i32) {
210
+ if self.logical {
211
+ return (x, y);
212
+ }
213
+ let scale = |v: i32, to: usize, from: usize| {
214
+ (i64::from(v) * to as i64).div_euclid(from.max(1) as i64) as i32
215
+ };
216
+ (
217
+ scale(x, self.source_size.0, self.buffer_size.0),
218
+ scale(y, self.source_size.1, self.buffer_size.1),
219
+ )
220
+ }
221
+
222
+ fn report(&mut self) {
223
+ if let (Some((x, y)), Some(reporter)) = (self.position, self.reporter.as_mut()) {
224
+ reporter.update(x, y, self.entered);
225
+ }
226
+ }
227
+ }
228
+
229
+ impl Dispatch<wl_registry::WlRegistry, ()> for State {
230
+ fn event(
231
+ state: &mut Self,
232
+ registry: &wl_registry::WlRegistry,
233
+ event: wl_registry::Event,
234
+ _: &(),
235
+ _: &Connection,
236
+ qh: &QueueHandle<Self>,
237
+ ) {
238
+ let wl_registry::Event::Global {
239
+ name,
240
+ interface,
241
+ version,
242
+ } = event
243
+ else {
244
+ return;
245
+ };
246
+ match interface.as_str() {
247
+ "ext_image_copy_capture_manager_v1" => {
248
+ state.capture = Some(registry.bind(name, 1, qh, ()));
249
+ }
250
+ "ext_output_image_capture_source_manager_v1" => {
251
+ state.sources = Some(registry.bind(name, 1, qh, ()));
252
+ }
253
+ "wl_seat" if state.seat.is_none() => {
254
+ state.seat = Some(registry.bind(name, version.min(7), qh, ()));
255
+ }
256
+ "wl_output" => {
257
+ let output = registry.bind(name, version.min(4), qh, ());
258
+ state.outputs.push(Output {
259
+ output,
260
+ position: (0, 0),
261
+ mode: (0, 0),
262
+ transform: wl_output::Transform::Normal,
263
+ });
264
+ }
265
+ _ => {}
266
+ }
267
+ }
268
+ }
269
+
270
+ impl Dispatch<wl_output::WlOutput, ()> for State {
271
+ fn event(
272
+ state: &mut Self,
273
+ proxy: &wl_output::WlOutput,
274
+ event: wl_output::Event,
275
+ _: &(),
276
+ _: &Connection,
277
+ _: &QueueHandle<Self>,
278
+ ) {
279
+ let Some(output) = state.outputs.iter_mut().find(|o| &o.output == proxy) else {
280
+ return;
281
+ };
282
+ match event {
283
+ wl_output::Event::Geometry {
284
+ x, y, transform, ..
285
+ } => {
286
+ output.position = (x, y);
287
+ if let WEnum::Value(transform) = transform {
288
+ output.transform = transform;
289
+ }
290
+ }
291
+ wl_output::Event::Mode {
292
+ flags: WEnum::Value(flags),
293
+ width,
294
+ height,
295
+ ..
296
+ } if flags.contains(wl_output::Mode::Current) => {
297
+ output.mode = (width.max(0) as usize, height.max(0) as usize);
298
+ }
299
+ _ => {}
300
+ }
301
+ }
302
+ }
303
+
304
+ impl Dispatch<wl_seat::WlSeat, ()> for State {
305
+ fn event(
306
+ state: &mut Self,
307
+ seat: &wl_seat::WlSeat,
308
+ event: wl_seat::Event,
309
+ _: &(),
310
+ _: &Connection,
311
+ qh: &QueueHandle<Self>,
312
+ ) {
313
+ if let wl_seat::Event::Capabilities {
314
+ capabilities: WEnum::Value(capabilities),
315
+ } = event
316
+ {
317
+ if capabilities.contains(wl_seat::Capability::Pointer) && state.pointer.is_none() {
318
+ state.pointer = Some(seat.get_pointer(qh, ()));
319
+ state.open(qh);
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ impl Dispatch<cursor_session::ExtImageCopyCaptureCursorSessionV1, ()> for State {
326
+ fn event(
327
+ state: &mut Self,
328
+ _: &cursor_session::ExtImageCopyCaptureCursorSessionV1,
329
+ event: cursor_session::Event,
330
+ _: &(),
331
+ _: &Connection,
332
+ _: &QueueHandle<Self>,
333
+ ) {
334
+ match event {
335
+ cursor_session::Event::Enter => {
336
+ state.entered = true;
337
+ state.entered_at.get_or_insert_with(Instant::now);
338
+ state.report();
339
+ }
340
+ // Off the source: the last position stands, now not visible.
341
+ cursor_session::Event::Leave => {
342
+ state.entered = false;
343
+ state.report();
344
+ }
345
+ cursor_session::Event::Position { x, y } => {
346
+ state.position = Some(state.map(x, y));
347
+ state.report();
348
+ if let Some(first) = state.first.take() {
349
+ let _ = first.send(());
350
+ }
351
+ }
352
+ cursor_session::Event::Hotspot { x, y } => {
353
+ let hotspot = state.map(x, y);
354
+ if let Some(reporter) = state.reporter.as_mut() {
355
+ reporter.hotspot = Some(hotspot);
356
+ }
357
+ state.report();
358
+ }
359
+ _ => {}
360
+ }
361
+ }
362
+ }
363
+
364
+ wayland_client::delegate_noop!(State: ignore wl_pointer::WlPointer);
365
+ wayland_client::delegate_noop!(State: capture_manager::ExtImageCopyCaptureManagerV1);
366
+ wayland_client::delegate_noop!(State: source_manager::ExtOutputImageCaptureSourceManagerV1);
367
+ wayland_client::delegate_noop!(State: ext_image_capture_source_v1::ExtImageCaptureSourceV1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/host",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Per-user, on-demand desktop capture, encode and input engine with a versioned local control protocol",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "ws": "8.21.3"
34
34
  },
35
35
  "optionalDependencies": {
36
- "@desklink/host-linux-x64-gnu": "0.5.0",
37
- "@desklink/host-darwin-arm64": "0.5.0"
36
+ "@desklink/host-linux-x64-gnu": "0.5.1",
37
+ "@desklink/host-darwin-arm64": "0.5.1"
38
38
  }
39
39
  }