@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 +14 -9
- package/dist/protocol.d.ts +9 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/docs/PROTOCOL.md +38 -23
- package/engine/Cargo.lock +2 -1
- package/engine/Cargo.toml +7 -4
- package/engine/src/capture.rs +7 -90
- package/engine/src/cursor.rs +7 -68
- package/engine/src/main.rs +10 -5
- package/engine/src/portal.rs +15 -10
- package/engine/src/protocol.rs +1 -0
- package/engine/src/session.rs +73 -25
- package/engine/src/wl_cursor.rs +367 -0
- package/package.json +3 -3
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
`
|
|
282
|
-
`
|
|
283
|
-
|
|
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).
|
package/dist/protocol.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface EngineCapabilities {
|
|
|
32
32
|
cursor_modes?: Array<'embedded' | 'hidden'>;
|
|
33
33
|
cursor_positions?: {
|
|
34
34
|
x11: boolean;
|
|
35
|
-
|
|
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';
|
package/dist/protocol.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAElC,MAAM,WAAW,WAAW;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAChD,sFAAsF;IACtF,GAAG,EAAE;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;KAAE,CAAC;IAC3D,OAAO,EAAE;QACL,SAAS,EAAE,MAAM,CAAC;QAClB,0EAA0E;QAC1E,QAAQ,EAAE,MAAM,EAAE,CAAC;QACnB,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;QACf,YAAY,CAAC,EAAE,KAAK,CAAC,UAAU,GAAG,QAAQ,CAAC,CAAC;QAC5C,gBAAgB,CAAC,EAAE;YAAE,GAAG,EAAE,OAAO,CAAC;YAAC,
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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"}
|
package/dist/protocol.js.map
CHANGED
|
@@ -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;
|
|
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, "
|
|
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
|
|
454
|
-
frame interval while a newer frame is
|
|
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.
|
|
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
|
|
494
|
-
**
|
|
495
|
-
omitted
|
|
496
|
-
|
|
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}`;
|
|
499
|
-
not just the requested
|
|
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,"
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
or
|
|
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
|
-
|
|
519
|
-
|
|
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
|
|
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.
|
|
525
|
-
|
|
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.
|
|
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.
|
|
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
|
|
44
|
-
wayland-
|
|
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"
|
package/engine/src/capture.rs
CHANGED
|
@@ -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::{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/engine/src/cursor.rs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
package/engine/src/main.rs
CHANGED
|
@@ -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
|
-
"
|
|
563
|
-
|
|
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
|
},
|
package/engine/src/portal.rs
CHANGED
|
@@ -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.
|
|
6
|
-
//! compositor
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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::
|
|
154
|
-
assert!(error.to_string().contains("
|
|
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
|
}
|
package/engine/src/protocol.rs
CHANGED
package/engine/src/session.rs
CHANGED
|
@@ -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, "
|
|
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("
|
|
1209
|
-
"
|
|
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
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
|
2663
|
-
///
|
|
2664
|
-
/// newer frame
|
|
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
|
-
///
|
|
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
|
|
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::
|
|
2804
|
+
let mut rate = RateControl::starting(bitrate_kbps);
|
|
2766
2805
|
if let Ok(mut m) = inner.metrics.lock() {
|
|
2767
|
-
m.target_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
|
|
2954
|
-
let large = packet.data.len() as u64 * 4 >
|
|
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.
|
|
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.
|
|
37
|
-
"@desklink/host-darwin-arm64": "0.5.
|
|
36
|
+
"@desklink/host-linux-x64-gnu": "0.5.1",
|
|
37
|
+
"@desklink/host-darwin-arm64": "0.5.1"
|
|
38
38
|
}
|
|
39
39
|
}
|