@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
@@ -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,47 +100,135 @@ 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);
163
+ return 0;
164
+ }
165
+
166
+ // Point cues are independent of the real cursor. A shaped override-redirect
167
+ // window works without a compositor; an empty input shape passes every event
168
+ // through and mapping it never asks for keyboard focus. It intentionally appears
169
+ // in GetImage(root), so the local person and the video observer share the cue.
170
+
171
+ int desklink_point_overlay_main(const char *display_name) {
172
+ Display *d = XOpenDisplay(display_name);
173
+ if (!d) return 2;
174
+ int event_base, error_base;
175
+ if (!XShapeQueryExtension(d, &event_base, &error_base)) { XCloseDisplay(d); return 2; }
176
+ int screen = DefaultScreen(d), width = DisplayWidth(d, screen), height = DisplayHeight(d, screen);
177
+ Window root = RootWindow(d, screen);
178
+ XSetWindowAttributes attrs = {0};
179
+ attrs.override_redirect = True;
180
+ attrs.background_pixel = 0x4c9ed0;
181
+ Window window = XCreateWindow(d, root, 0, 0, width, height, 0,
182
+ CopyFromParent, InputOutput, CopyFromParent, CWOverrideRedirect | CWBackPixel, &attrs);
183
+ XStoreName(d, window, "Desklink point");
184
+ XShapeCombineRectangles(d, window, ShapeInput, 0, 0, NULL, 0, ShapeSet, Unsorted);
185
+ Pixmap mask = XCreatePixmap(d, root, width, height, 1);
186
+ GC shape = XCreateGC(d, mask, 0, NULL), ink = XCreateGC(d, window, 0, NULL);
187
+ XFontStruct *font = XLoadQueryFont(d, "fixed");
188
+ if (font) XSetFont(d, ink, font->fid);
189
+ XSetLineAttributes(d, shape, 4, LineSolid, CapRound, JoinRound);
190
+ fcntl(STDIN_FILENO, F_SETFL, fcntl(STDIN_FILENO, F_GETFL) | O_NONBLOCK);
191
+ puts("READY"); fflush(stdout);
192
+ char pending[512]; size_t used = 0;
193
+ double deadline = 0;
194
+ for (;;) {
195
+ char bytes[512]; ssize_t n = read(STDIN_FILENO, bytes, sizeof(bytes));
196
+ if (n == 0) break;
197
+ for (ssize_t i = 0; i < n; i++) {
198
+ if (bytes[i] != '\n') { if (used < sizeof(pending)-1) pending[used++] = bytes[i]; continue; }
199
+ pending[used] = 0; used = 0;
200
+ double px, py; unsigned timeout; int offset = 0;
201
+ if (sscanf(pending, "P %lf %lf %u %n", &px, &py, &timeout, &offset) != 3 || !offset) continue;
202
+ const char *label = pending + offset;
203
+ int x = (int)px, y = (int)py, len = (int)strlen(label);
204
+ int badge_w = font ? XTextWidth(font, label, len) + 16 : len * 6 + 16;
205
+ if (badge_w > width) badge_w = width;
206
+ int badge_x = x + 30, badge_y = y - 14;
207
+ if (badge_x + badge_w > width) badge_x = width - badge_w;
208
+ if (badge_y < 0) badge_y = 0;
209
+ if (badge_y + 28 > height) badge_y = height - 28;
210
+ XUnmapWindow(d, window);
211
+ XSetForeground(d, shape, 0); XFillRectangle(d, mask, shape, 0, 0, width, height);
212
+ XSetForeground(d, shape, 1);
213
+ XDrawArc(d, mask, shape, x-18, y-18, 36, 36, 0, 360*64);
214
+ XFillArc(d, mask, shape, x-3, y-3, 6, 6, 0, 360*64);
215
+ if (len) XFillRectangle(d, mask, shape, badge_x, badge_y, badge_w, 28);
216
+ XShapeCombineMask(d, window, ShapeBounding, 0, 0, mask, ShapeSet);
217
+ XMapRaised(d, window);
218
+ XClearWindow(d, window);
219
+ if (len) {
220
+ XSetForeground(d, ink, 0x123047);
221
+ XFillRectangle(d, window, ink, badge_x, badge_y, badge_w, 28);
222
+ XSetForeground(d, ink, 0xffffff);
223
+ XDrawString(d, window, ink, badge_x+8, badge_y+18, label, len);
224
+ }
225
+ XSync(d, False);
226
+ deadline = now() + timeout/1000.0;
227
+ }
228
+ if (deadline && now() >= deadline) { XUnmapWindow(d, window); XFlush(d); deadline = 0; }
229
+ usleep(5000);
230
+ }
231
+ XDestroyWindow(d, window); XFreeGC(d, shape); XFreeGC(d, ink);
232
+ XFreePixmap(d, mask); if (font) XFreeFont(d, font); XCloseDisplay(d);
102
233
  return 0;
103
234
  }
@@ -13,7 +13,7 @@ use pw::spa;
13
13
  use pw::spa::pod::{serialize::PodSerializer, Pod};
14
14
  use std::io::Cursor;
15
15
  use std::os::fd::OwnedFd;
16
- use std::sync::atomic::{AtomicU64, Ordering};
16
+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
17
17
  use std::sync::{mpsc, Arc, Mutex};
18
18
  use std::thread::JoinHandle;
19
19
  use std::time::{Duration, Instant};
@@ -55,6 +55,7 @@ pub struct Capture {
55
55
  quit: pw::channel::Sender<()>,
56
56
  thread: Option<JoinHandle<()>>,
57
57
  pub source: SelectedSource,
58
+ cursor_positions: Arc<AtomicBool>,
58
59
  geometry: Arc<Mutex<Option<StreamGeometry>>>,
59
60
  frames: Arc<AtomicU64>,
60
61
  dropped: Arc<AtomicU64>,
@@ -63,6 +64,9 @@ pub struct Capture {
63
64
  }
64
65
 
65
66
  impl Capture {
67
+ pub fn cursor_positions(&self) -> bool {
68
+ self.cursor_positions.load(Ordering::Relaxed)
69
+ }
66
70
  pub fn geometry(&self) -> Option<StreamGeometry> {
67
71
  self.geometry.lock().ok().and_then(|g| g.clone())
68
72
  }
@@ -193,16 +197,29 @@ fn buffer_pod(buffer: &mut Vec<u8>) -> Result<()> {
193
197
  }
194
198
 
195
199
  /// Start capturing `session`'s node, delivering I420 frames to `sink`.
200
+ #[allow(clippy::too_many_arguments)]
196
201
  pub fn start(
197
202
  session: PortalSession,
198
203
  encoded_width: usize,
199
204
  encoded_height: usize,
200
205
  max_fps: u32,
206
+ cursor_sink: Option<crate::cursor::CursorSink>,
201
207
  sink: FrameSink,
202
208
  indicator_position: Option<Arc<Mutex<Option<(i64, i64)>>>>,
203
209
  on_stop: Box<dyn Fn(String) + Send>,
204
210
  ) -> Result<Capture> {
205
- let PortalSession { fd, source, .. } = session;
211
+ let PortalSession {
212
+ fd,
213
+ source,
214
+ cursor_mode,
215
+ ..
216
+ } = session;
217
+ let cursor_positions = Arc::new(AtomicBool::new(false));
218
+ let cursor_sink = if cursor_mode == "metadata" {
219
+ cursor_sink
220
+ } else {
221
+ None
222
+ };
206
223
  let geometry = Arc::new(Mutex::new(None));
207
224
  let frames = Arc::new(AtomicU64::new(0));
208
225
  let dropped = Arc::new(AtomicU64::new(0));
@@ -210,7 +227,9 @@ pub fn start(
210
227
  let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
211
228
 
212
229
  let node_id = source.node_id;
230
+ let source_size = (source.width.max(1) as usize, source.height.max(1) as usize);
213
231
  let thread = {
232
+ let cursor_positions = cursor_positions.clone();
214
233
  let geometry = geometry.clone();
215
234
  let frames = frames.clone();
216
235
  let dropped = dropped.clone();
@@ -221,9 +240,12 @@ pub fn start(
221
240
  let result = run_loop(
222
241
  fd,
223
242
  node_id,
243
+ source_size,
224
244
  encoded_width,
225
245
  encoded_height,
226
246
  max_fps,
247
+ cursor_sink,
248
+ cursor_positions,
227
249
  sink,
228
250
  indicator_position,
229
251
  on_stop,
@@ -250,6 +272,7 @@ pub fn start(
250
272
  quit,
251
273
  thread: Some(thread),
252
274
  source,
275
+ cursor_positions,
253
276
  geometry,
254
277
  frames,
255
278
  dropped,
@@ -268,9 +291,12 @@ pub fn start(
268
291
  fn run_loop(
269
292
  fd: OwnedFd,
270
293
  node_id: u32,
294
+ source_size: (usize, usize),
271
295
  encoded_width: usize,
272
296
  encoded_height: usize,
273
297
  max_fps: u32,
298
+ cursor_sink: Option<crate::cursor::CursorSink>,
299
+ cursor_positions: Arc<AtomicBool>,
274
300
  sink: FrameSink,
275
301
  indicator_position: Option<Arc<Mutex<Option<(i64, i64)>>>>,
276
302
  on_stop: Box<dyn Fn(String) + Send>,
@@ -302,6 +328,8 @@ fn run_loop(
302
328
  let stream = pw::stream::StreamBox::new(&core, "desklink-capture", props)
303
329
  .context("pw_stream_new failed")?;
304
330
 
331
+ let wants_cursor_meta = cursor_sink.is_some();
332
+ let mut cursor = cursor_sink.map(crate::cursor::Reporter::new);
305
333
  let mut state = StreamState {
306
334
  box_w: encoded_width,
307
335
  box_h: encoded_height,
@@ -367,6 +395,31 @@ fn run_loop(
367
395
  let Some(mut buffer) = stream.dequeue_buffer() else {
368
396
  return;
369
397
  };
398
+ if let Some(cursor) = cursor.as_mut() {
399
+ if let Some(meta) = buffer.find_meta::<spa::buffer::meta::MetaCursor>() {
400
+ cursor_positions.store(true, Ordering::Relaxed);
401
+ // The wrapper's size check bounds the bitmap header;
402
+ // never follow an unchecked bitmap offset.
403
+ let bitmap = buffer
404
+ .find_meta::<CursorWithBitmap>()
405
+ .filter(|m| {
406
+ m.cursor.bitmap_offset as usize
407
+ == std::mem::size_of::<spa::sys::spa_meta_cursor>()
408
+ })
409
+ .map(|m| &m.bitmap);
410
+ if state.logical_w == 0 || state.logical_h == 0 {
411
+ return;
412
+ }
413
+ let mut position = *meta.as_raw();
414
+ position.position.x = (i64::from(position.position.x) * source_size.0 as i64)
415
+ .div_euclid(state.logical_w as i64)
416
+ as i32;
417
+ position.position.y = (i64::from(position.position.y) * source_size.1 as i64)
418
+ .div_euclid(state.logical_h as i64)
419
+ as i32;
420
+ cursor.update_spa(&position, bitmap, source_size.0, source_size.1);
421
+ }
422
+ }
370
423
  let Some(format) = state.format else { return };
371
424
  let datas = buffer.datas_mut();
372
425
  if datas.is_empty() {
@@ -463,10 +516,15 @@ fn run_loop(
463
516
  format_pod(&mut format_buf, max_fps)?;
464
517
  let mut buffer_buf = Vec::new();
465
518
  buffer_pod(&mut buffer_buf)?;
466
- let mut params = [
519
+ let mut cursor_buf = Vec::new();
520
+ cursor_pod(&mut cursor_buf)?;
521
+ let mut params = vec![
467
522
  Pod::from_bytes(&format_buf).context("invalid EnumFormat pod")?,
468
523
  Pod::from_bytes(&buffer_buf).context("invalid Buffers pod")?,
469
524
  ];
525
+ if wants_cursor_meta {
526
+ params.push(Pod::from_bytes(&cursor_buf).context("invalid Cursor Meta pod")?);
527
+ }
470
528
 
471
529
  stream
472
530
  .connect(
@@ -483,7 +541,8 @@ fn run_loop(
483
541
 
484
542
  // The portal composites layer-shell into its pixels. Keep the last clean
485
543
  // backing for the helper's bounded circle and edge strips before diff/encode;
486
- // 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.
487
546
  struct IndicatorMask {
488
547
  position: Arc<Mutex<Option<(i64, i64)>>>,
489
548
  clean: Option<Vec<u8>>,
@@ -533,6 +592,7 @@ impl IndicatorMask {
533
592
  )
534
593
  });
535
594
  let radius = (150 * width / source_w).max(55) as i64;
595
+ let hole = (4 * width / source_w) as i64;
536
596
  let edge = (20 * width / source_w)
537
597
  .max(8)
538
598
  .min(width / 2)
@@ -572,7 +632,7 @@ impl IndicatorMask {
572
632
  };
573
633
  (col as i64 - cx).pow(2) + (row as i64 - cy).pow(2) <= radius * radius
574
634
  });
575
- if (distance <= radius * radius || along) && distance > 16 {
635
+ if (distance <= radius * radius || along) && distance > hole * hole {
576
636
  let at = start + col * 4;
577
637
  raw[at..at + 4].copy_from_slice(&clean[at..at + 4]);
578
638
  }
@@ -658,6 +718,7 @@ mod tests {
658
718
  origin_x: 0,
659
719
  origin_y: 0,
660
720
  },
721
+ cursor_positions: Arc::new(AtomicBool::new(false)),
661
722
  geometry: Arc::new(Mutex::new(None)),
662
723
  frames: Arc::new(AtomicU64::new(0)),
663
724
  dropped: Arc::new(AtomicU64::new(0)),
@@ -678,3 +739,33 @@ mod tests {
678
739
  .expect("capture stopped and joined");
679
740
  }
680
741
  }
742
+
743
+ fn cursor_pod(buffer: &mut Vec<u8>) -> Result<()> {
744
+ use spa::pod::{object, property, Value};
745
+ let obj = object!(
746
+ spa::utils::SpaTypes::ObjectParamMeta,
747
+ spa::param::ParamType::Meta,
748
+ property!(
749
+ RawKey(spa::sys::SPA_PARAM_META_type),
750
+ Id,
751
+ RawKey(spa::sys::SPA_META_Cursor)
752
+ ),
753
+ property!(
754
+ RawKey(spa::sys::SPA_PARAM_META_size),
755
+ Int,
756
+ (std::mem::size_of::<CursorWithBitmap>() + 256 * 256 * 4) as i32
757
+ )
758
+ );
759
+ PodSerializer::serialize(Cursor::new(buffer), &Value::Object(obj))
760
+ .map(|_| ())
761
+ .map_err(|e| anyhow::anyhow!("failed to build Cursor Meta pod: {e}"))
762
+ }
763
+
764
+ #[repr(C)]
765
+ struct CursorWithBitmap {
766
+ cursor: spa::sys::spa_meta_cursor,
767
+ bitmap: spa::sys::spa_meta_bitmap,
768
+ }
769
+ impl spa::buffer::meta::Metadata for CursorWithBitmap {
770
+ const META_TYPE: u32 = spa::sys::SPA_META_Cursor;
771
+ }
@@ -0,0 +1,23 @@
1
+ //! Windows desktop capture is deferred; encoded sources never call this adapter.
2
+ use crate::convert::I420;
3
+ use crate::portal::PortalSession;
4
+ use anyhow::{bail, Result};
5
+
6
+ pub type FrameSink = Box<dyn Fn(I420, u64, Pixels) + Send + 'static>;
7
+ pub type Pixels<'a> = &'a mut dyn FnMut() -> Option<Vec<u8>>;
8
+ pub struct Capture;
9
+
10
+ pub fn unavailable_reason() -> &'static str {
11
+ "Windows display capture is not implemented in this build."
12
+ }
13
+
14
+ pub fn start(
15
+ _session: PortalSession,
16
+ _width: usize,
17
+ _height: usize,
18
+ _fps: u32,
19
+ _sink: FrameSink,
20
+ _status: Box<dyn Fn(bool, String) + Send>,
21
+ ) -> Result<Capture> {
22
+ bail!("{}", unavailable_reason())
23
+ }