@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/docs/PROTOCOL.md CHANGED
@@ -10,8 +10,7 @@ macOS engine uses the same v3 stdio/WebRTC session path, with native display
10
10
  capture, input and clipboard adapters. A Windows build with static libvpx
11
11
  (`DESKLINK_VPX_STATIC_DIR`) also serves consumer-fed H.264 encoded sources over
12
12
  the shared session path. Native desktop capture, input and clipboard are not
13
- implemented on Windows, and `session.open` refuses `agent_indicator` with
14
- `indicator-unavailable`. Without static libvpx, the Windows compile seam
13
+ implemented on Windows; optional agent feedback is unavailable there. Without static libvpx, the Windows compile seam
15
14
  reports encoding unavailable and refuses `serve`. The protocol contains no
16
15
  application concepts: no accounts, no chat, no machine ids, no pane ids.
17
16
 
@@ -96,16 +95,16 @@ dimensions, origin and scale), the current keyboard layout, console state, and
96
95
  Screen Recording/Accessibility grant states. A `display` source accepts an
97
96
  optional `display_id` from `capture.displays`; an absent ID selects the main
98
97
  display. Screen Recording consent is requested by `session.open` when capture
99
- is needed. Control may request Accessibility consent; if it remains unavailable,
100
- open fails with `input-unavailable`. TCC permissions attach to the responsible
101
- launching app (for example Terminal, iTerm, or the Node.js host), not to a
98
+ is needed. Input may request Accessibility consent; if it remains unavailable,
99
+ the session still opens and input actions are refused with `input-unavailable`.
100
+ TCC permissions attach to the responsible launching app (for example Terminal, iTerm, or the Node.js host), not to a
102
101
  separate DesklinkHost.app bundle. Enable that app in **System Settings → Privacy
103
102
  & Security → Screen & System Audio Recording** and **Accessibility**, then
104
103
  reconnect. No TCC state is pre-seeded or bypassed.
105
104
 
106
- macOS clipboard read/write is plain text and requires the session's `clipboard`
107
- permission. `restore_token` is not supported by this native backend; a new
108
- session may require fresh consent. The macOS engine requires a VP9-enabled build
105
+ macOS clipboard read/write is plain text and available to paired consumers.
106
+ `restore_token` is not supported by this native backend; a new session may
107
+ require fresh consent. The macOS engine requires a VP9-enabled build
109
108
  (`DESKLINK_VPX_STATIC_DIR`) for `serve`.
110
109
 
111
110
  ## Capabilities
@@ -186,12 +185,17 @@ desktop is visible here rather than silent. `capture.backends` lists what this
186
185
  build has, not what this machine can necessarily use.
187
186
 
188
187
  `input.unavailable_reason` and `input.grant` describe the portal/uinput path:
189
- without kernel input access a portal session may request `view`, but an open
190
- requesting `control` is refused. X display capture uses XTest instead and does
191
- not require that grant. The muxr host and WebSocket bridge report clipboard
192
- unavailable for an X display, regardless of the engine's Wayland clipboard
193
- probe. On Linux the engine never asks for privileges on its own; macOS may
194
- request Screen Recording or Accessibility consent at `session.open`.
188
+ without kernel input access a portal session opens and captures, but input
189
+ messages are refused with `input-unavailable`. X display capture uses XTest
190
+ instead and does not require that grant. The engine probes XTest on that named
191
+ display; if it is unavailable, capture and clipboard still open, and input
192
+ messages are refused with `input-unavailable`. X11 clipboard uses CLIPBOARD on the
193
+ session's named display; the WebSocket bridge reports it independently of the
194
+ engine's ambient Wayland clipboard probe. The engine's Linux `clipboard.read`
195
+ reports the Wayland session environment; `clipboard.write` additionally requires
196
+ `wl-copy`. A portal bridge preserves those flags even when Xwayland is reachable.
197
+ On Linux the engine never asks for
198
+ privileges on its own; macOS may request Screen Recording or Accessibility consent at `session.open`.
195
199
 
196
200
  `capabilities` is safe to call before any consent has been given and must not
197
201
  trigger a capture request.
@@ -203,7 +207,6 @@ trigger a capture request.
203
207
  "source": {"kind":"portal"}, // or {"kind":"x11","display":":99"}
204
208
  // macOS: {"kind":"display","display_id":123}
205
209
  // or {"kind":"encoded","codec":"h264","width":486,"height":1080}
206
- "permissions": ["view","control","clipboard"],
207
210
  "max_width": 3840, "max_height": 2160, // encode box (default); never upscales
208
211
  "bitrate_kbps": 0, // 0 (default): sized to the encoded surface
209
212
  "max_fps": 60,
@@ -223,7 +226,7 @@ Result:
223
226
  "source":{"kind":"monitor","width":2560,"height":1440,"origin":{"x":0,"y":0}},
224
227
  "geometry":{"source":{"width":2560,"height":1440},
225
228
  "encoded":{"width":2560,"height":1440},
226
- "origin":{"x":0,"y":0}}}
229
+ "origin":{"x":0,"y":0}},"agentIndicator":false}
227
230
  ```
228
231
 
229
232
  `source` selects the desktop. `portal` (or absent) asks the compositor for a
@@ -234,11 +237,12 @@ main display when omitted); `encoded` carries video the consumer encoded itself.
234
237
  The choice is normally the *consumer's* decision, because a client is not in a
235
238
  position to know which backend a machine can offer.
236
239
 
237
- `permissions` are the engine's authority for this session. `view` is required
238
- to open a session. The engine enforces the remaining scopes on every input and
239
- clipboard action and never infers them from a source, a peer or an SDP.
240
- `control` without a working input backend is refused at `session.open` with
241
- `error.code = "input-unavailable"` — the consumer must ask for `view` explicitly instead.
240
+ There are no per-session permissions: whoever holds the channel is paired and
241
+ gets all available view, input and clipboard access. The `permissions` field
242
+ from 0.3 clients is accepted and ignored, including unknown scope names.
243
+ Missing input or clipboard backends fail per action, not at open. The
244
+ `permission` refusal code is reserved for 0.3 peers and is no longer emitted by
245
+ this engine.
242
246
 
243
247
  `max_fps` defaults to 60 and is bounded to 1–60. It caps the encoded frame
244
248
  rate and X11 capture loop; portal capture offers it as the preferred PipeWire
@@ -246,14 +250,29 @@ frame rate, and macOS sets its ScreenCaptureKit stream minimum frame interval.
246
250
  A source may still supply fewer frames.
247
251
 
248
252
  `agent_indicator` is opt-in local feedback for an agent controller; desklink-axi
249
- sets it for control sessions, never for view-only or human phone control. It
250
- adds an eased pointer halo, click ripple, and typing pulse. On Wayland it uses
251
- a click-through layer-shell surface with an edge glow; its bounded area is
253
+ sets it for sessions it drives (except Windows); human phone control omits it. On
254
+ Linux it adds an eased arrow inside a halo that brightens on clicks and keys,
255
+ animating in and out, with light and dark keylines so it reads on any content.
256
+ On Wayland it uses a click-through layer-shell surface with an edge glow, drawn
257
+ at the highest announced output integer scale, capped at 4; its bounded area is
252
258
  masked before capture/diff (pixels behind the effect can remain briefly stale).
253
- On bare X11 it replaces the pointer sprite instead, which the X11 grab excludes:
254
- there is no edge glow on that path. macOS uses a nonactivating, click-through
255
- window excluded by the ScreenCaptureKit content filter. Closing the session
256
- fades and removes the indicator.
259
+ On bare X11 it is an override-redirect, click-through window that follows the
260
+ pointer, so it stays
261
+ visible over windows that set their own cursor and never replaces the cursor.
262
+ Its size follows `Xft.dpi` from 96–384 DPI, defaulting to 1x otherwise;
263
+ the shaped cue reveals outward from the tip and retracts inward, while a
264
+ compositing manager with alpha enables fading instead. The helper announces each
265
+ state before drawing it, and X11 capture restores just
266
+ the pixels the cue covers from the previous frame, so the halo's interior stays
267
+ live; changes under the arrow and ring show once the cue moves away. Click and
268
+ key feedback only recolour the cue, so a still cue never changes what is
269
+ restored. There is no edge glow on that path.
270
+ macOS adds an eased arrow and halo, click ripple, and typing pulse in a
271
+ nonactivating, click-through window excluded by the ScreenCaptureKit content
272
+ filter. Closing the session animates out and removes the indicator.
273
+ Indicator startup is best-effort on every platform; a missing helper or overlay
274
+ backend does not refuse the session. The open result reports `agentIndicator:
275
+ true` only when the helper started.
257
276
 
258
277
  `local_frames` keeps the latest pre-encode frame and its tile hashes for
259
278
  `session.frame` and `session.frame.changed` (see "Latest frame over the local
@@ -514,14 +533,14 @@ an explicit `cursor:"hidden"`. macOS and Windows refuse hidden capture with
514
533
  the engine cannot remove pixels the consumer already encoded. Unknown cursor
515
534
  options are `malformed`.
516
535
 
517
- `@desklink/host` exposes `openSession({permissions:['view'], cursor:'hidden'})`
536
+ `@desklink/host` exposes `openSession({cursor:'hidden'})`
518
537
  and typed `session.cursor` events through `onEvent`/`drainEvents`. The client
519
538
  capture request also accepts `cursor`, and AXI accepts `start --cursor hidden`.
520
539
 
521
540
  ## Point on the host desktop
522
541
 
523
542
  A local consumer can guide the person without taking control of their pointer
524
- or keyboard. `session.point` requires `view`, not `control`, and is independent
543
+ or keyboard. `session.point` requires an open session and is independent
525
544
  of `agent_indicator`. It draws one blue ring centered at `x,y`, with an optional
526
545
  label. A new point replaces the previous one and restarts its timer.
527
546
 
@@ -560,7 +579,7 @@ output, no origin is needed. Points map from the selected source to that output.
560
579
  An ambiguous or missing selected output returns `indicator-unavailable`.
561
580
  Compositors without layer-shell return `point_unsupported` with reason
562
581
  `layer_shell_unavailable` (including GNOME Mutter). Consumer-fed encoded sources return `operation`
563
- because they have no host desktop overlay target. Other refusals are `permission`, `session`,
582
+ because they have no host desktop overlay target. Other refusals are `session`,
564
583
  `coordinates`, `malformed`, and `indicator-unavailable` (including unavailable
565
584
  X Shape support). Unknown fields are refused. No control-channel or remote
566
585
  bridge forwarding is added; the consumer chooses when to request a local cue.
@@ -568,13 +587,13 @@ bridge forwarding is added; the consumer chooses when to request a local cue.
568
587
  `@desklink/host` exposes `EngineClient.point(sessionId, {x, y, label?, timeoutMs?})`
569
588
  and `EngineClient.point(sessionId, {clear: true})`. `@desklink/axi` exposes
570
589
  `desklink-axi point 400,300 --label "Here" --timeout 3000` and
571
- `desklink-axi point --clear`, including for a session started without `--control`.
590
+ `desklink-axi point --clear`.
572
591
 
573
592
  ## Input and clipboard: the session's control channel
574
593
 
575
594
  Input does **not** travel the local protocol. It rides the WebRTC data channel
576
595
  the engine creates for the session, which is inside the DTLS/SRTP session that
577
- was opened for one authorized grant: it inherits that session's identity and dies
596
+ was opened by a paired consumer: it inherits that session's identity and dies
578
597
  with it, so a client that kept a socket open cannot keep driving a session that
579
598
  was revoked, and the consumer's request path never carries a pointer move.
580
599
 
@@ -611,12 +630,12 @@ The engine answers on the same channel, and sends two things unprompted:
611
630
  {"kind":"revoked","reason":"…","code":"closed"} // closed, or a session.revoked code
612
631
  ```
613
632
 
614
- Refusal codes: `permission`, `session`, `input-replay`, `coordinates`,
615
- `text-too-large`, `text-unsupported`, `input-unavailable`, `operation`.
633
+ Refusal codes: `session`, `input-replay`, `coordinates`, `text-too-large`,
634
+ `text-unsupported`, `input-unavailable`, `clipboard-unsupported`, `operation`.
616
635
 
617
636
  `seq` is monotonic per session. A repeat or a lower value is refused with
618
637
  `input-replay` and **no side effect**, so a replayed message cannot move the
619
- pointer twice. Validation, permission and ordering checks all run before any
638
+ pointer twice. Validation and ordering checks all run before any
620
639
  physical effect.
621
640
 
622
641
  The engine holds its own pressed-key/button state. `close`, `release_all`,
@@ -647,8 +666,7 @@ WebRTC, congestion and teardown logic that is already proven on this path.
647
666
  it is the only party that knows them (`1..7680` by `1..4320`). Nothing is scaled:
648
667
  `geometry.source` and `geometry.encoded` are both that size, the client aims at
649
668
  those pixels, and the consumer maps them to its device. `max_width`/
650
- `max_height`/`bitrate_kbps` are ignored for this source, and `permissions` works
651
- as it does anywhere else — with one difference: `control` needs no local input
669
+ `max_height`/`bitrate_kbps` are ignored for this source. Input needs no local
652
670
  backend, because nothing is applied locally.
653
671
 
654
672
  `session.open` returns before any offer exists. The offer must name the profile
@@ -719,7 +737,7 @@ pixels, exactly as a client sends them.
719
737
  ```
720
738
 
721
739
  The event carries the control message as it arrived. The engine still validates
722
- permission, ordering and replay before forwarding, so the client keeps the same
740
+ ordering and replay before forwarding, so the client keeps the same
723
741
  `ack`/`rejected` behaviour and a replayed gesture still cannot be delivered
724
742
  twice. `ack` means "accepted for this session", not "applied to a desktop" —
725
743
  `session.metrics.input_forwarded` counts what was handed on, and `input_applied`
@@ -738,7 +756,7 @@ because packetization is the only stage the engine itself runs.
738
756
 
739
757
  ## Latest frame over the local protocol
740
758
 
741
- `session.frame` requires `view` permission. It writes the latest pre-encode frame as
759
+ `session.frame` writes the latest pre-encode frame as
742
760
  packed BGRX bytes to the caller's path, with optional `[x,y,w,h]` `region`.
743
761
  The directory must already exist and be private to the caller; pixels never
744
762
  travel through JSON. `seq` advances only when capture changes, `still_ms` is
@@ -775,9 +793,17 @@ instead; both reach the same clipboard:
775
793
  {"id":10,"result":{"written":true}}
776
794
  ```
777
795
 
778
- Refused with `error.code = "clipboard"` when the session lacks `clipboard`, or
779
- when the desktop's clipboard has no text. A read is bounded by
780
- `capabilities.clipboard.maxBytes`; when the desktop's text is longer, `truncated`
796
+ Refused with `error.code = "clipboard"` when the desktop has no text or a
797
+ clipboard operation fails. Encoded sources have no desktop clipboard; their
798
+ control channel answers with an empty clipboard reply and an explanatory error.
799
+ X11 uses a fresh connection to the session's selected display for each operation.
800
+ Writes verify CLIPBOARD ownership before succeeding and serve TARGETS,
801
+ UTF8_STRING and TEXT; reads request UTF8_STRING. Writes are limited to 256 KiB
802
+ and the server's single request limit; the text lives until another client takes ownership, the engine
803
+ exits, or the display dies. Reads wait at most 2.5 seconds for the owner.
804
+ INCR transfers are refused explicitly as unsupported, not reported as success;
805
+ STRING, PRIMARY and persistence after engine exit are not implemented.
806
+ A read is bounded by `capabilities.clipboard.maxBytes`; when the desktop's text is longer, `truncated`
781
807
  is true and the text is cut on a character boundary, so the returned text is
782
808
  still valid.
783
809
 
@@ -853,8 +879,9 @@ anything to show.
853
879
 
854
880
  ## What the engine deliberately does not do
855
881
 
856
- - It does not decide *who* the user is. It trusts the consumer's local
857
- permission decision and enforces the resulting scope; there is no second
882
+ - It does not decide *who* the user is. Whoever holds stdio (or an authenticated
883
+ bridge socket) is paired; pairing, identity and revocation are the consumer's.
884
+ Revocation must end both live sessions and future access. There is no second
858
885
  account, token or pairing ceremony. For consumer integration, see the
859
886
  [BYOKit guidance](../../../README.md#why-desklink-exists).
860
887
  - It does not open ports, register a service, install udev rules, join groups or
package/engine/Cargo.lock CHANGED
@@ -536,7 +536,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
536
536
 
537
537
  [[package]]
538
538
  name = "desklink-host"
539
- version = "0.3.1"
539
+ version = "0.4.0"
540
540
  dependencies = [
541
541
  "anyhow",
542
542
  "ashpd",
package/engine/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "desklink-host"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  rust-version = "1.97.1"
6
6
  license = "Apache-2.0"
package/engine/build.rs CHANGED
@@ -38,6 +38,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
38
38
  println!("cargo:rerun-if-changed=native/mac_stream.mm");
39
39
  println!("cargo:rerun-if-changed=native/agent_overlay_mac.m");
40
40
  println!("cargo:rerun-if-changed=native/agent_overlay_x11.c");
41
+ println!("cargo:rerun-if-changed=native/agent_indicator.h");
41
42
  println!("cargo:rerun-if-changed=native/agent_overlay_wayland.c");
42
43
  println!("cargo:rerun-if-changed=Info.plist");
43
44
  println!("cargo:rerun-if-changed=vendor/inputtino/src/uinput/include/inputtino/keyboard.hpp");
@@ -0,0 +1,57 @@
1
+ // Shared Linux agent cue, in logical pixels. The arrow follows the Mac outline;
2
+ // light and dark keylines keep it visible on both bright and dark desktops.
3
+ #ifndef DESKLINK_AGENT_INDICATOR_H
4
+ #define DESKLINK_AGENT_INDICATOR_H
5
+ #include <math.h>
6
+ #include <stdint.h>
7
+
8
+ static double indicator_segment(double x, double y, double ax, double ay, double bx, double by) {
9
+ double dx=bx-ax, dy=by-ay;
10
+ double t=fmax(0,fmin(1,((x-ax)*dx+(y-ay)*dy)/(dx*dx+dy*dy)));
11
+ return hypot(x-ax-t*dx,y-ay-t*dy);
12
+ }
13
+ static uint32_t indicator_over(uint32_t dst, double coverage, unsigned rgb) {
14
+ unsigned a=(unsigned)(255*fmax(0,fmin(1,coverage))), inverse=255-a;
15
+ unsigned da=dst>>24, r=(dst>>16)&255, g=(dst>>8)&255, b=dst&255;
16
+ return ((a+da*inverse/255)<<24) |
17
+ ((((rgb>>16)&255)*a/255+r*inverse/255)<<16) |
18
+ ((((rgb>>8)&255)*a/255+g*inverse/255)<<8) |
19
+ ((rgb&255)*a/255+b*inverse/255);
20
+ }
21
+ // Recolour without changing coverage, so feedback on a still cue never
22
+ // changes which pixels capture has to restore.
23
+ static uint32_t indicator_tint(uint32_t c, double amount, unsigned rgb) {
24
+ double keep=1-fmax(0,fmin(1,amount)), a=(c>>24)/255.0;
25
+ return (c&0xff000000) |
26
+ ((unsigned)(((c>>16)&255)*keep+((rgb>>16)&255)*a*(1-keep))<<16) |
27
+ ((unsigned)(((c>>8)&255)*keep+((rgb>>8)&255)*a*(1-keep))<<8) |
28
+ (unsigned)((c&255)*keep+(rgb&255)*a*(1-keep));
29
+ }
30
+ static uint32_t indicator_pixel(double x, double y, double opacity, double click, double typed) {
31
+ double radius=hypot(x,y);
32
+ uint32_t color=0;
33
+ // 62 logical px halo: a blue band, wide white keyline and dark rim, so the
34
+ // ring keeps >=2px of contrast after a 2:1 downscale. Inside the band only
35
+ // the arrow is opaque, so capture can keep the click area live.
36
+ color=indicator_over(color,fmax(0,fmin(1,2.5-fabs(radius-28.5)))*opacity,0x123047);
37
+ color=indicator_over(color,fmax(0,fmin(1,2.5-fabs(radius-24)))*opacity,0xffffff);
38
+ color=indicator_over(color,fmax(0,fmin(1,1.5-fabs(radius-20)))*0.95*opacity,0x2999ff);
39
+ static const double arrow[][2]={{0,0},{2,24},{8,18},{15,31},{21,28},{14,15},{24,14}};
40
+ double distance=1000; int inside=0;
41
+ for(int i=0,j=6;i<7;j=i++) {
42
+ double ax=arrow[j][0],ay=arrow[j][1],bx=arrow[i][0],by=arrow[i][1];
43
+ distance=fmin(distance,indicator_segment(x,y,ax,ay,bx,by));
44
+ if((ay>y)!=(by>y) && x<(bx-ax)*(y-ay)/(by-ay)+ax)inside=!inside;
45
+ }
46
+ double signed_distance=inside?-distance:distance;
47
+ color=indicator_over(color,fmax(0,fmin(1,3-signed_distance))*opacity,0x123047);
48
+ color=indicator_over(color,fmax(0,fmin(1,2-signed_distance))*opacity,0xffffff);
49
+ color=indicator_over(color,fmax(0,fmin(1,-signed_distance))*opacity,0x2999ff);
50
+ // A click sweeps a bright wave out through the cue; a key flashes it.
51
+ if(click>=0 && click<1)
52
+ color=indicator_tint(color,fmax(0,1-fabs(radius-(4+32*click))/5)*0.9*(1-click),0x9fdcff);
53
+ if(typed>=0 && typed<1)
54
+ color=indicator_tint(color,0.6*(1-typed),0xc8ecff);
55
+ return color;
56
+ }
57
+ #endif
@@ -1,5 +1,5 @@
1
1
  // Minimal wlroots layer-shell surface: transparent, unfocusable and input-empty.
2
- // Only the portal path uses it; X11 uses a cursor sprite instead.
2
+ // Portal capture masking is implemented by capture.rs::IndicatorMask.
3
3
  // Protocol interface names/signatures from wlr-layer-shell-unstable-v1:
4
4
  // Copyright (c) 2017 Drew DeVault. Permission is hereby granted to use, copy,
5
5
  // modify and distribute this protocol for any purpose without fee, provided
@@ -14,6 +14,7 @@
14
14
  #include <unistd.h>
15
15
  #include <fcntl.h>
16
16
  #include <math.h>
17
+ #include "agent_indicator.h"
17
18
  #include <stdio.h>
18
19
  #include <stdlib.h>
19
20
  #include <string.h>
@@ -63,7 +64,13 @@ static const struct wl_interface xdg_manager_interface={"zxdg_output_manager_v1"
63
64
  static const struct wl_message output_requests[]={{"destroy","",NULL}};
64
65
  static const struct wl_message output_events[]={{"logical_position","ii",NULL},{"logical_size","ii",NULL},{"done","",NULL},{"name","2s",NULL},{"description","2s",NULL}};
65
66
  static const struct wl_interface xdg_output_interface={"zxdg_output_v1",2,1,output_requests,5,output_events};
66
- struct Output { struct wl_output *handle; struct wl_proxy *logical; int x,y,w,h,valid; };
67
+ struct Output { struct wl_output *handle; struct wl_proxy *logical; int x,y,w,h,valid,scale; };
68
+ static void output_geometry(void *data,struct wl_output *p,int32_t x,int32_t y,int32_t pw,int32_t ph,int32_t sub,const char *make,const char *model,int32_t transform)
69
+ {(void)data;(void)p;(void)x;(void)y;(void)pw;(void)ph;(void)sub;(void)make;(void)model;(void)transform;}
70
+ static void output_mode(void *data,struct wl_output *p,uint32_t flags,int32_t w,int32_t h,int32_t refresh){(void)data;(void)p;(void)flags;(void)w;(void)h;(void)refresh;}
71
+ static void output_done(void *data,struct wl_output *p){(void)data;(void)p;}
72
+ static void output_scale(void *data,struct wl_output *p,int32_t scale){(void)p;((struct Output *)data)->scale=scale;}
73
+ static const struct wl_output_listener output_listener={.geometry=output_geometry,.mode=output_mode,.done=output_done,.scale=output_scale};
67
74
  static void logical_position(void *data, struct wl_proxy *p,int32_t x,int32_t y){(void)p;struct Output *o=data;o->x=x;o->y=y;o->valid=1;}
68
75
  static void logical_size(void *data,struct wl_proxy *p,int32_t w,int32_t h){(void)p;struct Output *o=data;o->w=w;o->h=h;}
69
76
  static void logical_done(void *data,struct wl_proxy *p){(void)data;(void)p;}
@@ -89,14 +96,16 @@ struct Overlay {
89
96
  struct Output outputs[32]; int output_count;
90
97
  struct wl_surface *surface;
91
98
  struct Buffer buffers[2];
92
- int width, height, source_w, source_h, configured, closed;
99
+ int width, height, scale, source_w, source_h, configured, closed;
93
100
  };
94
101
  static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC,&t); return t.tv_sec+t.tv_nsec/1e9; }
95
102
  static void registry_global(void *data, struct wl_registry *registry, uint32_t name, const char *interface, uint32_t version) {
96
103
  struct Overlay *o=data;
97
104
  if (!strcmp(interface,"wl_output") && o->output_count<32) {
98
105
  struct Output *out=&o->outputs[o->output_count++];
99
- out->handle=wl_registry_bind(registry,name,&wl_output_interface,1);
106
+ out->handle=wl_registry_bind(registry,name,&wl_output_interface,version<2?version:2);
107
+ out->scale=1;
108
+ wl_output_add_listener(out->handle,&output_listener,out);
100
109
  }
101
110
  if (!strcmp(interface,"zxdg_output_manager_v1")) o->output_manager=wl_registry_bind(registry,name,&xdg_manager_interface,version<2?version:2);
102
111
  if (!strcmp(interface,"wl_compositor")) o->compositor=wl_registry_bind(registry,name,&wl_compositor_interface,version<4?version:4);
@@ -119,13 +128,14 @@ static void buffer_release(void *data, struct wl_buffer *buffer) { (void)buffer;
119
128
  static const struct wl_buffer_listener buffer_listener={buffer_release};
120
129
 
121
130
  static int create_buffer(struct Overlay *o, struct Buffer *b) {
122
- b->length=(size_t)o->width*o->height*4;
131
+ int w=o->width*o->scale,h=o->height*o->scale;
132
+ b->length=(size_t)w*h*4;
123
133
  int fd=memfd_create("desklink-agent-overlay",MFD_CLOEXEC);
124
134
  if (fd<0 || ftruncate(fd,b->length)) { if(fd>=0)close(fd); return -1; }
125
135
  b->pixels=mmap(NULL,b->length,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
126
136
  if (b->pixels==MAP_FAILED) {close(fd);return -1;}
127
137
  struct wl_shm_pool *pool=wl_shm_create_pool(o->shm,fd,(int)b->length);
128
- b->handle=wl_shm_pool_create_buffer(pool,0,o->width,o->height,o->width*4,WL_SHM_FORMAT_ARGB8888);
138
+ b->handle=wl_shm_pool_create_buffer(pool,0,w,h,w*4,WL_SHM_FORMAT_ARGB8888);
129
139
  wl_shm_pool_destroy(pool); close(fd);
130
140
  wl_buffer_add_listener(b->handle,&buffer_listener,b);
131
141
  return 0;
@@ -134,12 +144,15 @@ static void blend(uint32_t *pixel, unsigned alpha, unsigned rgb) {
134
144
  // premultiplied ARGB8888: the compositor blends this over the desktop.
135
145
  *pixel=(alpha<<24)|(((rgb>>16)&255)*alpha/255<<16)|(((rgb>>8)&255)*alpha/255<<8)|((rgb&255)*alpha/255);
136
146
  }
137
- static void draw(struct Overlay *o, struct Buffer *b, double x, double y, double click, double typed, double ending, int positioned) {
138
- double t=now(), opacity=ending?fmax(0,1-(t-ending)/0.6):1;
147
+ static void draw(struct Overlay *o, struct Buffer *b, double x, double y, double click, double typed, double ending, double started, double cue_started) {
148
+ double t=now(), opacity=ending?fmax(0,1-(t-ending)/0.6):fmin(1,(t-started)/0.18);
149
+ // The cue fades in from its first position, not from the session's start.
150
+ double cue=ending?opacity:fmin(1,(t-cue_started)/0.18);
139
151
  memset(b->pixels,0,b->length);
140
- int w=o->width,h=o->height;
141
- for (int edge=0;edge<7;edge++) {
142
- unsigned alpha=(unsigned)((7-edge)*1.8*opacity);
152
+ // Draw in buffer pixels; the cue itself is specified in logical pixels.
153
+ int s=o->scale,w=o->width*s,h=o->height*s;x*=s;y*=s;
154
+ for (int edge=0;edge<7*s;edge++) {
155
+ unsigned alpha=(unsigned)((7-(double)edge/s)*1.8*opacity);
143
156
  for(int px=edge;px<w-edge;px++) {
144
157
  blend(&b->pixels[(size_t)edge*w+px],alpha,0x4c9ed0);
145
158
  blend(&b->pixels[(size_t)(h-1-edge)*w+px],alpha,0x4c9ed0);
@@ -149,16 +162,17 @@ static void draw(struct Overlay *o, struct Buffer *b, double x, double y, double
149
162
  blend(&b->pixels[(size_t)py*w+w-1-edge],alpha,0x4c9ed0);
150
163
  }
151
164
  }
152
- int left=fmax(0,x-55),right=fmin(w,x+55),top=fmax(0,y-55),bottom=fmin(h,y+55);
153
- if(positioned)for(int py=top;py<bottom;py++)for(int px=left;px<right;px++) {
154
- double distance=hypot(px-x,py-y);
155
- double ring=fmax(0,1-fabs(distance-13)/5)*0.18;
156
- ring+=fmax(0,1-fabs(distance-23)/7)*0.06;
157
- if(click && t-click<0.55)ring+=fmax(0,1-fabs(distance-(12+37*(t-click)/0.55))/3)*0.42*(1-(t-click)/0.55);
158
- if(typed && t-typed<0.4)
159
- ring+=fmax(0,1-fabs(distance-(8+15*(t-typed)/0.4))/3)*0.22*(1-(t-typed)/0.4);
160
- unsigned alpha=(unsigned)(fmin(1,ring*opacity)*255);
161
- if(alpha)blend(&b->pixels[(size_t)py*w+px],alpha,0x4c9ed0);
165
+ int left=fmax(0,x-40*s),right=fmin(w,x+40*s),top=fmax(0,y-40*s),bottom=fmin(h,y+40*s);
166
+ if(cue_started)for(int py=top;py<bottom;py++)for(int px=left;px<right;px++) {
167
+ // Capture keeps the pointer's own pixels live (IndicatorMask), so the
168
+ // tip stays clear; the real cursor sits there anyway.
169
+ if(hypot((px-x)/s,(py-y)/s)<6)continue;
170
+ uint32_t src=indicator_pixel((px-x)/s,(py-y)/s,cue,
171
+ click?(t-click)/0.55:-1,typed?(t-typed)/0.4:-1), *dst=&b->pixels[(size_t)py*w+px];
172
+ // Premultiplied source-over, keeping the edge glow under the cue.
173
+ unsigned inverse=255-(src>>24);
174
+ if(src)*dst=src+((((*dst>>24)&255)*inverse/255)<<24)+((((*dst>>16)&255)*inverse/255)<<16)
175
+ +((((*dst>>8)&255)*inverse/255)<<8)+((*dst&255)*inverse/255);
162
176
  }
163
177
  }
164
178
 
@@ -202,7 +216,7 @@ static void draw_point(struct Overlay *o,struct Buffer *b,double x,double y,cons
202
216
 
203
217
  static int overlay_main(int source_w,int source_h,int point_mode,int origin_known,int origin_x,int origin_y) {
204
218
 
205
- struct Overlay o={.source_w=source_w,.source_h=source_h};
219
+ struct Overlay o={.source_w=source_w,.source_h=source_h,.scale=1};
206
220
  o.display=wl_display_connect(NULL);
207
221
  if(!o.display)return 2;
208
222
  struct wl_registry *registry=wl_display_get_registry(o.display);
@@ -228,6 +242,11 @@ static int overlay_main(int source_w,int source_h,int point_mode,int origin_know
228
242
  if(!selected && o.output_count==1 && (!origin_known || !o.output_manager))selected=o.outputs[0].handle;
229
243
  if(!selected){puts("selected_output_unavailable");fflush(stdout);wl_display_disconnect(o.display);return 2;}
230
244
  }
245
+ if(!point_mode && wl_proxy_get_version((struct wl_proxy *)o.compositor)>=3) {
246
+ // Render the cue at the densest output's integer scale so it stays crisp on HiDPI.
247
+ if(wl_display_roundtrip(o.display)<0)return 2;
248
+ for(int i=0;i<o.output_count;i++)if(o.outputs[i].scale>o.scale)o.scale=o.outputs[i].scale<4?o.outputs[i].scale:4;
249
+ }
231
250
  FT_Library library=NULL;FT_Face font=point_mode?point_font(&library):NULL;
232
251
  if(point_mode&&!font){wl_display_disconnect(o.display);return 2;}
233
252
  o.surface=wl_compositor_create_surface(o.compositor);
@@ -244,13 +263,14 @@ static int overlay_main(int source_w,int source_h,int point_mode,int origin_know
244
263
  wl_surface_set_input_region(o.surface,empty);
245
264
  wl_region_destroy(empty);
246
265
  }
266
+ if(o.scale>1)wl_surface_set_buffer_scale(o.surface,o.scale);
247
267
  wl_surface_commit(o.surface);
248
268
  while(!o.configured&&!o.closed&&wl_display_dispatch(o.display)>=0) {}
249
269
  if(o.closed||o.width<=0||o.height<=0){wl_display_disconnect(o.display);return 2;}
250
270
  if(create_buffer(&o,&o.buffers[0])||create_buffer(&o,&o.buffers[1])){wl_display_disconnect(o.display);return 2;}
251
271
  int flags=fcntl(STDIN_FILENO,F_GETFL);fcntl(STDIN_FILENO,F_SETFL,flags|O_NONBLOCK);
252
272
  puts("READY");fflush(stdout);
253
- double tx=0,ty=0,x=0,y=0,click=0,typed=0,ending=0;
273
+ double tx=0,ty=0,x=0,y=0,click=0,typed=0,ending=0,started=0,cue_started=0;
254
274
  int active=0,positioned=0;
255
275
  double deadline=0;char label[97]={0};int dirty=0,visible=0;
256
276
  char pending[512];size_t used=0;
@@ -277,8 +297,9 @@ static int overlay_main(int source_w,int source_h,int point_mode,int origin_know
277
297
  else if(kind=='M'||kind=='C'||kind=='T'||kind=='A') {
278
298
  if(px>=0&&py>=0) {
279
299
  if(desklink_wayland_geometry(o.source_w,o.source_h,o.width,o.height,px,py,&tx,&ty)
280
- && !positioned){x=tx;y=ty;positioned=1;}
300
+ && !positioned){x=tx;y=ty;positioned=1;cue_started=now();}
281
301
  }
302
+ if(!active)started=now();
282
303
  active=1;
283
304
  if(kind=='C')click=now();
284
305
  if(kind=='T')typed=now();
@@ -292,11 +313,11 @@ static int overlay_main(int source_w,int source_h,int point_mode,int origin_know
292
313
  if(!point_mode){x+=(tx-x)*0.24;y+=(ty-y)*0.24;}
293
314
  for(int i=0;i<2;i++)if(!o.buffers[i].busy) {
294
315
  if(point_mode)draw_point(&o,&o.buffers[i],x,y,label,font,visible);
295
- else draw(&o,&o.buffers[i],x,y,click,typed,ending,positioned);
316
+ else draw(&o,&o.buffers[i],x,y,click,typed,ending,started,cue_started);
296
317
  dirty=0;
297
318
  o.buffers[i].busy=1;
298
319
  wl_surface_attach(o.surface,o.buffers[i].handle,0,0);
299
- wl_surface_damage_buffer(o.surface,0,0,o.width,o.height);
320
+ wl_surface_damage_buffer(o.surface,0,0,o.width*o.scale,o.height*o.scale);
300
321
  wl_surface_commit(o.surface);
301
322
  break;
302
323
  }