@desklink/host 0.2.0 → 0.3.1

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 (82) hide show
  1. package/NOTICE +26 -0
  2. package/README.md +134 -56
  3. package/bin/desklink-host.mjs +13 -5
  4. package/dist/bridge.d.ts +1 -0
  5. package/dist/bridge.d.ts.map +1 -1
  6. package/dist/bridge.js +12 -4
  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 +35 -4
  11. package/dist/engineProcess.js.map +1 -1
  12. package/dist/protocol.d.ts +58 -0
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/protocol.js.map +1 -1
  15. package/dist/resolveEngine.d.ts +7 -1
  16. package/dist/resolveEngine.d.ts.map +1 -1
  17. package/dist/resolveEngine.js +66 -14
  18. package/dist/resolveEngine.js.map +1 -1
  19. package/docs/BOUNDARY.md +15 -7
  20. package/docs/PROTOCOL.md +267 -27
  21. package/engine/Cargo.lock +47 -2
  22. package/engine/Cargo.toml +18 -13
  23. package/engine/build.rs +86 -4
  24. package/engine/native/agent_overlay_mac.m +51 -4
  25. package/engine/native/agent_overlay_wayland.c +145 -12
  26. package/engine/native/agent_overlay_x11.c +72 -0
  27. package/engine/native/nvenc_shim.c +453 -0
  28. package/engine/native/vaapi_shim.c +727 -0
  29. package/engine/native/vpx_shim.c +54 -4
  30. package/engine/native/vt_shim.c +448 -0
  31. package/engine/src/capture.rs +167 -58
  32. package/engine/src/capture_mac.rs +24 -14
  33. package/engine/src/capture_win.rs +23 -0
  34. package/engine/src/clipboard_win.rs +8 -0
  35. package/engine/src/convert.rs +286 -61
  36. package/engine/src/cursor.rs +118 -0
  37. package/engine/src/encoder.rs +122 -13
  38. package/engine/src/h264.rs +188 -0
  39. package/engine/src/h264_nvenc.rs +308 -0
  40. package/engine/src/h264_openh264.rs +735 -0
  41. package/engine/src/h264_vaapi.rs +263 -0
  42. package/engine/src/h264_vt.rs +368 -0
  43. package/engine/src/indicator.rs +104 -3
  44. package/engine/src/input_win.rs +91 -0
  45. package/engine/src/keymap_win.rs +39 -0
  46. package/engine/src/mac.rs +34 -9
  47. package/engine/src/main.rs +186 -26
  48. package/engine/src/peer.rs +466 -28
  49. package/engine/src/portal.rs +42 -4
  50. package/engine/src/portal_win.rs +37 -0
  51. package/engine/src/protocol.rs +64 -2
  52. package/engine/src/session.rs +1252 -105
  53. package/engine/src/win.rs +110 -0
  54. package/engine/src/x11.rs +225 -9
  55. package/engine/src/x11_mac.rs +12 -0
  56. package/engine/vendor/libva/COPYING +19 -0
  57. package/engine/vendor/libva/va/va.h +5450 -0
  58. package/engine/vendor/libva/va/va_dec_av1.h +695 -0
  59. package/engine/vendor/libva/va/va_dec_hevc.h +616 -0
  60. package/engine/vendor/libva/va/va_dec_jpeg.h +157 -0
  61. package/engine/vendor/libva/va/va_dec_vp8.h +249 -0
  62. package/engine/vendor/libva/va/va_dec_vp9.h +312 -0
  63. package/engine/vendor/libva/va/va_dec_vvc.h +688 -0
  64. package/engine/vendor/libva/va/va_drm.h +61 -0
  65. package/engine/vendor/libva/va/va_enc_av1.h +1010 -0
  66. package/engine/vendor/libva/va/va_enc_h264.h +659 -0
  67. package/engine/vendor/libva/va/va_enc_hevc.h +958 -0
  68. package/engine/vendor/libva/va/va_enc_jpeg.h +162 -0
  69. package/engine/vendor/libva/va/va_enc_mpeg2.h +308 -0
  70. package/engine/vendor/libva/va/va_enc_vp8.h +350 -0
  71. package/engine/vendor/libva/va/va_enc_vp9.h +639 -0
  72. package/engine/vendor/libva/va/va_fei.h +170 -0
  73. package/engine/vendor/libva/va/va_fei_h264.h +505 -0
  74. package/engine/vendor/libva/va/va_fei_hevc.h +254 -0
  75. package/engine/vendor/libva/va/va_prot.h +525 -0
  76. package/engine/vendor/libva/va/va_version.h +87 -0
  77. package/engine/vendor/libva/va/va_vpp.h +1590 -0
  78. package/engine/vendor/nv-codec-headers/LICENSE +24 -0
  79. package/engine/vendor/nv-codec-headers/README +7 -0
  80. package/engine/vendor/nv-codec-headers/include/ffnvcodec/nvEncodeAPI.h +3825 -0
  81. package/examples/reference-client.html +1 -1
  82. package/package.json +4 -3
package/docs/PROTOCOL.md CHANGED
@@ -5,10 +5,15 @@ the user's session starts it and talks to it over an inherited private channel
5
5
  the engine never opens a public listener, never runs as root, and never installs
6
6
  a service.
7
7
 
8
- This document describes the shared Linux and macOS engine protocol. The macOS
9
- engine uses the same v3 stdio/WebRTC session path, with native display capture,
10
- input and clipboard adapters. The protocol contains no application concepts:
11
- no accounts, no chat, no machine ids, no pane ids.
8
+ This document describes the Linux, macOS, and Windows engine protocol. The
9
+ macOS engine uses the same v3 stdio/WebRTC session path, with native display
10
+ capture, input and clipboard adapters. A Windows build with static libvpx
11
+ (`DESKLINK_VPX_STATIC_DIR`) also serves consumer-fed H.264 encoded sources over
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
15
+ reports encoding unavailable and refuses `serve`. The protocol contains no
16
+ application concepts: no accounts, no chat, no machine ids, no pane ids.
12
17
 
13
18
  ## Starting the engine
14
19
 
@@ -66,7 +71,7 @@ Request parameters use the engine's own snake_case names (`session_id`,
66
71
  Result:
67
72
 
68
73
  ```jsonc
69
- {"protocol":3,"engine":"desklink-host/0.2.0","platform":"linux", /* the rest of Capabilities */ }
74
+ {"protocol":3,"engine":"desklink-host/0.3.0","platform":"linux", /* the rest of Capabilities */ }
70
75
  ```
71
76
 
72
77
  The consumer must send `hello` first, and a version the engine does not speak is
@@ -76,8 +81,12 @@ arrives, every other request is refused the same way; `capabilities` and
76
81
 
77
82
  ## macOS
78
83
 
84
+ For engine installation and the macOS resolution opt-out, see the
85
+ [host installation guide](../README.md#macos-preview).
86
+
79
87
  macOS speaks protocol 3 and shares the session lifecycle below. It captures the
80
- selected display with ScreenCaptureKit, encodes VP9, and uses the same WebRTC
88
+ selected display with ScreenCaptureKit, encodes VP9 (and H.264 through
89
+ VideoToolbox, for a receiver that asks for it), and uses the same WebRTC
81
90
  offer, ICE, control-channel, lease, and metrics flow as Linux. Capture currently
82
91
  uses one bounded ScreenCaptureKit stream (three-frame queue); the backend may
83
92
  supply fewer frames. Audio is not captured.
@@ -108,7 +117,7 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
108
117
  ```jsonc
109
118
  {
110
119
  "protocol": 3,
111
- "engine": "desklink-host/0.2.0",
120
+ "engine": "desklink-host/0.3.0",
112
121
  "platform": "linux",
113
122
  "session": {"kind": "wayland"},
114
123
  "x11": {"available": true, "size": [2560, 1440]},
@@ -117,9 +126,12 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
117
126
  "backends": ["portal-screencast+pipewire", "x11-root"],
118
127
  "formats": ["bgrx", "bgra", "rgbx", "rgba"],
119
128
  "cursor": "embedded",
129
+ "cursor_modes": ["embedded", "hidden"],
130
+ "cursor_positions": {"x11": true, "portal": "negotiated"},
120
131
  "audio": false
121
132
  },
122
- "encode": {"codecs": ["vp9"], "hardware": false},
133
+ "encode": {"codecs": ["vp9", "h264"], "hardware": false,
134
+ "h264": {"encoder": "vaapi", "hardware": true}},
123
135
  "input": {
124
136
  "mechanism": "inputtino/uinput",
125
137
  "pointer": true, "wheel": true, "keyboard": true,
@@ -133,6 +145,32 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
133
145
  }
134
146
  ```
135
147
 
148
+ `encode` describes what the engine codes from a captured desktop. VP9 (libvpx,
149
+ software; `hardware` describes it) is always there and is the default. `h264`
150
+ appears in `codecs` when an H.264 encoder starts on this machine right now, and
151
+ `encode.h264.encoder` names it: `videotoolbox` on macOS; on Linux `nvenc` or
152
+ `vaapi` when the GPU has an H.264 encoder, else `openh264`, Cisco's prebuilt
153
+ library. `encoder` is `null` when there is none. The answer to the offer picks
154
+ between them (see *Codecs* below). Windows with static libvpx reports VP9 in
155
+ `encode.codecs`, but has no native desktop capture; without static libvpx its
156
+ `encode.codecs` is empty. On Windows, `encoded.codecs` reports H.264 only when
157
+ static libvpx is linked, for consumer-fed sources; it does not mean the engine
158
+ can encode H.264 itself.
159
+
160
+ openh264 is never part of this package: the engine loads only Cisco's own
161
+ binary, checked against its published SHA-256, from
162
+ `$XDG_CACHE_HOME/desklink/openh264/` (else `~/.cache/desklink/openh264/`). When a
163
+ Linux machine has no hardware H.264 encoder and the library is not there yet,
164
+ the first captured `session.open` downloads it from Cisco
165
+ (`ciscobinary.openh264.org`) before making its offer; that session is offered
166
+ VP9 alone if the download fails. `capabilities` itself never downloads.
167
+ `DESKLINK_OPENH264=off` in the engine's environment disables openh264 (no
168
+ download, no load); `DESKLINK_OPENH264=<path>` loads that file instead, still
169
+ only if its hash is Cisco's. `DESKLINK_H264_ENCODER=<name>` restricts the engine
170
+ to one H.264 encoder (`off` for none), for measuring one backend. Cisco's
171
+ licence asks that this be stated: OpenH264 Video Codec provided by Cisco
172
+ Systems, Inc.
173
+
136
174
  `encoded` lists the codecs this build will carry when the consumer supplies the
137
175
  video itself (see *Encoded sources*): a consumer reads it rather than inferring
138
176
  support from the engine's version string.
@@ -168,12 +206,13 @@ trigger a capture request.
168
206
  "permissions": ["view","control","clipboard"],
169
207
  "max_width": 3840, "max_height": 2160, // encode box (default); never upscales
170
208
  "bitrate_kbps": 0, // 0 (default): sized to the encoded surface
171
- "max_fps": 30,
209
+ "max_fps": 60,
172
210
  "ice_servers": [{"urls":["stun:..."],"username":null,"credential":null}],
173
211
  "restore_token": null, // from a previous session.restoreToken
174
212
  "ttl_seconds": 3600, // session lease; default 3600
175
213
  "loopback_tcp": false, // also offer ICE over TCP on 127.0.0.1; default false
176
- "agent_indicator": false // local input feedback; default false (phone control)
214
+ "agent_indicator": false, // local input feedback; default false (phone control)
215
+ "local_frames": true // keep frames for session.frame; default true
177
216
  }}
178
217
  ```
179
218
 
@@ -201,7 +240,7 @@ clipboard action and never infers them from a source, a peer or an SDP.
201
240
  `control` without a working input backend is refused at `session.open` with
202
241
  `error.code = "input-unavailable"` — the consumer must ask for `view` explicitly instead.
203
242
 
204
- `max_fps` defaults to 30 and is bounded to 1–60. It caps the encoded frame
243
+ `max_fps` defaults to 60 and is bounded to 1–60. It caps the encoded frame
205
244
  rate and X11 capture loop; portal capture offers it as the preferred PipeWire
206
245
  frame rate, and macOS sets its ScreenCaptureKit stream minimum frame interval.
207
246
  A source may still supply fewer frames.
@@ -211,11 +250,19 @@ sets it for control sessions, never for view-only or human phone control. It
211
250
  adds an eased pointer halo, click ripple, and typing pulse. On Wayland it uses
212
251
  a click-through layer-shell surface with an edge glow; its bounded area is
213
252
  masked before capture/diff (pixels behind the effect can remain briefly stale).
214
- On bare X11 it replaces the pointer sprite instead, which XGetImage excludes:
253
+ On bare X11 it replaces the pointer sprite instead, which the X11 grab excludes:
215
254
  there is no edge glow on that path. macOS uses a nonactivating, click-through
216
255
  window excluded by the ScreenCaptureKit content filter. Closing the session
217
256
  fades and removes the indicator.
218
257
 
258
+ `local_frames` keeps the latest pre-encode frame and its tile hashes for
259
+ `session.frame` and `session.frame.changed` (see "Latest frame over the local
260
+ protocol"). A consumer that only streams sets it to `false`: each captured
261
+ frame is then converted once, for the encoder, no frame is kept, no
262
+ `session.frame.changed` is emitted, and `session.frame` is refused with
263
+ `error.code = "operation"`. The WebSocket bridge sets it to `false` unless its
264
+ host listens to engine events, because neither travels past the socket.
265
+
219
266
  `session.open` is where the capture request happens, which is where the user's
220
267
  consent appears. It is not implicit and it is not retried silently.
221
268
 
@@ -243,6 +290,36 @@ the SDP and candidates to the client and brings back the answer.
243
290
 
244
291
  A candidate that arrives before the remote description is buffered, not dropped.
245
292
 
293
+ #### Codecs
294
+
295
+ For a captured desktop the offer's video section lists VP9 (payload type 98)
296
+ and, when `capabilities.encode.codecs` has `h264` and the encoded surface is
297
+ within H.264 level 5.2 (at most 36864 16x16 macroblocks, e.g. 4096x2304 — a
298
+ 5K surface is offered VP9 alone), H.264 after it (payload type
299
+ 102, `packetization-mode=1`, Constrained Baseline, `profile-level-id=42e034`
300
+ with `level-asymmetry-allowed=1`). The engine codes whichever of the two the
301
+ answer lists first. A receiver that applies the offer as it comes answers in
302
+ the offer's order and gets VP9, as before; a receiver that wants H.264 puts it
303
+ first — with `setCodecPreferences` before `createAnswer`, or by moving its
304
+ payload type to the front of the offer's `m=video` line before applying it.
305
+ The iOS receiver in `@desklink/client` does the latter, because iOS decodes
306
+ H.264 in hardware and VP9 in software; the web and Android receivers keep VP9.
307
+ The choice is made by the first answer and kept for the session, ICE restarts
308
+ included; the encoder starts after that answer, so the first frame sent is
309
+ already in the chosen codec. H.264 is coded at the full encoded size throughout
310
+ (no half-size motion), with an IDR carrying its SPS and PPS only for the first
311
+ frame and on a receiver's request. An `encoded` source offers H.264 alone, as
312
+ described under *Encoded sources*.
313
+
314
+ The offer negotiates the `playout-delay` RTP header extension
315
+ (`http://www.webrtc.org/experiments/rtp-hdrext/playout-delay`), and every video
316
+ packet asks for a playout delay of 0: a receiver that honours it shows each
317
+ frame when it is decoded instead of buffering frames to smooth playback. A
318
+ receiver may decline the extension in its answer; its packets then carry no
319
+ playout delay and it buffers as usual. The iOS receiver in
320
+ `@desklink/react-native` declines it, because `RTCView` draws a frame only when
321
+ its timestamp is new and zero-delay frames all carry the same one.
322
+
246
323
  A client whose ICE connection goes `disconnected` may restart ICE on the same
247
324
  peer connection instead of opening a new session: it sends
248
325
  `session.restart_ice` with the session's id and generation, and the engine
@@ -278,8 +355,9 @@ always does.
278
355
 
279
356
  `disconnected` is ICE losing the path: the picture is frozen but the session
280
357
  is alive, and a client should show reconnecting and may restart ICE (above)
281
- rather than open a new session. `failed` is terminal: the engine revokes and
282
- closes the session, and only a new `session.open` recovers it.
358
+ rather than open a new session. `failed` is terminal: the engine revokes the
359
+ session with code `transport` and closes it, and only a new `session.open`
360
+ recovers it.
283
361
 
284
362
  While its control channel is open the engine also sends a heartbeat twice a
285
363
  second, `{"kind":"ping"}`. It lets a client notice a stalled path within
@@ -310,10 +388,26 @@ picker; restoration is silent only where the backend supports it. Cancellation
310
388
  is an error, not a reason to retry or bypass consent.
311
389
 
312
390
  ```jsonc
313
- {"event":"session.revoked","params":{"sessionId":"…","reason":"…"}}
391
+ {"event":"session.revoked","params":{"sessionId":"…","reason":"…",
392
+ "code":"transport"}} // transport|lease|error
314
393
  ```
315
394
 
316
395
  The session ended on the engine's side; there is nothing further to drain.
396
+ `reason` is prose for people; `code` is what a client acts on:
397
+
398
+ | `code` | Meaning | A client should |
399
+ |---|---|---|
400
+ | `transport` | The path to the client's peer was lost (ICE `failed`, or the track refused a frame) | open a new session with fresh authority; the desktop is still available |
401
+ | `lease` | The session's `ttl_seconds` ran out | end; the grant is spent |
402
+ | `error` | The engine could not keep serving it, such as an encoder refusing a frame | end and report |
403
+
404
+ A session the consumer closes or replaces (`session.close`, a new
405
+ `session.open`, the consumer leaving) sends no `session.revoked`: the consumer
406
+ asked for it, and signaling sees `session.state` with `capture` `ended`. Only
407
+ its control channel is told, with code `closed` (below).
408
+
409
+ A code a client does not know is terminal. Engines before this field sent no
410
+ `code`; treat its absence as terminal too.
317
411
 
318
412
  Metrics are a request, not a notification:
319
413
 
@@ -323,17 +417,158 @@ Metrics are a request, not a notification:
323
417
 
324
418
  ```jsonc
325
419
  {"id":7,"result":{"captured_frames":812,"dropped_frames":3,"encoded_frames":809,
326
- "encoded_bytes":12345678,"key_frames":1,"refined_frames":37,
420
+ "encoded_bytes":12345678,"key_frames":1,"refined_frames":37,"halved_frames":0,"motion_frames":770,
327
421
  "encode_micros":7390000,"target_kbps":16430,"input_applied":44,"input_rejected":0,
328
- "fed_frames":0,"input_forwarded":0}}
422
+ "fed_frames":0,"input_forwarded":0,"codec":"vp9","encoder":"libvpx",
423
+ "capture_micros":14100000,"convert_micros":3200000,
424
+ "queue_micros":410000,"send_micros":180000}}
329
425
  ```
330
426
 
331
427
  `dropped_frames` counts frames superseded by a newer one before they were coded;
332
- the newest frame is always coded. `refined_frames` counts refinement passes: a
428
+ the newest frame is always coded. That includes frames that arrived while a
429
+ large one (a key frame, a refinement) was still crossing the link: the stream
430
+ runs at most about 60 ms of its rate target ahead of it, so frames never queue
431
+ behind a burst. `refined_frames` counts refinement passes: a
333
432
  desktop that stops changing is coded once more at a fine quantizer, so it reads
334
- sharp. Key frames are sent only for the first frame and when the receiver asks
335
- (RTCP PLI or FIR). `target_kbps` is the current rate target after any back-off
336
- for loss the receiver reported.
433
+ sharp. `halved_frames` counts motion frames coded at half width and height: when
434
+ full-size frames that change much of the picture take longer to code than a
435
+ frame interval while a newer frame is already waiting, several frames in a row,
436
+ motion is coded at half size until the desktop stops or its changes shrink to
437
+ small ones that code in time; the refinement pass is always full size. Key
438
+ frames are sent only for the first frame, when the receiver asks (RTCP PLI or
439
+ FIR), and when the coded size changes that way, so a receiver never has to scale
440
+ references across a size change. `target_kbps` is the current rate target after
441
+ any back-off: for a standing queue on the path, measured from the receiver's
442
+ transport-wide congestion feedback (RTP `transport-cc`), and for the loss
443
+ its receiver reports carry. `codec` is what frames go out in
444
+ (`vp9` or `h264`) and `encoder` what codes them (`libvpx`, `videotoolbox`,
445
+ `nvenc`, `vaapi` or `openh264`); both are empty for an encoded source.
446
+
447
+ Per-stage timings (the L0 lab harness reads these, and every later lane cites
448
+ them). Each is a cumulative microsecond counter; divide by the frame count
449
+ named beside it for the per-frame mean:
450
+
451
+ - `capture_micros` / `captured_frames`: grabbing a frame and converting it on
452
+ the capture thread (on X11: an MIT-SHM grab, falling back to socket
453
+ `GetImage` where the server lacks MIT-SHM, plus the downscale to I420), over
454
+ forwarded (changed) frames only — unchanged polls are excluded.
455
+ - `convert_micros` / `captured_frames`: tile hashing and damage computation on
456
+ the capture thread, after the grab; the hash branch runs only with
457
+ `local_frames` on.
458
+ - `queue_micros` / `motion_frames`: how long a freshly captured frame waited
459
+ between the capture handoff and the start of its encode. Refinement,
460
+ keepalive and receiver-requested re-encodes re-code a picture that was
461
+ already sent; they queue nothing and stay out of this mean.
462
+ - `send_micros` / `encoded_frames`: packetizing a frame and handing it to the
463
+ transport.
464
+ - `encode_micros` / `encoded_frames`: the encoder alone.
465
+
466
+ `capture + convert` per captured frame plus `queue` per motion-coded frame and
467
+ `encode + send` per encoded frame reconciles against engine-side wall time per
468
+ frame within measurement noise; a stage that will not reconcile is a stage
469
+ whose accounting is wrong.
470
+
471
+ ## Cursor-free capture
472
+
473
+ `session.open` accepts `cursor: "hidden"` (default `"embedded"`). Existing
474
+ callers keep their capture behavior. Linux hidden capture requires portal
475
+ **Metadata** so cursor positions remain available while cursor pixels are
476
+ omitted. A portal without Metadata returns the typed
477
+ `cursor_positions_unavailable` refusal. Successful open returns
478
+ `cursor: {"mode":"metadata"|"hidden"|"embedded"|"unavailable",
479
+ "positions":true|false}`; `mode` names the portal mode accepted by the portal,
480
+ not just the requested option.
481
+
482
+ Linux capabilities advertise `capture.cursor_modes: ["embedded","hidden"]`
483
+ and `capture.cursor_positions: {"x11":true,"portal":"negotiated"}`. Portal
484
+ support is resolved at open: hidden capture requires Metadata and an observed
485
+ SPA cursor metadata buffer. A portal that offers only Hidden refuses open with
486
+ `cursor_positions_unavailable`; accepted hidden sessions report `positions:true`.
487
+ Metadata-mode buffers that contain no new cursor data do not fabricate a move
488
+ or a visibility change.
489
+
490
+ Hidden sessions with positions emit local protocol events independently of
491
+ pixel damage, including on a still X11 desktop:
492
+
493
+ ```json
494
+ {"event":"session.cursor","params":{"sessionId":"…","x":400,"y":300,"visible":true,"timestamp_us":123456}}
495
+ ```
496
+
497
+ `x,y` name the pointer hotspot in the selected source's coordinates
498
+ (`geometry.source`), before video scaling, without the desktop origin added.
499
+ Portal buffer coordinates are mapped to that geometry. `visible` is false
500
+ outside the source or when SPA supplies an invisible cursor bitmap.
501
+ `timestamp_us` is a monotonic engine-process clock in microseconds, sampled
502
+ when the capture thread observes the pointer. The same clock stamps
503
+ `session.frame.changed` and `session.frame` metadata, so local recording
504
+ consumers can compare cursor samples to frame handoff times. These are not
505
+ wall-clock or RTP timestamps. SPA cursor updates accompany PipeWire buffers;
506
+ X11 pointer queries run at the session frame cap even with no pixel damage.
507
+ Repeated identical positions/visibility are suppressed.
508
+
509
+ X11 root GetImage/MIT-SHM never contains the server cursor and this engine
510
+ never composites it, including for existing embedded-default callers. Its
511
+ open reply therefore reports `mode:"hidden"`; positions are enabled only by
512
+ an explicit `cursor:"hidden"`. macOS and Windows refuse hidden capture with
513
+ `cursor-unavailable` in this build. Consumer-fed encoded sources refuse it too:
514
+ the engine cannot remove pixels the consumer already encoded. Unknown cursor
515
+ options are `malformed`.
516
+
517
+ `@desklink/host` exposes `openSession({permissions:['view'], cursor:'hidden'})`
518
+ and typed `session.cursor` events through `onEvent`/`drainEvents`. The client
519
+ capture request also accepts `cursor`, and AXI accepts `start --cursor hidden`.
520
+
521
+ ## Point on the host desktop
522
+
523
+ 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
525
+ of `agent_indicator`. It draws one blue ring centered at `x,y`, with an optional
526
+ label. A new point replaces the previous one and restarts its timer.
527
+
528
+ ```jsonc
529
+ {"id":30,"method":"session.point","params":{"session_id":"…","x":400,"y":300,"label":"Here","timeout_ms":3000}}
530
+ {"id":30,"result":{"shown":true}}
531
+ {"id":31,"method":"session.point","params":{"session_id":"…","clear":true}}
532
+ {"id":31,"result":{"shown":false}}
533
+ ```
534
+
535
+ Coordinates are integers in the **selected desktop's original pixels**, top-left
536
+ origin, before video scaling (`geometry.source`, not `geometry.encoded`). On X11
537
+ this is the root screen; on macOS it is the selected display, with native pixels
538
+ converted to display points. Both coordinates must be within its bounds. The
539
+ ring may clip at an edge; its label stays inside the display. `timeout_ms`
540
+ defaults to 3000 and must be an integer from 1 to 120000. Labels are at most 96
541
+ printable ASCII bytes, including spaces; other labels are refused rather than
542
+ rendered incorrectly by the X server's core font. A clear request has no
543
+ coordinates, label or timeout, and is idempotent. Invalid requests do not alter
544
+ an existing marker. Replies acknowledge submission to the local overlay helper,
545
+ not that a person has seen it. Timeout hides it; clear, session replacement,
546
+ lease expiry, close and consumer disconnection remove and reap the helper.
547
+
548
+ X11 uses an override-redirect, shaped window with an empty input shape. It never
549
+ warps the pointer, changes the cursor, requests focus or injects events. macOS
550
+ reuses the nonactivating, mouse-transparent indicator window in a separate
551
+ helper. **Point cues intentionally appear in local frames and outgoing video**:
552
+ the person and observer can see the same cue. They are separate from the
553
+ capture-excluded input activity indicator. Consumers reading frame differences
554
+ should allow for the point's appearance/disappearance.
555
+
556
+ Wayland portal sessions use a wlr-layer-shell overlay with no keyboard
557
+ interactivity and an empty input region. The selected monitor is matched by its
558
+ portal origin against compositor logical output geometry (xdg-output); with one
559
+ output, no origin is needed. Points map from the selected source to that output.
560
+ An ambiguous or missing selected output returns `indicator-unavailable`.
561
+ Compositors without layer-shell return `point_unsupported` with reason
562
+ `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`,
564
+ `coordinates`, `malformed`, and `indicator-unavailable` (including unavailable
565
+ X Shape support). Unknown fields are refused. No control-channel or remote
566
+ bridge forwarding is added; the consumer chooses when to request a local cue.
567
+
568
+ `@desklink/host` exposes `EngineClient.point(sessionId, {x, y, label?, timeoutMs?})`
569
+ and `EngineClient.point(sessionId, {clear: true})`. `@desklink/axi` exposes
570
+ `desklink-axi point 400,300 --label "Here" --timeout 3000` and
571
+ `desklink-axi point --clear`, including for a session started without `--control`.
337
572
 
338
573
  ## Input and clipboard: the session's control channel
339
574
 
@@ -373,7 +608,7 @@ The engine answers on the same channel, and sends two things unprompted:
373
608
  {"kind":"ack","seq":44}
374
609
  {"kind":"rejected","seq":44,"code":"coordinates","message":"(9000,4) is outside the 1280x720 surface"}
375
610
  {"kind":"clipboard","request":"…","text":"…","truncated":false} // an empty "text" with "error" when the read failed
376
- {"kind":"revoked","reason":"…"}
611
+ {"kind":"revoked","reason":"…","code":"closed"} // closed, or a session.revoked code
377
612
  ```
378
613
 
379
614
  Refusal codes: `permission`, `session`, `input-replay`, `coordinates`,
@@ -494,9 +729,12 @@ is the consumer's to carry.
494
729
 
495
730
  `session.frame` and `session.wait_frame` are refused with `error.code =
496
731
  "operation"` — there is no lossless frame to read — and `captured_frames`,
497
- `refined_frames` and `target_kbps` stay at zero while `fed_frames` counts what
498
- the consumer handed over and `encoded_frames`, `encoded_bytes`, `key_frames`
499
- and `encode_micros` describe what was packetized and sent.
732
+ `refined_frames`, `halved_frames`, `motion_frames`, `target_kbps`, `capture_micros`,
733
+ `convert_micros`, `queue_micros` and `send_micros` stay at zero while
734
+ `fed_frames` counts what the consumer handed over and `encoded_frames`,
735
+ `encoded_bytes`, `key_frames` and `encode_micros` describe what was packetized
736
+ and sent — on this path `encode_micros` covers packetizing and sending too,
737
+ because packetization is the only stage the engine itself runs.
500
738
 
501
739
  ## Latest frame over the local protocol
502
740
 
@@ -516,7 +754,8 @@ of stillness. When both are supplied, both conditions must hold.
516
754
  Each changed capture emits a local `session.frame.changed` event with `sessionId`,
517
755
  `seq` and `damage`, allowing a co-located consumer to track animation across
518
756
  observations. Neither the event nor this file-writing method is forwarded by
519
- the remote WebSocket bridge.
757
+ the remote WebSocket bridge. A session opened with `local_frames: false` keeps
758
+ no frame: both are refused with `error.code = "operation"` (see `session.open`).
520
759
 
521
760
  ```json
522
761
  {"id":20,"method":"session.frame","params":{"session_id":"…","since":7,"path":"/run/user/1000/frame.raw"}}
@@ -616,7 +855,8 @@ anything to show.
616
855
 
617
856
  - It does not decide *who* the user is. It trusts the consumer's local
618
857
  permission decision and enforces the resulting scope; there is no second
619
- account, token or pairing ceremony.
858
+ account, token or pairing ceremony. For consumer integration, see the
859
+ [BYOKit guidance](../../../README.md#why-desklink-exists).
620
860
  - It does not open ports, register a service, install udev rules, join groups or
621
861
  raise capabilities. Kernel input access is a separate, explicit, guided step
622
862
  (`desklink-host setup-input` reports what is missing; it does not change it).
package/engine/Cargo.lock CHANGED
@@ -307,6 +307,16 @@ version = "1.12.1"
307
307
  source = "registry+https://github.com/rust-lang/crates.io-index"
308
308
  checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
309
309
 
310
+ [[package]]
311
+ name = "bzip2-rs"
312
+ version = "0.1.2"
313
+ source = "registry+https://github.com/rust-lang/crates.io-index"
314
+ checksum = "beeb59e7e4c811ab37cc73680c798c7a5da77fc9989c62b09138e31ee740f735"
315
+ dependencies = [
316
+ "crc32fast",
317
+ "tinyvec",
318
+ ]
319
+
310
320
  [[package]]
311
321
  name = "cc"
312
322
  version = "1.4.7"
@@ -449,6 +459,15 @@ dependencies = [
449
459
  "rustc_version",
450
460
  ]
451
461
 
462
+ [[package]]
463
+ name = "crc32fast"
464
+ version = "1.5.2"
465
+ source = "registry+https://github.com/rust-lang/crates.io-index"
466
+ checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
467
+ dependencies = [
468
+ "cfg-if",
469
+ ]
470
+
452
471
  [[package]]
453
472
  name = "crossbeam-utils"
454
473
  version = "0.8.23"
@@ -517,7 +536,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
517
536
 
518
537
  [[package]]
519
538
  name = "desklink-host"
520
- version = "0.2.0"
539
+ version = "0.3.1"
521
540
  dependencies = [
522
541
  "anyhow",
523
542
  "ashpd",
@@ -525,8 +544,8 @@ dependencies = [
525
544
  "base64 0.22.1",
526
545
  "block2",
527
546
  "bytes",
547
+ "bzip2-rs",
528
548
  "cc",
529
- "futures-util",
530
549
  "libc",
531
550
  "log",
532
551
  "objc2-core-foundation",
@@ -534,13 +553,18 @@ dependencies = [
534
553
  "objc2-core-media",
535
554
  "objc2-foundation",
536
555
  "objc2-screen-capture-kit",
556
+ "openh264-sys2",
537
557
  "pipewire",
538
558
  "rand 0.8.8",
539
559
  "rtc",
540
560
  "serde",
541
561
  "serde_json",
562
+ "sha2",
542
563
  "tokio",
564
+ "wayland-client",
565
+ "wayland-protocols",
543
566
  "webrtc",
567
+ "windows-sys 0.61.2",
544
568
  "wl-clipboard-rs",
545
569
  "x11rb",
546
570
  "xkbcommon",
@@ -1430,6 +1454,16 @@ version = "1.21.4"
1430
1454
  source = "registry+https://github.com/rust-lang/crates.io-index"
1431
1455
  checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1432
1456
 
1457
+ [[package]]
1458
+ name = "openh264-sys2"
1459
+ version = "0.9.8"
1460
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1461
+ checksum = "6ada29a4cadc13d4d326737af87c13e224210d7d99fe47594e9f3f9b0102fd70"
1462
+ dependencies = [
1463
+ "libloading",
1464
+ "sha2",
1465
+ ]
1466
+
1433
1467
  [[package]]
1434
1468
  name = "ordered-stream"
1435
1469
  version = "0.2.0"
@@ -2239,6 +2273,17 @@ dependencies = [
2239
2273
  "digest",
2240
2274
  ]
2241
2275
 
2276
+ [[package]]
2277
+ name = "sha2"
2278
+ version = "0.10.9"
2279
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2280
+ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
2281
+ dependencies = [
2282
+ "cfg-if",
2283
+ "cpufeatures 0.2.17",
2284
+ "digest",
2285
+ ]
2286
+
2242
2287
  [[package]]
2243
2288
  name = "shlex"
2244
2289
  version = "1.3.0"
package/engine/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "desklink-host"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  edition = "2021"
5
5
  rust-version = "1.97.1"
6
6
  license = "Apache-2.0"
@@ -16,10 +16,9 @@ serde = { version = "1", features = ["derive"] }
16
16
  serde_json = "1"
17
17
  base64 = "0.22"
18
18
 
19
- [target.'cfg(target_os = "linux")'.dependencies]
19
+ [target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dependencies]
20
20
  anyhow = "1"
21
21
  tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "time", "signal"] }
22
- futures-util = "0.3"
23
22
  libc = "0.2"
24
23
  bytes = "1"
25
24
  webrtc = { version = "0.21", default-features = false, features = ["runtime-tokio", "crypto-ring"] }
@@ -27,22 +26,28 @@ rtc = { version = "0.21", default-features = false, features = ["crypto-ring"] }
27
26
  log = "0.4"
28
27
  async-trait = "0.1"
29
28
  rand = "0.8"
29
+
30
+ [target.'cfg(target_os = "linux")'.dependencies]
30
31
  ashpd = { version = "0.11", default-features = false, features = ["tokio"] }
31
32
  pipewire = { version = "0.10", features = ["v0_3_44"] }
32
33
  xkbcommon = "0.8"
33
- x11rb = { version = "0.14", default-features = false, features = ["allow-unsafe-code", "xtest"] }
34
+ x11rb = { version = "0.14", default-features = false, features = ["allow-unsafe-code", "xtest", "shm", "damage"] }
34
35
  wl-clipboard-rs = "0.9"
36
+ # H.264 fallback: Cisco's prebuilt openh264, loaded at runtime (never built
37
+ # from source), fetched once as bzip2 and checked against a pinned sha256.
38
+ openh264-sys2 = { version = "0.9.8", default-features = false, features = ["libloading"] }
39
+ sha2 = "0.10"
40
+ bzip2-rs = "0.1.2"
41
+
42
+ [target.'cfg(target_os = "linux")'.dev-dependencies]
43
+ # The Wayland stamp example only; already in the tree through wl-clipboard-rs.
44
+ wayland-client = "0.31"
45
+ wayland-protocols = { version = "0.32", features = ["client"] }
46
+
47
+ [target.'cfg(target_os = "windows")'.dependencies]
48
+ windows-sys = "0.61.2"
35
49
 
36
50
  [target.'cfg(target_os = "macos")'.dependencies]
37
- anyhow = "1"
38
- async-trait = "0.1"
39
- bytes = "1"
40
- libc = "0.2"
41
- log = "0.4"
42
- rand = "0.8"
43
- rtc = { version = "0.21", default-features = false, features = ["crypto-ring"] }
44
- tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "time", "signal"] }
45
- webrtc = { version = "0.21", default-features = false, features = ["runtime-tokio", "crypto-ring"] }
46
51
  objc2-screen-capture-kit = { version = "0.3.2", features = ["SCStream", "SCShareableContent", "SCError", "SCScreenshotManager", "block2", "dispatch2", "objc2-core-media", "objc2-core-graphics"] }
47
52
  objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSError", "NSString"] }
48
53
  objc2-core-media = "0.3.2"