@desklink/host 0.3.1 → 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.
@@ -1,40 +1,82 @@
1
- // The X server draws cursors outside GetImage(root). No overlay window can
2
- // expose black pixels, intercept input, or poison agent frame diffs.
1
+ // The agent cue is a click-through override-redirect window that follows the
2
+ // pointer, so it stays visible over windows that set their own cursor and never
3
+ // replaces the person's cursor. With a compositing manager it uses an ARGB
4
+ // visual; without one, a 1-bit shape. It appears in GetImage(root), so each
5
+ // state is reported on stdout before it is drawn and the engine restores the
6
+ // covered pixels in X11 capture (desklink_indicator_covered below).
3
7
  #include <X11/Xlib.h>
4
- #include <X11/Xcursor/Xcursor.h>
8
+ #include <X11/Xutil.h>
9
+ #include <X11/Xresource.h>
10
+ #include <X11/extensions/shape.h>
5
11
  #include <math.h>
6
12
  #include <fcntl.h>
7
13
  #include <stdio.h>
14
+ #include <stdlib.h>
8
15
  #include <string.h>
9
16
  #include <time.h>
10
17
  #include <unistd.h>
18
+ #include "agent_indicator.h"
19
+
20
+ // The same coverage test the helper draws with, for the engine's capture mask.
21
+ int desklink_indicator_covered(double x, double y, double opacity, double click, double typed, unsigned threshold, double reveal) {
22
+ return hypot(x,y)<reveal && (indicator_pixel(x,y,opacity,click,typed)>>24) >= threshold;
23
+ }
11
24
 
12
25
  static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec/1e9; }
13
26
 
14
- static unsigned pixel(double distance, double radius, double opacity) {
15
- double edge = fmax(0, 1 - fabs(distance-radius)/2.5);
16
- unsigned alpha = (unsigned)(fmin(1, edge * opacity) * 255);
17
- return (alpha << 24) | 0x4c9ed0;
27
+ static double desktop_scale(Display *d) {
28
+ double scale=1;
29
+ const char *resources=XResourceManagerString(d);
30
+ if(!resources) return scale;
31
+ XrmInitialize(); XrmDatabase db=XrmGetStringDatabase(resources);
32
+ XrmValue value; char *type;
33
+ if(db && XrmGetResource(db,"Xft.dpi","Xft.Dpi",&type,&value) && value.addr) {
34
+ double dpi=strtod(value.addr,NULL);
35
+ if(isfinite(dpi) && dpi>=96 && dpi<=384)scale=dpi/96;
36
+ }
37
+ if(db)XrmDestroyDatabase(db);
38
+ return scale;
18
39
  }
19
40
 
20
41
  int desklink_agent_overlay_main(const char *display_name) {
21
42
  Display *d = XOpenDisplay(display_name);
22
43
  if (!d) return 2;
23
- Window root = DefaultRootWindow(d);
24
- unsigned best_w=64,best_h=64;
25
- XQueryBestCursor(d,root,64,64,&best_w,&best_h);
26
- unsigned size = best_w < best_h ? best_w : best_h;
27
- if (size < 32) { XCloseDisplay(d); return 2; }
28
- if (size > 96) size = 96;
29
- XcursorImage *image = XcursorImageCreate(size,size);
30
- if (!image) { XCloseDisplay(d); return 2; }
31
- image->version = XCURSOR_IMAGE_VERSION;
32
- Cursor previous = None;
44
+ int event_base, error_base, screen=DefaultScreen(d);
45
+ if (!XShapeQueryExtension(d,&event_base,&error_base)) { XCloseDisplay(d); return 2; }
46
+ Window root = RootWindow(d,screen);
47
+ double scale=desktop_scale(d);
48
+ // The arrow reaches 36 logical px from the cue's center.
49
+ int size=(int)ceil(80*scale);
50
+ char selection[32]; snprintf(selection,sizeof(selection),"_NET_WM_CM_S%d",screen);
51
+ XVisualInfo argb;
52
+ int alpha = XGetSelectionOwner(d,XInternAtom(d,selection,False))!=None
53
+ && XMatchVisualInfo(d,screen,32,TrueColor,&argb);
54
+ XSetWindowAttributes attrs={0};
55
+ attrs.override_redirect=True;
56
+ attrs.background_pixmap=None;
57
+ attrs.border_pixel=0;
58
+ unsigned long mask=CWOverrideRedirect|CWBackPixmap|CWBorderPixel;
59
+ Visual *visual=DefaultVisual(d,screen); int depth=DefaultDepth(d,screen);
60
+ if(alpha) {
61
+ visual=argb.visual; depth=32;
62
+ attrs.colormap=XCreateColormap(d,root,visual,AllocNone); mask|=CWColormap;
63
+ } else if(depth<24) { XCloseDisplay(d); return 2; }
64
+ Window window=XCreateWindow(d,root,0,0,size,size,0,depth,InputOutput,visual,mask,&attrs);
65
+ XStoreName(d,window,"Desklink agent");
66
+ XShapeCombineRectangles(d,window,ShapeInput,0,0,NULL,0,ShapeSet,Unsorted);
67
+ XSelectInput(d,window,ExposureMask);
68
+ XSelectInput(d,root,SubstructureNotifyMask);
69
+ GC gc=XCreateGC(d,window,0,NULL);
70
+ uint32_t *pixels=calloc((size_t)size*size,4);
71
+ int row=(size+7)/8;
72
+ char *bits=calloc((size_t)row*size,1);
73
+ XImage *image=pixels?XCreateImage(d,visual,depth,ZPixmap,0,(char *)pixels,size,size,32,0):NULL;
74
+ if(!image || !bits || image->bits_per_pixel!=32) { XCloseDisplay(d); return 2; }
33
75
  int stdin_flags = fcntl(STDIN_FILENO, F_GETFL);
34
76
  fcntl(STDIN_FILENO, F_SETFL, stdin_flags | O_NONBLOCK);
35
77
  puts("READY"); fflush(stdout);
36
- double target_x=0,target_y=0,cursor_x=0,cursor_y=0,click=0,typed=0,ending=0;
37
- int active=0;
78
+ double target_x=0,target_y=0,cue_x=0,cue_y=0,click=0,typed=0,ending=0,started=0;
79
+ int active=0,mapped=0,redraw=0,was_animating=0,last_x=-100000,last_y=-100000;
38
80
  char pending[128]; size_t used=0;
39
81
  for (;;) {
40
82
  char bytes[128]; ssize_t n = read(STDIN_FILENO, bytes, sizeof(bytes));
@@ -44,11 +86,12 @@ int desklink_agent_overlay_main(const char *display_name) {
44
86
  pending[used]=0; char kind=0; double px=0,py=0;
45
87
  if (sscanf(pending,"%c %lf %lf",&kind,&px,&py)>=1) {
46
88
  if (kind=='S') ending=now();
47
- else if (kind=='M'||kind=='C'||kind=='T') {
89
+ else if ((kind=='M'||kind=='C'||kind=='T') && (active || (px>=0 && py>=0))) {
48
90
  if (px>=0 && py>=0) {
49
91
  target_x=px; target_y=py;
50
- if (!active) { cursor_x=px; cursor_y=py; }
92
+ if (!active) { cue_x=px; cue_y=py; }
51
93
  }
94
+ if (!active) started=now();
52
95
  active=1;
53
96
  if (kind=='C') click=now();
54
97
  if (kind=='T') typed=now();
@@ -57,48 +100,66 @@ int desklink_agent_overlay_main(const char *display_name) {
57
100
  used=0;
58
101
  } else if (used < sizeof(pending)-1) pending[used++]=bytes[i];
59
102
  }
103
+ while (XPending(d)) {
104
+ XEvent event; XNextEvent(d,&event);
105
+ if (event.type==Expose && event.xexpose.window==window) redraw=1;
106
+ // Stay above windows and menus mapped or restacked after us.
107
+ if (mapped && ((event.type==MapNotify && event.xmap.window!=window)
108
+ || (event.type==ConfigureNotify && event.xconfigure.window!=window))) XRaiseWindow(d,window);
109
+ }
60
110
  double t=now();
61
- if (ending && t-ending>0.55) break;
111
+ if (ending && (!active || t-ending>0.55)) break;
62
112
  if (active) {
63
- cursor_x += (target_x-cursor_x)*0.24;
64
- cursor_y += (target_y-cursor_y)*0.24;
65
- int radius=(int)size/2-3;
66
- image->xhot = (unsigned)fmax(0,fmin(size-1,(double)size/2+target_x-cursor_x));
67
- image->yhot = (unsigned)fmax(0,fmin(size-1,(double)size/2+target_y-cursor_y));
68
- for (unsigned y=0;y<size;y++) for (unsigned x=0;x<size;x++) {
69
- double distance=hypot(x-(double)size/2,y-(double)size/2);
70
- double opacity=ending ? fmax(0,1-(t-ending)/0.55) : 1;
71
- unsigned color=pixel(distance,12,0.72*opacity);
72
- if (distance>=16 && distance<=radius) {
73
- unsigned outer=pixel(distance,20,0.20*opacity);
74
- if ((outer>>24)>(color>>24)) color=outer;
113
+ cue_x += (target_x-cue_x)*0.24;
114
+ cue_y += (target_y-cue_y)*0.24;
115
+ double opacity=ending ? fmax(0,1-(t-ending)/0.55) : fmin(1,(t-started)/0.18);
116
+ double c=click?(t-click)/0.55:-1, k=typed?(t-typed)/0.4:-1;
117
+ int animating = opacity<1 || (c>=0 && c<1) || (k>=0 && k<1);
118
+ // One more frame once an animation ends, so the rest state is exact.
119
+ if (!animating && was_animating) redraw=1;
120
+ was_animating=animating;
121
+ int x=(int)lround(cue_x), y=(int)lround(cue_y);
122
+ if (animating || redraw || x!=last_x || y!=last_y) {
123
+ // A 1-bit shape cannot fade: reveal outward from the tip and
124
+ // retract inward, so coverage only grows while appearing and a
125
+ // still cue never uncovers stale pixels.
126
+ double unit=scale, reveal=alpha?99:40*opacity*(2-opacity);
127
+ memset(bits,0,(size_t)row*size);
128
+ for (int py=0;py<size;py++) for (int px=0;px<size;px++) {
129
+ double lx=(px-size/2.0)/unit, ly=(py-size/2.0)/unit;
130
+ uint32_t color=hypot(lx,ly)<reveal?indicator_pixel(lx,ly,alpha?opacity:1,c,k):0;
131
+ unsigned a=color>>24;
132
+ if (alpha) { pixels[py*size+px]=color; continue; }
133
+ if (a<128) { pixels[py*size+px]=0; continue; }
134
+ bits[py*row+px/8]|=(char)(1<<(px%8));
135
+ // Un-premultiply: the shaped path paints opaque pixels.
136
+ pixels[py*size+px]=((((color>>16)&255)*255/a)<<16)|((((color>>8)&255)*255/a)<<8)|((color&255)*255/a);
75
137
  }
76
- if (click && t-click<0.5) {
77
- double progress=(t-click)/0.5;
78
- unsigned ripple=pixel(distance,12+(radius-14)*progress,0.6*(1-progress)*opacity);
79
- if ((ripple>>24)>(color>>24)) color=ripple;
138
+ // Announce first: capture reads this pipe after each grab, so
139
+ // every state a grab can see is already announced.
140
+ printf("P %d %d %d %.6f %.4f %.4f %.4f %u %.4f\n",x-size/2,y-size/2,size,unit,alpha?opacity:1,c,k,alpha?1u:128u,reveal);
141
+ // No reader means the engine is gone: leave at once rather
142
+ // than animate unmasked into whatever captures next.
143
+ if (fflush(stdout)) break;
144
+ if (!alpha) {
145
+ Pixmap shape=XCreateBitmapFromData(d,window,bits,size,size);
146
+ XShapeCombineMask(d,window,ShapeBounding,0,0,shape,ShapeSet);
147
+ XFreePixmap(d,shape);
80
148
  }
81
- if (typed && t-typed<0.35) {
82
- double progress=(t-typed)/0.35;
83
- if (distance<5+progress*12) {
84
- unsigned pulse=(unsigned)(0.22*(1-progress)*opacity*255);
85
- if (pulse>(color>>24)) color=(pulse<<24)|0x5baed8;
86
- }
87
- }
88
- image->pixels[y*size+x]=color;
89
- }
90
- Cursor next=XcursorImageLoadCursor(d,image);
91
- if (next!=None) {
92
- XDefineCursor(d,root,next); XFlush(d);
93
- if (previous!=None) XFreeCursor(d,previous);
94
- previous=next;
149
+ if (x!=last_x || y!=last_y) XMoveWindow(d,window,x-size/2,y-size/2);
150
+ if (!mapped) { XMapRaised(d,window); mapped=1; }
151
+ XPutImage(d,window,gc,image,0,0,0,0,size,size);
152
+ // Wait until the server has drawn it, so at most the newest
153
+ // announced state is not on screen yet.
154
+ XSync(d,False);
155
+ last_x=x; last_y=y; redraw=0;
95
156
  }
96
157
  }
97
158
  usleep(16000);
98
159
  }
99
- XUndefineCursor(d,root); XFlush(d);
100
- if (previous!=None) XFreeCursor(d,previous);
101
- XcursorImageDestroy(image); XCloseDisplay(d);
160
+ XDestroyWindow(d,window);
161
+ XDestroyImage(image); free(bits);
162
+ XFreeGC(d,gc); XCloseDisplay(d);
102
163
  return 0;
103
164
  }
104
165
 
@@ -106,8 +167,6 @@ int desklink_agent_overlay_main(const char *display_name) {
106
167
  // window works without a compositor; an empty input shape passes every event
107
168
  // through and mapping it never asks for keyboard focus. It intentionally appears
108
169
  // in GetImage(root), so the local person and the video observer share the cue.
109
- #include <X11/extensions/shape.h>
110
- #include <stdlib.h>
111
170
 
112
171
  int desklink_point_overlay_main(const char *display_name) {
113
172
  Display *d = XOpenDisplay(display_name);
@@ -541,7 +541,8 @@ fn run_loop(
541
541
 
542
542
  // The portal composites layer-shell into its pixels. Keep the last clean
543
543
  // backing for the helper's bounded circle and edge strips before diff/encode;
544
- // the center stays live so a click marker at the caret remains observable.
544
+ // the center (4 logical px, which the cue leaves clear) stays live so a
545
+ // click marker at the caret remains observable.
545
546
  struct IndicatorMask {
546
547
  position: Arc<Mutex<Option<(i64, i64)>>>,
547
548
  clean: Option<Vec<u8>>,
@@ -591,6 +592,7 @@ impl IndicatorMask {
591
592
  )
592
593
  });
593
594
  let radius = (150 * width / source_w).max(55) as i64;
595
+ let hole = (4 * width / source_w) as i64;
594
596
  let edge = (20 * width / source_w)
595
597
  .max(8)
596
598
  .min(width / 2)
@@ -630,7 +632,7 @@ impl IndicatorMask {
630
632
  };
631
633
  (col as i64 - cx).pow(2) + (row as i64 - cy).pow(2) <= radius * radius
632
634
  });
633
- if (distance <= radius * radius || along) && distance > 16 {
635
+ if (distance <= radius * radius || along) && distance > hole * hole {
634
636
  let at = start + col * 4;
635
637
  raw[at..at + 4].copy_from_slice(&clean[at..at + 4]);
636
638
  }
@@ -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::*;
@@ -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,6 +94,7 @@ 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,
14
98
  }
15
99
 
16
100
  #[cfg(target_os = "linux")]
@@ -161,24 +245,42 @@ impl Indicator {
161
245
  let stdout = child.stdout.take().unwrap();
162
246
  let (ready_tx, ready_rx) = std::sync::mpsc::channel();
163
247
  let reader = std::thread::spawn(move || {
248
+ let mut reader = BufReader::new(stdout);
164
249
  let mut ready = String::new();
165
- let result = BufReader::new(stdout).read_line(&mut ready);
166
- let _ = ready_tx.send(if result.is_ok() {
167
- ready.trim().to_owned()
168
- } else {
169
- String::new()
170
- });
250
+ let _ = reader.read_line(&mut ready);
251
+ let _ = ready_tx.send((ready.trim().to_owned(), reader));
171
252
  });
172
- let ready = ready_rx
173
- .recv_timeout(std::time::Duration::from_secs(5))
174
- .unwrap_or_default();
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
+ };
175
257
  if ready != "READY" {
176
258
  let _ = child.kill();
177
259
  let _ = child.wait();
178
260
  let _ = reader.join();
179
261
  anyhow::bail!("agent indicator did not open its overlay within 5s: {ready}");
180
262
  }
181
- let _ = reader.join();
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
+ }
283
+ }
182
284
  Ok(Self {
183
285
  input: child.stdin.take(),
184
286
  child: Some(child),
@@ -186,9 +288,14 @@ impl Indicator {
186
288
  scale_y,
187
289
  #[cfg(target_os = "linux")]
188
290
  position: Default::default(),
291
+ cues,
189
292
  })
190
293
  }
191
294
 
295
+ pub fn cues(&self) -> Cues {
296
+ self.cues.clone()
297
+ }
298
+
192
299
  #[cfg(target_os = "macos")]
193
300
  pub fn pid(&self) -> u32 {
194
301
  self.child.as_ref().unwrap().id()
@@ -215,8 +322,12 @@ impl Drop for Indicator {
215
322
  // Closing stdin fades the edge and exits; do not wait for the animation.
216
323
  self.input.take();
217
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();
218
328
  std::thread::spawn(move || {
219
329
  let _ = child.wait();
330
+ drop(cues);
220
331
  });
221
332
  }
222
333
  }
@@ -224,6 +335,60 @@ impl Drop for Indicator {
224
335
 
225
336
  #[cfg(all(test, target_os = "linux"))]
226
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
+
227
392
  unsafe extern "C" {
228
393
  fn desklink_wayland_geometry(
229
394
  source_w: i32,