@desklink/host 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +3 -0
- package/README.md +92 -23
- package/bin/desklink-host.mjs +14 -5
- package/dist/bridge.d.ts +3 -4
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +18 -7
- package/dist/bridge.js.map +1 -1
- package/dist/engineProcess.d.ts +6 -1
- package/dist/engineProcess.d.ts.map +1 -1
- package/dist/engineProcess.js +34 -5
- package/dist/engineProcess.js.map +1 -1
- package/dist/protocol.d.ts +35 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/resolveEngine.d.ts +6 -0
- package/dist/resolveEngine.d.ts.map +1 -1
- package/dist/resolveEngine.js +45 -6
- package/dist/resolveEngine.js.map +1 -1
- package/docs/BOUNDARY.md +8 -8
- package/docs/PROTOCOL.md +185 -45
- package/engine/Cargo.lock +2 -2
- package/engine/Cargo.toml +7 -12
- package/engine/build.rs +62 -5
- package/engine/native/agent_indicator.h +57 -0
- package/engine/native/agent_overlay_mac.m +51 -4
- package/engine/native/agent_overlay_wayland.c +189 -35
- package/engine/native/agent_overlay_x11.c +186 -55
- package/engine/src/capture.rs +96 -5
- package/engine/src/capture_win.rs +23 -0
- package/engine/src/clipboard.rs +174 -0
- package/engine/src/clipboard_win.rs +8 -0
- package/engine/src/cursor.rs +118 -0
- package/engine/src/indicator.rs +271 -5
- package/engine/src/input_win.rs +91 -0
- package/engine/src/keymap_win.rs +39 -0
- package/engine/src/mac.rs +28 -6
- package/engine/src/main.rs +167 -25
- package/engine/src/peer.rs +10 -4
- package/engine/src/portal.rs +42 -4
- package/engine/src/portal_win.rs +37 -0
- package/engine/src/protocol.rs +56 -10
- package/engine/src/session.rs +749 -203
- package/engine/src/win.rs +110 -0
- package/engine/src/x11.rs +44 -29
- package/engine/src/x11_mac.rs +4 -0
- package/examples/reference-client.html +3 -5
- package/package.json +4 -4
package/engine/src/clipboard.rs
CHANGED
|
@@ -97,6 +97,180 @@ pub fn read_or_explain() -> Result<(String, bool), String> {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/// Each operation uses its own connection: capture drains selection events.
|
|
101
|
+
fn x11_window(display: &str) -> Result<(x11rb::rust_connection::RustConnection, u32)> {
|
|
102
|
+
use x11rb::connection::Connection;
|
|
103
|
+
use x11rb::protocol::xproto::{ConnectionExt, CreateWindowAux, WindowClass};
|
|
104
|
+
let (connection, screen) = x11rb::connect(Some(display))?;
|
|
105
|
+
let window = connection.generate_id()?;
|
|
106
|
+
connection
|
|
107
|
+
.create_window(
|
|
108
|
+
0,
|
|
109
|
+
window,
|
|
110
|
+
connection.setup().roots[screen].root,
|
|
111
|
+
0,
|
|
112
|
+
0,
|
|
113
|
+
1,
|
|
114
|
+
1,
|
|
115
|
+
0,
|
|
116
|
+
WindowClass::INPUT_ONLY,
|
|
117
|
+
0,
|
|
118
|
+
&CreateWindowAux::new(),
|
|
119
|
+
)?
|
|
120
|
+
.check()?;
|
|
121
|
+
Ok((connection, window))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fn x11_atom(connection: &impl x11rb::connection::Connection, name: &[u8]) -> Result<u32> {
|
|
125
|
+
use x11rb::protocol::xproto::ConnectionExt;
|
|
126
|
+
Ok(connection.intern_atom(false, name)?.reply()?.atom)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Own CLIPBOARD until another client replaces it, the engine exits, or X dies.
|
|
130
|
+
/// No INCR send: a single bounded property is the entire supported transfer.
|
|
131
|
+
pub fn x11_write(display: &str, text: &str) -> Result<()> {
|
|
132
|
+
use x11rb::connection::{Connection, RequestConnection};
|
|
133
|
+
use x11rb::protocol::{xproto::*, Event};
|
|
134
|
+
use x11rb::wrapper::ConnectionExt as _;
|
|
135
|
+
anyhow::ensure!(
|
|
136
|
+
text.len() <= MAX_CLIPBOARD_BYTES,
|
|
137
|
+
"refusing to place {} bytes on the clipboard",
|
|
138
|
+
text.len()
|
|
139
|
+
);
|
|
140
|
+
let (connection, window) = x11_window(display)?;
|
|
141
|
+
// Leave room for the ChangeProperty header and alignment padding.
|
|
142
|
+
anyhow::ensure!(
|
|
143
|
+
text.len() <= connection.maximum_request_bytes().saturating_sub(28),
|
|
144
|
+
"clipboard text is too large for one transfer (INCR is unsupported)"
|
|
145
|
+
);
|
|
146
|
+
let selection = x11_atom(&connection, b"CLIPBOARD")?;
|
|
147
|
+
let targets = x11_atom(&connection, b"TARGETS")?;
|
|
148
|
+
let utf8 = x11_atom(&connection, b"UTF8_STRING")?;
|
|
149
|
+
let text_target = x11_atom(&connection, b"TEXT")?;
|
|
150
|
+
connection
|
|
151
|
+
.set_selection_owner(window, selection, x11rb::CURRENT_TIME)?
|
|
152
|
+
.check()?;
|
|
153
|
+
anyhow::ensure!(
|
|
154
|
+
connection.get_selection_owner(selection)?.reply()?.owner == window,
|
|
155
|
+
"the X server refused clipboard ownership"
|
|
156
|
+
);
|
|
157
|
+
let text = text.to_owned();
|
|
158
|
+
std::thread::Builder::new()
|
|
159
|
+
.name(String::from("desklink-x11-clipboard"))
|
|
160
|
+
.spawn(move || -> Result<()> {
|
|
161
|
+
loop {
|
|
162
|
+
match connection.wait_for_event()? {
|
|
163
|
+
Event::SelectionClear(_) => break,
|
|
164
|
+
Event::SelectionRequest(request) => {
|
|
165
|
+
let property = if request.property == x11rb::NONE {
|
|
166
|
+
request.target
|
|
167
|
+
} else {
|
|
168
|
+
request.property
|
|
169
|
+
};
|
|
170
|
+
let accepted = if request.target == targets {
|
|
171
|
+
connection
|
|
172
|
+
.change_property32(
|
|
173
|
+
PropMode::REPLACE,
|
|
174
|
+
request.requestor,
|
|
175
|
+
property,
|
|
176
|
+
AtomEnum::ATOM,
|
|
177
|
+
&[targets, utf8, text_target],
|
|
178
|
+
)?
|
|
179
|
+
.check()
|
|
180
|
+
.is_ok()
|
|
181
|
+
} else if request.target == utf8 || request.target == text_target {
|
|
182
|
+
connection
|
|
183
|
+
.change_property8(
|
|
184
|
+
PropMode::REPLACE,
|
|
185
|
+
request.requestor,
|
|
186
|
+
property,
|
|
187
|
+
utf8,
|
|
188
|
+
text.as_bytes(),
|
|
189
|
+
)?
|
|
190
|
+
.check()
|
|
191
|
+
.is_ok()
|
|
192
|
+
} else {
|
|
193
|
+
false
|
|
194
|
+
};
|
|
195
|
+
let reply = SelectionNotifyEvent {
|
|
196
|
+
response_type: SELECTION_NOTIFY_EVENT,
|
|
197
|
+
sequence: 0,
|
|
198
|
+
time: request.time,
|
|
199
|
+
requestor: request.requestor,
|
|
200
|
+
selection: request.selection,
|
|
201
|
+
target: request.target,
|
|
202
|
+
property: if accepted { property } else { x11rb::NONE },
|
|
203
|
+
};
|
|
204
|
+
connection.send_event(
|
|
205
|
+
false,
|
|
206
|
+
request.requestor,
|
|
207
|
+
EventMask::NO_EVENT,
|
|
208
|
+
reply,
|
|
209
|
+
)?;
|
|
210
|
+
connection.flush()?;
|
|
211
|
+
}
|
|
212
|
+
_ => {}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
Ok(())
|
|
216
|
+
})?;
|
|
217
|
+
Ok(())
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
pub fn x11_read(display: &str) -> Result<(String, bool)> {
|
|
221
|
+
use x11rb::connection::Connection;
|
|
222
|
+
use x11rb::protocol::{xproto::ConnectionExt, Event};
|
|
223
|
+
let (connection, window) = x11_window(display)?;
|
|
224
|
+
let selection = x11_atom(&connection, b"CLIPBOARD")?;
|
|
225
|
+
anyhow::ensure!(
|
|
226
|
+
connection.get_selection_owner(selection)?.reply()?.owner != x11rb::NONE,
|
|
227
|
+
"the desktop clipboard has no text"
|
|
228
|
+
);
|
|
229
|
+
let utf8 = x11_atom(&connection, b"UTF8_STRING")?;
|
|
230
|
+
let incr = x11_atom(&connection, b"INCR")?;
|
|
231
|
+
let property = x11_atom(&connection, b"DESKLINK_CLIPBOARD")?;
|
|
232
|
+
connection.convert_selection(window, selection, utf8, property, x11rb::CURRENT_TIME)?;
|
|
233
|
+
connection.flush()?;
|
|
234
|
+
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2500);
|
|
235
|
+
loop {
|
|
236
|
+
if let Some(Event::SelectionNotify(event)) = connection.poll_for_event()? {
|
|
237
|
+
if event.requestor != window || event.selection != selection {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
anyhow::ensure!(
|
|
241
|
+
event.property != x11rb::NONE,
|
|
242
|
+
"the desktop clipboard has no text"
|
|
243
|
+
);
|
|
244
|
+
let reply = connection
|
|
245
|
+
.get_property(
|
|
246
|
+
true,
|
|
247
|
+
window,
|
|
248
|
+
property,
|
|
249
|
+
x11rb::protocol::xproto::AtomEnum::ANY,
|
|
250
|
+
0,
|
|
251
|
+
(MAX_CLIPBOARD_BYTES / 4 + 1) as u32,
|
|
252
|
+
)?
|
|
253
|
+
.reply()?;
|
|
254
|
+
// ponytail: one property only; implement INCR when larger transfers are needed.
|
|
255
|
+
anyhow::ensure!(
|
|
256
|
+
reply.type_ != incr,
|
|
257
|
+
"clipboard text is too large for one transfer (INCR is unsupported)"
|
|
258
|
+
);
|
|
259
|
+
anyhow::ensure!(
|
|
260
|
+
reply.format == 8 && reply.type_ == utf8,
|
|
261
|
+
"the clipboard contents are not UTF-8 text"
|
|
262
|
+
);
|
|
263
|
+
let (text, truncated) = bounded_text(&reply.value, MAX_CLIPBOARD_BYTES)?;
|
|
264
|
+
return Ok((text, truncated || reply.bytes_after > 0));
|
|
265
|
+
}
|
|
266
|
+
anyhow::ensure!(
|
|
267
|
+
std::time::Instant::now() < deadline,
|
|
268
|
+
"the clipboard owner did not answer"
|
|
269
|
+
);
|
|
270
|
+
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
100
274
|
#[cfg(test)]
|
|
101
275
|
mod tests {
|
|
102
276
|
use super::*;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//! Windows clipboard seam. This build never reads or writes the clipboard.
|
|
2
|
+
pub const MAX_CLIPBOARD_BYTES: usize = 256 * 1024;
|
|
3
|
+
pub fn read_or_explain() -> Result<(String, bool), String> {
|
|
4
|
+
Err("Windows clipboard transfer is not implemented in this build.".into())
|
|
5
|
+
}
|
|
6
|
+
pub fn write(_text: &str) -> anyhow::Result<()> {
|
|
7
|
+
anyhow::bail!("Windows clipboard transfer is not implemented in this build.")
|
|
8
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
//! Cursor metadata is independent of pixel damage and never painted into video.
|
|
2
|
+
use crate::protocol::{timestamp_us, CursorPosition};
|
|
3
|
+
use std::time::Instant;
|
|
4
|
+
|
|
5
|
+
pub type CursorSink = Box<dyn Fn(CursorPosition) + Send + 'static>;
|
|
6
|
+
|
|
7
|
+
pub struct Reporter {
|
|
8
|
+
sink: CursorSink,
|
|
9
|
+
last: Option<(i32, i32, bool)>,
|
|
10
|
+
bitmap_visible: bool,
|
|
11
|
+
}
|
|
12
|
+
impl Reporter {
|
|
13
|
+
pub fn new(sink: CursorSink) -> Self {
|
|
14
|
+
Self {
|
|
15
|
+
sink,
|
|
16
|
+
last: None,
|
|
17
|
+
bitmap_visible: true,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
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
|
+
pub fn update(&mut self, x: i32, y: i32, visible: bool) {
|
|
46
|
+
if self.last == Some((x, y, visible)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
self.last = Some((x, y, visible));
|
|
50
|
+
(self.sink)(CursorPosition {
|
|
51
|
+
x,
|
|
52
|
+
y,
|
|
53
|
+
visible,
|
|
54
|
+
timestamp_us: timestamp_us(Instant::now()),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[cfg(test)]
|
|
60
|
+
mod tests {
|
|
61
|
+
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
|
+
#[test]
|
|
101
|
+
fn reports_initial_motion_and_visibility_but_not_unchanged_polls() {
|
|
102
|
+
let (tx, rx) = std::sync::mpsc::channel();
|
|
103
|
+
let mut reporter = Reporter::new(Box::new(move |p| {
|
|
104
|
+
tx.send(p).unwrap();
|
|
105
|
+
}));
|
|
106
|
+
reporter.update(10, 20, true);
|
|
107
|
+
reporter.update(10, 20, true);
|
|
108
|
+
reporter.update(30, 40, true);
|
|
109
|
+
reporter.update(30, 40, false);
|
|
110
|
+
let notices: Vec<_> = rx.try_iter().collect();
|
|
111
|
+
assert_eq!(notices.len(), 3);
|
|
112
|
+
assert_eq!((notices[1].x, notices[1].y), (30, 40));
|
|
113
|
+
assert!(!notices[2].visible);
|
|
114
|
+
assert!(notices
|
|
115
|
+
.windows(2)
|
|
116
|
+
.all(|p| p[0].timestamp_us <= p[1].timestamp_us));
|
|
117
|
+
}
|
|
118
|
+
}
|
package/engine/src/indicator.rs
CHANGED
|
@@ -4,6 +4,89 @@ use anyhow::{Context, Result};
|
|
|
4
4
|
use std::io::{BufRead, BufReader, Write};
|
|
5
5
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
|
6
6
|
|
|
7
|
+
/// One drawn state of the X11 agent cue, announced by the helper before it
|
|
8
|
+
/// reaches the screen: the window's origin and size plus the inputs of
|
|
9
|
+
/// `desklink_indicator_covered`.
|
|
10
|
+
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
11
|
+
pub struct Cue {
|
|
12
|
+
pub x: i64,
|
|
13
|
+
pub y: i64,
|
|
14
|
+
pub size: i64,
|
|
15
|
+
pub unit: f64,
|
|
16
|
+
pub opacity: f64,
|
|
17
|
+
pub click: f64,
|
|
18
|
+
pub typed: f64,
|
|
19
|
+
pub threshold: u32,
|
|
20
|
+
/// Logical radius drawn so far while the shaped cue appears or leaves.
|
|
21
|
+
pub reveal: f64,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl Cue {
|
|
25
|
+
fn parse(line: &str) -> Option<Self> {
|
|
26
|
+
let mut parts = line.strip_prefix("P ")?.split(' ');
|
|
27
|
+
let mut next = || parts.next();
|
|
28
|
+
Some(Self {
|
|
29
|
+
x: next()?.parse().ok()?,
|
|
30
|
+
y: next()?.parse().ok()?,
|
|
31
|
+
size: next()?
|
|
32
|
+
.parse()
|
|
33
|
+
.ok()
|
|
34
|
+
.filter(|size| (1..=1024).contains(size))?,
|
|
35
|
+
unit: next()?
|
|
36
|
+
.parse()
|
|
37
|
+
.ok()
|
|
38
|
+
.filter(|unit: &f64| unit.is_finite() && *unit > 0.0)?,
|
|
39
|
+
opacity: next()?.parse().ok()?,
|
|
40
|
+
click: next()?.parse().ok()?,
|
|
41
|
+
typed: next()?.parse().ok()?,
|
|
42
|
+
threshold: next()?.parse().ok()?,
|
|
43
|
+
reveal: next()?.parse().ok()?,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// The X11 agent cue's announcements, read by the capture loop itself. The
|
|
49
|
+
/// helper writes each state before drawing it, so everything a grab can
|
|
50
|
+
/// contain is already in the pipe when the grab returns.
|
|
51
|
+
#[derive(Default)]
|
|
52
|
+
pub struct CueFeed {
|
|
53
|
+
reader: Option<BufReader<std::process::ChildStdout>>,
|
|
54
|
+
line: Vec<u8>,
|
|
55
|
+
recent: Vec<Cue>,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
impl CueFeed {
|
|
59
|
+
/// The last two states (the newer may not be drawn yet) plus every state
|
|
60
|
+
/// announced since the previous call.
|
|
61
|
+
pub fn drain(&mut self) -> Vec<Cue> {
|
|
62
|
+
let mut states = self.recent.clone();
|
|
63
|
+
while let Some(reader) = self.reader.as_mut() {
|
|
64
|
+
match reader.read_until(b'\n', &mut self.line) {
|
|
65
|
+
Ok(_) if self.line.ends_with(b"\n") => {
|
|
66
|
+
if let Some(cue) = Cue::parse(String::from_utf8_lossy(&self.line).trim()) {
|
|
67
|
+
states.push(cue);
|
|
68
|
+
self.recent.push(cue);
|
|
69
|
+
if self.recent.len() > 2 {
|
|
70
|
+
self.recent.remove(0);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
self.line.clear();
|
|
74
|
+
}
|
|
75
|
+
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break,
|
|
76
|
+
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
|
77
|
+
// The helper exited, and it closes its window before that.
|
|
78
|
+
_ => {
|
|
79
|
+
self.reader = None;
|
|
80
|
+
self.recent.clear();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
states
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pub type Cues = std::sync::Arc<std::sync::Mutex<CueFeed>>;
|
|
89
|
+
|
|
7
90
|
pub struct Indicator {
|
|
8
91
|
child: Option<Child>,
|
|
9
92
|
input: Option<ChildStdin>,
|
|
@@ -11,9 +94,33 @@ pub struct Indicator {
|
|
|
11
94
|
scale_y: f64,
|
|
12
95
|
#[cfg(target_os = "linux")]
|
|
13
96
|
position: std::sync::Arc<std::sync::Mutex<Option<(i64, i64)>>>,
|
|
97
|
+
cues: Cues,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[cfg(target_os = "linux")]
|
|
101
|
+
fn point_wayland_executable() -> Result<std::path::PathBuf> {
|
|
102
|
+
#[cfg(test)]
|
|
103
|
+
if let Some(path) = std::env::var_os("DESKLINK_POINT_TEST_ENGINE") {
|
|
104
|
+
return Ok(path.into());
|
|
105
|
+
}
|
|
106
|
+
Ok(std::env::current_exe()?)
|
|
14
107
|
}
|
|
15
108
|
|
|
16
109
|
impl Indicator {
|
|
110
|
+
#[cfg(target_os = "linux")]
|
|
111
|
+
pub fn wayland_layer_shell_available() -> Result<bool> {
|
|
112
|
+
let mut command = Command::new(point_wayland_executable()?);
|
|
113
|
+
command.arg("point-layer-shell-probe");
|
|
114
|
+
match Self::spawn(&mut command, 1.0, 1.0) {
|
|
115
|
+
Ok(helper) => {
|
|
116
|
+
helper.stop();
|
|
117
|
+
Ok(true)
|
|
118
|
+
}
|
|
119
|
+
Err(error) if error.to_string().contains("layer_shell_unavailable") => Ok(false),
|
|
120
|
+
Err(error) => Err(error),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
17
124
|
#[cfg(target_os = "macos")]
|
|
18
125
|
pub fn start(display_id: u32, width: usize, height: usize) -> Result<Self> {
|
|
19
126
|
let executable = std::env::current_exe()?;
|
|
@@ -39,6 +146,77 @@ impl Indicator {
|
|
|
39
146
|
)
|
|
40
147
|
}
|
|
41
148
|
|
|
149
|
+
#[cfg(target_os = "linux")]
|
|
150
|
+
pub fn start_point(display: &str) -> Result<Self> {
|
|
151
|
+
Self::spawn(
|
|
152
|
+
Command::new(std::env::current_exe()?)
|
|
153
|
+
.arg("point-overlay")
|
|
154
|
+
.arg(display),
|
|
155
|
+
1.0,
|
|
156
|
+
1.0,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#[cfg(target_os = "linux")]
|
|
161
|
+
pub fn start_point_wayland(
|
|
162
|
+
width: i32,
|
|
163
|
+
height: i32,
|
|
164
|
+
position: Option<(i32, i32)>,
|
|
165
|
+
) -> Result<Self> {
|
|
166
|
+
let (x, y) = position.unwrap_or_default();
|
|
167
|
+
Self::spawn(
|
|
168
|
+
Command::new(point_wayland_executable()?)
|
|
169
|
+
.arg("point-overlay-wayland")
|
|
170
|
+
.args([
|
|
171
|
+
width.to_string(),
|
|
172
|
+
height.to_string(),
|
|
173
|
+
i32::from(position.is_some()).to_string(),
|
|
174
|
+
x.to_string(),
|
|
175
|
+
y.to_string(),
|
|
176
|
+
]),
|
|
177
|
+
1.0,
|
|
178
|
+
1.0,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[cfg(target_os = "macos")]
|
|
183
|
+
pub fn start_point(display_id: u32, width: usize, height: usize) -> Result<Self> {
|
|
184
|
+
let (_, _, points_w, points_h) = crate::mac::display_geometry(display_id)
|
|
185
|
+
.context("the selected display is unavailable")?;
|
|
186
|
+
Self::spawn(
|
|
187
|
+
Command::new(std::env::current_exe()?)
|
|
188
|
+
.arg("point-overlay")
|
|
189
|
+
.arg(display_id.to_string()),
|
|
190
|
+
points_w / width as f64,
|
|
191
|
+
points_h / height as f64,
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#[cfg(all(test, target_os = "linux"))]
|
|
196
|
+
pub fn test_pid(&self) -> u32 {
|
|
197
|
+
self.child.as_ref().unwrap().id()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
pub fn point(&mut self, x: i64, y: i64, timeout_ms: u64, label: &str) -> Result<()> {
|
|
201
|
+
let input = self.input.as_mut().context("point overlay exited")?;
|
|
202
|
+
writeln!(
|
|
203
|
+
input,
|
|
204
|
+
"P {:.2} {:.2} {timeout_ms} {label}",
|
|
205
|
+
x as f64 * self.scale_x,
|
|
206
|
+
y as f64 * self.scale_y
|
|
207
|
+
)?;
|
|
208
|
+
Ok(())
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Point markers have no fade: close/clear must remove them and reap the helper.
|
|
212
|
+
pub fn stop(mut self) {
|
|
213
|
+
self.input.take();
|
|
214
|
+
if let Some(mut child) = self.child.take() {
|
|
215
|
+
let _ = child.kill();
|
|
216
|
+
let _ = child.wait();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
42
220
|
#[cfg(target_os = "linux")]
|
|
43
221
|
pub fn start_wayland(width: usize, height: usize) -> Result<Self> {
|
|
44
222
|
let executable = std::env::current_exe()?;
|
|
@@ -67,17 +245,42 @@ impl Indicator {
|
|
|
67
245
|
let stdout = child.stdout.take().unwrap();
|
|
68
246
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
|
69
247
|
let reader = std::thread::spawn(move || {
|
|
248
|
+
let mut reader = BufReader::new(stdout);
|
|
70
249
|
let mut ready = String::new();
|
|
71
|
-
let
|
|
72
|
-
let _ = ready_tx.send(
|
|
250
|
+
let _ = reader.read_line(&mut ready);
|
|
251
|
+
let _ = ready_tx.send((ready.trim().to_owned(), reader));
|
|
73
252
|
});
|
|
74
|
-
|
|
253
|
+
let (ready, reader_after) = match ready_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
|
254
|
+
Ok((ready, reader)) => (ready, Some(reader)),
|
|
255
|
+
Err(_) => (String::new(), None),
|
|
256
|
+
};
|
|
257
|
+
if ready != "READY" {
|
|
75
258
|
let _ = child.kill();
|
|
76
259
|
let _ = child.wait();
|
|
77
260
|
let _ = reader.join();
|
|
78
|
-
anyhow::bail!("agent indicator did not open its overlay within 5s");
|
|
261
|
+
anyhow::bail!("agent indicator did not open its overlay within 5s: {ready}");
|
|
262
|
+
}
|
|
263
|
+
drop(reader);
|
|
264
|
+
// Only the X11 agent cue writes after READY, and only its capture loop
|
|
265
|
+
// reads; the helper blocks on a full pipe rather than draw unannounced.
|
|
266
|
+
let cues = Cues::default();
|
|
267
|
+
#[cfg(not(unix))]
|
|
268
|
+
drop(reader_after);
|
|
269
|
+
#[cfg(unix)]
|
|
270
|
+
if let Some(reader) = reader_after {
|
|
271
|
+
use std::os::fd::AsRawFd;
|
|
272
|
+
let fd = reader.get_ref().as_raw_fd();
|
|
273
|
+
unsafe {
|
|
274
|
+
libc::fcntl(
|
|
275
|
+
fd,
|
|
276
|
+
libc::F_SETFL,
|
|
277
|
+
libc::fcntl(fd, libc::F_GETFL) | libc::O_NONBLOCK,
|
|
278
|
+
)
|
|
279
|
+
};
|
|
280
|
+
if let Ok(mut feed) = cues.lock() {
|
|
281
|
+
feed.reader = Some(reader);
|
|
282
|
+
}
|
|
79
283
|
}
|
|
80
|
-
let _ = reader.join();
|
|
81
284
|
Ok(Self {
|
|
82
285
|
input: child.stdin.take(),
|
|
83
286
|
child: Some(child),
|
|
@@ -85,9 +288,14 @@ impl Indicator {
|
|
|
85
288
|
scale_y,
|
|
86
289
|
#[cfg(target_os = "linux")]
|
|
87
290
|
position: Default::default(),
|
|
291
|
+
cues,
|
|
88
292
|
})
|
|
89
293
|
}
|
|
90
294
|
|
|
295
|
+
pub fn cues(&self) -> Cues {
|
|
296
|
+
self.cues.clone()
|
|
297
|
+
}
|
|
298
|
+
|
|
91
299
|
#[cfg(target_os = "macos")]
|
|
92
300
|
pub fn pid(&self) -> u32 {
|
|
93
301
|
self.child.as_ref().unwrap().id()
|
|
@@ -114,8 +322,12 @@ impl Drop for Indicator {
|
|
|
114
322
|
// Closing stdin fades the edge and exits; do not wait for the animation.
|
|
115
323
|
self.input.take();
|
|
116
324
|
if let Some(mut child) = self.child.take() {
|
|
325
|
+
// Keep reading the cue's pipe open until it exits, so it can
|
|
326
|
+
// still animate out; it leaves at once only when the engine does.
|
|
327
|
+
let cues = self.cues.clone();
|
|
117
328
|
std::thread::spawn(move || {
|
|
118
329
|
let _ = child.wait();
|
|
330
|
+
drop(cues);
|
|
119
331
|
});
|
|
120
332
|
}
|
|
121
333
|
}
|
|
@@ -123,6 +335,60 @@ impl Drop for Indicator {
|
|
|
123
335
|
|
|
124
336
|
#[cfg(all(test, target_os = "linux"))]
|
|
125
337
|
mod tests {
|
|
338
|
+
#[test]
|
|
339
|
+
fn cue_feed_keeps_recent_states_until_the_helper_exits() {
|
|
340
|
+
use std::os::fd::AsRawFd;
|
|
341
|
+
let mut child = std::process::Command::new("sh")
|
|
342
|
+
.args([
|
|
343
|
+
"-c",
|
|
344
|
+
"for x in 1 2 3; do echo \"P $x 0 110 1 1 -1 -1 128 99\"; done; read _",
|
|
345
|
+
])
|
|
346
|
+
.stdin(std::process::Stdio::piped())
|
|
347
|
+
.stdout(std::process::Stdio::piped())
|
|
348
|
+
.spawn()
|
|
349
|
+
.unwrap();
|
|
350
|
+
let reader = super::BufReader::new(child.stdout.take().unwrap());
|
|
351
|
+
let fd = reader.get_ref().as_raw_fd();
|
|
352
|
+
unsafe {
|
|
353
|
+
libc::fcntl(
|
|
354
|
+
fd,
|
|
355
|
+
libc::F_SETFL,
|
|
356
|
+
libc::fcntl(fd, libc::F_GETFL) | libc::O_NONBLOCK,
|
|
357
|
+
)
|
|
358
|
+
};
|
|
359
|
+
let mut feed = super::CueFeed {
|
|
360
|
+
reader: Some(reader),
|
|
361
|
+
..Default::default()
|
|
362
|
+
};
|
|
363
|
+
let mut drained = vec![];
|
|
364
|
+
for _ in 0..200 {
|
|
365
|
+
drained.extend(feed.drain().into_iter().map(|cue| cue.x));
|
|
366
|
+
if drained.len() >= 3 {
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
370
|
+
}
|
|
371
|
+
assert_eq!(
|
|
372
|
+
drained,
|
|
373
|
+
[1, 2, 3],
|
|
374
|
+
"a drain that finds nothing does not block"
|
|
375
|
+
);
|
|
376
|
+
let xs =
|
|
377
|
+
|feed: &mut super::CueFeed| feed.drain().iter().map(|cue| cue.x).collect::<Vec<_>>();
|
|
378
|
+
assert_eq!(
|
|
379
|
+
xs(&mut feed),
|
|
380
|
+
[2, 3],
|
|
381
|
+
"either of the last two may be on screen"
|
|
382
|
+
);
|
|
383
|
+
drop(child.stdin.take());
|
|
384
|
+
child.wait().unwrap();
|
|
385
|
+
assert_eq!(xs(&mut feed), [2, 3]);
|
|
386
|
+
assert!(
|
|
387
|
+
xs(&mut feed).is_empty(),
|
|
388
|
+
"a closed helper has no window left"
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
126
392
|
unsafe extern "C" {
|
|
127
393
|
fn desklink_wayland_geometry(
|
|
128
394
|
source_w: i32,
|