@desklink/host 0.4.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 -228
- package/engine/src/wl_cursor.rs +367 -0
- package/package.json +3 -3
|
@@ -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.
|
|
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.
|
|
37
|
-
"@desklink/host-darwin-arm64": "0.
|
|
36
|
+
"@desklink/host-linux-x64-gnu": "0.5.1",
|
|
37
|
+
"@desklink/host-darwin-arm64": "0.5.1"
|
|
38
38
|
}
|
|
39
39
|
}
|