@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.
- package/README.md +30 -9
- package/bin/desklink-host.mjs +2 -1
- 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.map +1 -1
- package/dist/engineProcess.js +0 -1
- package/dist/engineProcess.js.map +1 -1
- package/dist/protocol.d.ts +4 -3
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/docs/BOUNDARY.md +8 -8
- package/docs/PROTOCOL.md +72 -45
- package/engine/Cargo.lock +1 -1
- package/engine/Cargo.toml +1 -1
- package/engine/build.rs +1 -0
- package/engine/native/agent_indicator.h +57 -0
- package/engine/native/agent_overlay_wayland.c +47 -26
- package/engine/native/agent_overlay_x11.c +116 -57
- package/engine/src/capture.rs +4 -2
- package/engine/src/clipboard.rs +174 -0
- package/engine/src/indicator.rs +175 -10
- package/engine/src/main.rs +2 -2
- package/engine/src/peer.rs +10 -4
- package/engine/src/protocol.rs +7 -10
- package/engine/src/session.rs +305 -209
- package/engine/src/x11.rs +29 -29
- package/engine/src/x11_mac.rs +4 -0
- package/examples/reference-client.html +3 -5
- package/package.json +3 -3
|
@@ -1,40 +1,82 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
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/
|
|
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
|
|
15
|
-
double
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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,
|
|
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) {
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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);
|
package/engine/src/capture.rs
CHANGED
|
@@ -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
|
|
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 >
|
|
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
|
}
|
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::*;
|
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,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
|
|
166
|
-
let _ = ready_tx.send(
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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,
|