@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.
Files changed (47) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +92 -23
  3. package/bin/desklink-host.mjs +14 -5
  4. package/dist/bridge.d.ts +3 -4
  5. package/dist/bridge.d.ts.map +1 -1
  6. package/dist/bridge.js +18 -7
  7. package/dist/bridge.js.map +1 -1
  8. package/dist/engineProcess.d.ts +6 -1
  9. package/dist/engineProcess.d.ts.map +1 -1
  10. package/dist/engineProcess.js +34 -5
  11. package/dist/engineProcess.js.map +1 -1
  12. package/dist/protocol.d.ts +35 -2
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/protocol.js.map +1 -1
  15. package/dist/resolveEngine.d.ts +6 -0
  16. package/dist/resolveEngine.d.ts.map +1 -1
  17. package/dist/resolveEngine.js +45 -6
  18. package/dist/resolveEngine.js.map +1 -1
  19. package/docs/BOUNDARY.md +8 -8
  20. package/docs/PROTOCOL.md +185 -45
  21. package/engine/Cargo.lock +2 -2
  22. package/engine/Cargo.toml +7 -12
  23. package/engine/build.rs +62 -5
  24. package/engine/native/agent_indicator.h +57 -0
  25. package/engine/native/agent_overlay_mac.m +51 -4
  26. package/engine/native/agent_overlay_wayland.c +189 -35
  27. package/engine/native/agent_overlay_x11.c +186 -55
  28. package/engine/src/capture.rs +96 -5
  29. package/engine/src/capture_win.rs +23 -0
  30. package/engine/src/clipboard.rs +174 -0
  31. package/engine/src/clipboard_win.rs +8 -0
  32. package/engine/src/cursor.rs +118 -0
  33. package/engine/src/indicator.rs +271 -5
  34. package/engine/src/input_win.rs +91 -0
  35. package/engine/src/keymap_win.rs +39 -0
  36. package/engine/src/mac.rs +28 -6
  37. package/engine/src/main.rs +167 -25
  38. package/engine/src/peer.rs +10 -4
  39. package/engine/src/portal.rs +42 -4
  40. package/engine/src/portal_win.rs +37 -0
  41. package/engine/src/protocol.rs +56 -10
  42. package/engine/src/session.rs +749 -203
  43. package/engine/src/win.rs +110 -0
  44. package/engine/src/x11.rs +44 -29
  45. package/engine/src/x11_mac.rs +4 -0
  46. package/examples/reference-client.html +3 -5
  47. package/package.json +4 -4
@@ -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
+ }
@@ -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 result = BufReader::new(stdout).read_line(&mut ready);
72
- let _ = ready_tx.send(result.is_ok() && ready.trim() == "READY");
250
+ let _ = reader.read_line(&mut ready);
251
+ let _ = ready_tx.send((ready.trim().to_owned(), reader));
73
252
  });
74
- if ready_rx.recv_timeout(std::time::Duration::from_secs(5)) != Ok(true) {
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,