@desklink/host 0.1.1 → 0.3.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 (78) hide show
  1. package/NOTICE +23 -0
  2. package/README.md +107 -55
  3. package/bin/desklink-host.mjs +2 -2
  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.map +1 -1
  9. package/dist/engineProcess.js +1 -0
  10. package/dist/engineProcess.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/keeper.d.ts +52 -0
  16. package/dist/keeper.d.ts.map +1 -0
  17. package/dist/keeper.js +101 -0
  18. package/dist/keeper.js.map +1 -0
  19. package/dist/protocol.d.ts +26 -0
  20. package/dist/protocol.d.ts.map +1 -1
  21. package/dist/protocol.js.map +1 -1
  22. package/dist/resolveEngine.d.ts +3 -3
  23. package/dist/resolveEngine.d.ts.map +1 -1
  24. package/dist/resolveEngine.js +24 -11
  25. package/dist/resolveEngine.js.map +1 -1
  26. package/docs/BOUNDARY.md +20 -8
  27. package/docs/PROTOCOL.md +347 -24
  28. package/engine/Cargo.lock +47 -1
  29. package/engine/Cargo.toml +13 -2
  30. package/engine/build.rs +28 -2
  31. package/engine/native/nvenc_shim.c +453 -0
  32. package/engine/native/vaapi_shim.c +727 -0
  33. package/engine/native/vpx_shim.c +54 -4
  34. package/engine/native/vt_shim.c +448 -0
  35. package/engine/src/capture.rs +75 -55
  36. package/engine/src/capture_mac.rs +24 -14
  37. package/engine/src/convert.rs +286 -61
  38. package/engine/src/encoder.rs +122 -13
  39. package/engine/src/h264.rs +188 -0
  40. package/engine/src/h264_nvenc.rs +308 -0
  41. package/engine/src/h264_openh264.rs +735 -0
  42. package/engine/src/h264_vaapi.rs +263 -0
  43. package/engine/src/h264_vt.rs +368 -0
  44. package/engine/src/keeper.rs +622 -0
  45. package/engine/src/mac.rs +9 -6
  46. package/engine/src/main.rs +64 -4
  47. package/engine/src/peer.rs +887 -42
  48. package/engine/src/protocol.rs +153 -4
  49. package/engine/src/session.rs +1547 -137
  50. package/engine/src/x11.rs +210 -9
  51. package/engine/src/x11_mac.rs +12 -0
  52. package/engine/vendor/libva/COPYING +19 -0
  53. package/engine/vendor/libva/va/va.h +5450 -0
  54. package/engine/vendor/libva/va/va_dec_av1.h +695 -0
  55. package/engine/vendor/libva/va/va_dec_hevc.h +616 -0
  56. package/engine/vendor/libva/va/va_dec_jpeg.h +157 -0
  57. package/engine/vendor/libva/va/va_dec_vp8.h +249 -0
  58. package/engine/vendor/libva/va/va_dec_vp9.h +312 -0
  59. package/engine/vendor/libva/va/va_dec_vvc.h +688 -0
  60. package/engine/vendor/libva/va/va_drm.h +61 -0
  61. package/engine/vendor/libva/va/va_enc_av1.h +1010 -0
  62. package/engine/vendor/libva/va/va_enc_h264.h +659 -0
  63. package/engine/vendor/libva/va/va_enc_hevc.h +958 -0
  64. package/engine/vendor/libva/va/va_enc_jpeg.h +162 -0
  65. package/engine/vendor/libva/va/va_enc_mpeg2.h +308 -0
  66. package/engine/vendor/libva/va/va_enc_vp8.h +350 -0
  67. package/engine/vendor/libva/va/va_enc_vp9.h +639 -0
  68. package/engine/vendor/libva/va/va_fei.h +170 -0
  69. package/engine/vendor/libva/va/va_fei_h264.h +505 -0
  70. package/engine/vendor/libva/va/va_fei_hevc.h +254 -0
  71. package/engine/vendor/libva/va/va_prot.h +525 -0
  72. package/engine/vendor/libva/va/va_version.h +87 -0
  73. package/engine/vendor/libva/va/va_vpp.h +1590 -0
  74. package/engine/vendor/nv-codec-headers/LICENSE +24 -0
  75. package/engine/vendor/nv-codec-headers/README +7 -0
  76. package/engine/vendor/nv-codec-headers/include/ffnvcodec/nvEncodeAPI.h +3825 -0
  77. package/examples/reference-client.html +1 -1
  78. package/package.json +3 -2
package/docs/PROTOCOL.md CHANGED
@@ -23,7 +23,10 @@ to `stderr`. One JSON object per line, no length prefix, UTF-8.
23
23
  Why stdio rather than a socket: the consumer already holds the process, so the
24
24
  channel inherits the consumer's own access control (same uid, same session, no
25
25
  filesystem permission to get wrong) and no second authorization mechanism has to
26
- exist. A consumer that wants a socket can wrap this process.
26
+ exist. A consumer that wants a socket can wrap this process. (A screen that
27
+ needs no protocol at all — a private `Xvfb` whose windows should simply be kept
28
+ in order and reported — can run the engine as a display keeper instead; see
29
+ "Display keeper" below.)
27
30
 
28
31
  Cancellation is by closing the consumer's end. The engine treats EOF on `stdin`
29
32
  exactly like `shutdown`: it releases held input, stops capture, closes peers, and
@@ -63,7 +66,7 @@ Request parameters use the engine's own snake_case names (`session_id`,
63
66
  Result:
64
67
 
65
68
  ```jsonc
66
- {"protocol":3,"engine":"desklink-host/0.1.0","platform":"linux", /* the rest of Capabilities */ }
69
+ {"protocol":3,"engine":"desklink-host/0.3.0","platform":"linux", /* the rest of Capabilities */ }
67
70
  ```
68
71
 
69
72
  The consumer must send `hello` first, and a version the engine does not speak is
@@ -73,8 +76,12 @@ arrives, every other request is refused the same way; `capabilities` and
73
76
 
74
77
  ## macOS
75
78
 
79
+ For engine installation and the macOS resolution opt-out, see the
80
+ [host installation guide](../README.md#macos).
81
+
76
82
  macOS speaks protocol 3 and shares the session lifecycle below. It captures the
77
- selected display with ScreenCaptureKit, encodes VP9, and uses the same WebRTC
83
+ selected display with ScreenCaptureKit, encodes VP9 (and H.264 through
84
+ VideoToolbox, for a receiver that asks for it), and uses the same WebRTC
78
85
  offer, ICE, control-channel, lease, and metrics flow as Linux. Capture currently
79
86
  uses one bounded ScreenCaptureKit stream (three-frame queue); the backend may
80
87
  supply fewer frames. Audio is not captured.
@@ -105,7 +112,7 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
105
112
  ```jsonc
106
113
  {
107
114
  "protocol": 3,
108
- "engine": "desklink-host/0.1.0",
115
+ "engine": "desklink-host/0.3.0",
109
116
  "platform": "linux",
110
117
  "session": {"kind": "wayland"},
111
118
  "x11": {"available": true, "size": [2560, 1440]},
@@ -116,7 +123,8 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
116
123
  "cursor": "embedded",
117
124
  "audio": false
118
125
  },
119
- "encode": {"codecs": ["vp9"], "hardware": false},
126
+ "encode": {"codecs": ["vp9", "h264"], "hardware": false,
127
+ "h264": {"encoder": "vaapi", "hardware": true}},
120
128
  "input": {
121
129
  "mechanism": "inputtino/uinput",
122
130
  "pointer": true, "wheel": true, "keyboard": true,
@@ -125,10 +133,37 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
125
133
  "unavailable_reason": null,
126
134
  "grant": "granted"
127
135
  },
128
- "clipboard": {"read": true, "write": true, "mime": ["text/plain;charset=utf-8"], "maxBytes": 262144}
136
+ "clipboard": {"read": true, "write": true, "mime": ["text/plain;charset=utf-8"], "maxBytes": 262144},
137
+ "encoded": {"codecs": ["h264"]}
129
138
  }
130
139
  ```
131
140
 
141
+ `encode` describes what the engine codes from a captured desktop. VP9 (libvpx,
142
+ software; `hardware` describes it) is always there and is the default. `h264`
143
+ appears in `codecs` when an H.264 encoder starts on this machine right now, and
144
+ `encode.h264.encoder` names it: `videotoolbox` on macOS; on Linux `nvenc` or
145
+ `vaapi` when the GPU has an H.264 encoder, else `openh264`, Cisco's prebuilt
146
+ library. `encoder` is `null` when there is none. The answer to the offer picks
147
+ between them (see *Codecs* below).
148
+
149
+ openh264 is never part of this package: the engine loads only Cisco's own
150
+ binary, checked against its published SHA-256, from
151
+ `$XDG_CACHE_HOME/desklink/openh264/` (else `~/.cache/desklink/openh264/`). When a
152
+ Linux machine has no hardware H.264 encoder and the library is not there yet,
153
+ the first captured `session.open` downloads it from Cisco
154
+ (`ciscobinary.openh264.org`) before making its offer; that session is offered
155
+ VP9 alone if the download fails. `capabilities` itself never downloads.
156
+ `DESKLINK_OPENH264=off` in the engine's environment disables openh264 (no
157
+ download, no load); `DESKLINK_OPENH264=<path>` loads that file instead, still
158
+ only if its hash is Cisco's. `DESKLINK_H264_ENCODER=<name>` restricts the engine
159
+ to one H.264 encoder (`off` for none), for measuring one backend. Cisco's
160
+ licence asks that this be stated: OpenH264 Video Codec provided by Cisco
161
+ Systems, Inc.
162
+
163
+ `encoded` lists the codecs this build will carry when the consumer supplies the
164
+ video itself (see *Encoded sources*): a consumer reads it rather than inferring
165
+ support from the engine's version string.
166
+
132
167
  `input.mechanism` names the build's portal input path, `inputtino/uinput`,
133
168
  where the engine creates its own virtual devices. A session opened against an X
134
169
  display applies input through XTest instead, where the X server applies the
@@ -156,15 +191,17 @@ trigger a capture request.
156
191
  {"id":4,"method":"session.open","params":{
157
192
  "source": {"kind":"portal"}, // or {"kind":"x11","display":":99"}
158
193
  // macOS: {"kind":"display","display_id":123}
194
+ // or {"kind":"encoded","codec":"h264","width":486,"height":1080}
159
195
  "permissions": ["view","control","clipboard"],
160
196
  "max_width": 3840, "max_height": 2160, // encode box (default); never upscales
161
197
  "bitrate_kbps": 0, // 0 (default): sized to the encoded surface
162
- "max_fps": 30,
198
+ "max_fps": 60,
163
199
  "ice_servers": [{"urls":["stun:..."],"username":null,"credential":null}],
164
200
  "restore_token": null, // from a previous session.restoreToken
165
201
  "ttl_seconds": 3600, // session lease; default 3600
166
202
  "loopback_tcp": false, // also offer ICE over TCP on 127.0.0.1; default false
167
- "agent_indicator": false // local input feedback; default false (phone control)
203
+ "agent_indicator": false, // local input feedback; default false (phone control)
204
+ "local_frames": true // keep frames for session.frame; default true
168
205
  }}
169
206
  ```
170
207
 
@@ -182,8 +219,9 @@ Result:
182
219
  screen cast, which is the only path that carries a Wayland user's consent;
183
220
  `x11` reads a named X display's root window and applies input through XTest;
184
221
  `display` selects a macOS display by the ID reported in capabilities (or the
185
- main display when omitted). The choice is normally the *consumer's* decision,
186
- because a client is not in a position to know which backend a machine can offer.
222
+ main display when omitted); `encoded` carries video the consumer encoded itself.
223
+ The choice is normally the *consumer's* decision, because a client is not in a
224
+ position to know which backend a machine can offer.
187
225
 
188
226
  `permissions` are the engine's authority for this session. `view` is required
189
227
  to open a session. The engine enforces the remaining scopes on every input and
@@ -191,7 +229,7 @@ clipboard action and never infers them from a source, a peer or an SDP.
191
229
  `control` without a working input backend is refused at `session.open` with
192
230
  `error.code = "input-unavailable"` — the consumer must ask for `view` explicitly instead.
193
231
 
194
- `max_fps` defaults to 30 and is bounded to 1–60. It caps the encoded frame
232
+ `max_fps` defaults to 60 and is bounded to 1–60. It caps the encoded frame
195
233
  rate and X11 capture loop; portal capture offers it as the preferred PipeWire
196
234
  frame rate, and macOS sets its ScreenCaptureKit stream minimum frame interval.
197
235
  A source may still supply fewer frames.
@@ -201,11 +239,19 @@ sets it for control sessions, never for view-only or human phone control. It
201
239
  adds an eased pointer halo, click ripple, and typing pulse. On Wayland it uses
202
240
  a click-through layer-shell surface with an edge glow; its bounded area is
203
241
  masked before capture/diff (pixels behind the effect can remain briefly stale).
204
- On bare X11 it replaces the pointer sprite instead, which XGetImage excludes:
242
+ On bare X11 it replaces the pointer sprite instead, which the X11 grab excludes:
205
243
  there is no edge glow on that path. macOS uses a nonactivating, click-through
206
244
  window excluded by the ScreenCaptureKit content filter. Closing the session
207
245
  fades and removes the indicator.
208
246
 
247
+ `local_frames` keeps the latest pre-encode frame and its tile hashes for
248
+ `session.frame` and `session.frame.changed` (see "Latest frame over the local
249
+ protocol"). A consumer that only streams sets it to `false`: each captured
250
+ frame is then converted once, for the encoder, no frame is kept, no
251
+ `session.frame.changed` is emitted, and `session.frame` is refused with
252
+ `error.code = "operation"`. The WebSocket bridge sets it to `false` unless its
253
+ host listens to engine events, because neither travels past the socket.
254
+
209
255
  `session.open` is where the capture request happens, which is where the user's
210
256
  consent appears. It is not implicit and it is not retried silently.
211
257
 
@@ -233,6 +279,52 @@ the SDP and candidates to the client and brings back the answer.
233
279
 
234
280
  A candidate that arrives before the remote description is buffered, not dropped.
235
281
 
282
+ #### Codecs
283
+
284
+ For a captured desktop the offer's video section lists VP9 (payload type 98)
285
+ and, when `capabilities.encode.codecs` has `h264` and the encoded surface is
286
+ within H.264 level 5.2 (at most 36864 16x16 macroblocks, e.g. 4096x2304 — a
287
+ 5K surface is offered VP9 alone), H.264 after it (payload type
288
+ 102, `packetization-mode=1`, Constrained Baseline, `profile-level-id=42e034`
289
+ with `level-asymmetry-allowed=1`). The engine codes whichever of the two the
290
+ answer lists first. A receiver that applies the offer as it comes answers in
291
+ the offer's order and gets VP9, as before; a receiver that wants H.264 puts it
292
+ first — with `setCodecPreferences` before `createAnswer`, or by moving its
293
+ payload type to the front of the offer's `m=video` line before applying it.
294
+ The iOS receiver in `@desklink/client` does the latter, because iOS decodes
295
+ H.264 in hardware and VP9 in software; the web and Android receivers keep VP9.
296
+ The choice is made by the first answer and kept for the session, ICE restarts
297
+ included; the encoder starts after that answer, so the first frame sent is
298
+ already in the chosen codec. H.264 is coded at the full encoded size throughout
299
+ (no half-size motion), with an IDR carrying its SPS and PPS only for the first
300
+ frame and on a receiver's request. An `encoded` source offers H.264 alone, as
301
+ described under *Encoded sources*.
302
+
303
+ The offer negotiates the `playout-delay` RTP header extension
304
+ (`http://www.webrtc.org/experiments/rtp-hdrext/playout-delay`), and every video
305
+ packet asks for a playout delay of 0: a receiver that honours it shows each
306
+ frame when it is decoded instead of buffering frames to smooth playback. A
307
+ receiver may decline the extension in its answer; its packets then carry no
308
+ playout delay and it buffers as usual. The iOS receiver in
309
+ `@desklink/react-native` declines it, because `RTCView` draws a frame only when
310
+ its timestamp is new and zero-delay frames all carry the same one.
311
+
312
+ A client whose ICE connection goes `disconnected` may restart ICE on the same
313
+ peer connection instead of opening a new session: it sends
314
+ `session.restart_ice` with the session's id and generation, and the engine
315
+ re-offers — a fresh offer with a new ICE generation — as a new
316
+ `session.description` event, which the client answers as usual. The session
317
+ and its generation survive; only the ICE generation turns over. Fresh
318
+ candidates from the restart trickle through `session.candidate` on both sides
319
+ exactly as the initial ones do.
320
+
321
+ ```jsonc
322
+ {"id":7,"method":"session.restart_ice","params":{"session_id":"…","generation":1}}
323
+ // → {"id":7,"result":{"accepted":true}}
324
+ // …then {"event":"session.description","params":{"sessionId":"…","generation":1,
325
+ // "description":{"type":"offer","sdp":"v=0\r\n…"}}}
326
+ ```
327
+
236
328
  With `loopback_tcp`, the engine also listens for ICE over TCP on
237
329
  `127.0.0.1` and offers that passive candidate. It is for a client whose only
238
330
  way to this computer is a forward of its loopback, such as an SSH tunnel: the
@@ -246,10 +338,22 @@ always does.
246
338
  ```jsonc
247
339
  {"event":"session.state","params":{"sessionId":"…",
248
340
  "capture":"streaming", // consented|streaming|ended
249
- "transport":"connected", // new|connecting|connected|failed|closed
341
+ "transport":"connected", // new|connecting|connected|disconnected|failed|closed
250
342
  "firstFrame":true}} // false until a frame has been encoded
251
343
  ```
252
344
 
345
+ `disconnected` is ICE losing the path: the picture is frozen but the session
346
+ is alive, and a client should show reconnecting and may restart ICE (above)
347
+ rather than open a new session. `failed` is terminal: the engine revokes the
348
+ session with code `transport` and closes it, and only a new `session.open`
349
+ recovers it.
350
+
351
+ While its control channel is open the engine also sends a heartbeat twice a
352
+ second, `{"kind":"ping"}`. It lets a client notice a stalled path within
353
+ about a second and a half, long before ICE consent timers declare it
354
+ `disconnected` — and a resumed heartbeat is the path working again. Clients
355
+ that predate it ignore the unknown kind.
356
+
253
357
  A stopped capture emits `{"event":"session.capture.stopped","params":{"sessionId":"…","reason":"SCStream stopped (SCStreamErrorDomain -3821): …"}}`. Frame requests then return `stream-stopped` rather than stale pixels. On macOS the engine retries opening the stream up to three times and emits `session.state` with `capture=streaming` on recovery; Linux capture errors also surface instead of serving an old frame.\n\nThe `geometry` a client maps touch against is the one `session.open` returned,
254
358
  and the one the control channel's `hello` repeats. A session keeps the one
255
359
  `generation` it was opened with: a different source or scale is a new
@@ -273,10 +377,26 @@ picker; restoration is silent only where the backend supports it. Cancellation
273
377
  is an error, not a reason to retry or bypass consent.
274
378
 
275
379
  ```jsonc
276
- {"event":"session.revoked","params":{"sessionId":"…","reason":"…"}}
380
+ {"event":"session.revoked","params":{"sessionId":"…","reason":"…",
381
+ "code":"transport"}} // transport|lease|error
277
382
  ```
278
383
 
279
384
  The session ended on the engine's side; there is nothing further to drain.
385
+ `reason` is prose for people; `code` is what a client acts on:
386
+
387
+ | `code` | Meaning | A client should |
388
+ |---|---|---|
389
+ | `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 |
390
+ | `lease` | The session's `ttl_seconds` ran out | end; the grant is spent |
391
+ | `error` | The engine could not keep serving it, such as an encoder refusing a frame | end and report |
392
+
393
+ A session the consumer closes or replaces (`session.close`, a new
394
+ `session.open`, the consumer leaving) sends no `session.revoked`: the consumer
395
+ asked for it, and signaling sees `session.state` with `capture` `ended`. Only
396
+ its control channel is told, with code `closed` (below).
397
+
398
+ A code a client does not know is terminal. Engines before this field sent no
399
+ `code`; treat its absence as terminal too.
280
400
 
281
401
  Metrics are a request, not a notification:
282
402
 
@@ -286,16 +406,56 @@ Metrics are a request, not a notification:
286
406
 
287
407
  ```jsonc
288
408
  {"id":7,"result":{"captured_frames":812,"dropped_frames":3,"encoded_frames":809,
289
- "encoded_bytes":12345678,"key_frames":1,"refined_frames":37,
290
- "encode_micros":7390000,"target_kbps":16430,"input_applied":44,"input_rejected":0}}
409
+ "encoded_bytes":12345678,"key_frames":1,"refined_frames":37,"halved_frames":0,"motion_frames":770,
410
+ "encode_micros":7390000,"target_kbps":16430,"input_applied":44,"input_rejected":0,
411
+ "fed_frames":0,"input_forwarded":0,"codec":"vp9","encoder":"libvpx",
412
+ "capture_micros":14100000,"convert_micros":3200000,
413
+ "queue_micros":410000,"send_micros":180000}}
291
414
  ```
292
415
 
293
416
  `dropped_frames` counts frames superseded by a newer one before they were coded;
294
- the newest frame is always coded. `refined_frames` counts refinement passes: a
417
+ the newest frame is always coded. That includes frames that arrived while a
418
+ large one (a key frame, a refinement) was still crossing the link: the stream
419
+ runs at most about 60 ms of its rate target ahead of it, so frames never queue
420
+ behind a burst. `refined_frames` counts refinement passes: a
295
421
  desktop that stops changing is coded once more at a fine quantizer, so it reads
296
- sharp. Key frames are sent only for the first frame and when the receiver asks
297
- (RTCP PLI or FIR). `target_kbps` is the current rate target after any back-off
298
- for loss the receiver reported.
422
+ sharp. `halved_frames` counts motion frames coded at half width and height: when
423
+ full-size frames that change much of the picture take longer to code than a
424
+ frame interval while a newer frame is already waiting, several frames in a row,
425
+ motion is coded at half size until the desktop stops or its changes shrink to
426
+ small ones that code in time; the refinement pass is always full size. Key
427
+ frames are sent only for the first frame, when the receiver asks (RTCP PLI or
428
+ FIR), and when the coded size changes that way, so a receiver never has to scale
429
+ references across a size change. `target_kbps` is the current rate target after
430
+ any back-off: for a standing queue on the path, measured from the receiver's
431
+ transport-wide congestion feedback (RTP `transport-cc`), and for the loss
432
+ its receiver reports carry. `codec` is what frames go out in
433
+ (`vp9` or `h264`) and `encoder` what codes them (`libvpx`, `videotoolbox`,
434
+ `nvenc`, `vaapi` or `openh264`); both are empty for an encoded source.
435
+
436
+ Per-stage timings (the L0 lab harness reads these, and every later lane cites
437
+ them). Each is a cumulative microsecond counter; divide by the frame count
438
+ named beside it for the per-frame mean:
439
+
440
+ - `capture_micros` / `captured_frames`: grabbing a frame and converting it on
441
+ the capture thread (on X11: an MIT-SHM grab, falling back to socket
442
+ `GetImage` where the server lacks MIT-SHM, plus the downscale to I420), over
443
+ forwarded (changed) frames only — unchanged polls are excluded.
444
+ - `convert_micros` / `captured_frames`: tile hashing and damage computation on
445
+ the capture thread, after the grab; the hash branch runs only with
446
+ `local_frames` on.
447
+ - `queue_micros` / `motion_frames`: how long a freshly captured frame waited
448
+ between the capture handoff and the start of its encode. Refinement,
449
+ keepalive and receiver-requested re-encodes re-code a picture that was
450
+ already sent; they queue nothing and stay out of this mean.
451
+ - `send_micros` / `encoded_frames`: packetizing a frame and handing it to the
452
+ transport.
453
+ - `encode_micros` / `encoded_frames`: the encoder alone.
454
+
455
+ `capture + convert` per captured frame plus `queue` per motion-coded frame and
456
+ `encode + send` per encoded frame reconciles against engine-side wall time per
457
+ frame within measurement noise; a stage that will not reconcile is a stage
458
+ whose accounting is wrong.
299
459
 
300
460
  ## Input and clipboard: the session's control channel
301
461
 
@@ -327,14 +487,15 @@ are JSON, one per message:
327
487
  | `release_all` | — | explicit safety net; the engine also does this on close and on channel loss |
328
488
  | `clipboard_read` / `clipboard_write` | `request` (echoed back), `text` | explicit, on user action; never polled |
329
489
 
330
- The engine answers on the same channel:
490
+ The engine answers on the same channel, and sends two things unprompted:
331
491
 
332
492
  ```jsonc
333
493
  {"kind":"hello","protocol":3,"geometry":{…}} // once, when the channel opens
494
+ {"kind":"ping"} // heartbeat, twice a second; see "State" above
334
495
  {"kind":"ack","seq":44}
335
496
  {"kind":"rejected","seq":44,"code":"coordinates","message":"(9000,4) is outside the 1280x720 surface"}
336
497
  {"kind":"clipboard","request":"…","text":"…","truncated":false} // an empty "text" with "error" when the read failed
337
- {"kind":"revoked","reason":"…"}
498
+ {"kind":"revoked","reason":"…","code":"closed"} // closed, or a session.revoked code
338
499
  ```
339
500
 
340
501
  Refusal codes: `permission`, `session`, `input-replay`, `coordinates`,
@@ -354,6 +515,114 @@ layout can produce. macOS reports Unicode text support and currently maps
354
515
  layout is diagnostic, not proof that this map matches it. Anything outside the
355
516
  map is refused with `text-unsupported`.
356
517
 
518
+ ## Encoded sources
519
+
520
+ An `encoded` source is video the **consumer** produced: a phone mirror from a
521
+ device helper, a simulator, a camera. The engine captures nothing, encodes
522
+ nothing and holds no input device for it; it packetizes what it is handed and
523
+ returns the client's control messages to the consumer, which owns whatever
524
+ device the picture came from. That is the whole point of the source: the helper
525
+ that already speaks H.264 keeps its own encoder, and the engine keeps the
526
+ WebRTC, congestion and teardown logic that is already proven on this path.
527
+
528
+ ```jsonc
529
+ {"source": {"kind":"encoded","codec":"h264","width":486,"height":1080}}
530
+ ```
531
+
532
+ `codec` is `h264`, the only one this build carries (`capabilities.encoded`).
533
+ `width`/`height` are the stream's own pixel size, chosen by the consumer because
534
+ it is the only party that knows them (`1..7680` by `1..4320`). Nothing is scaled:
535
+ `geometry.source` and `geometry.encoded` are both that size, the client aims at
536
+ those pixels, and the consumer maps them to its device. `max_width`/
537
+ `max_height`/`bitrate_kbps` are ignored for this source, and `permissions` works
538
+ as it does anywhere else — with one difference: `control` needs no local input
539
+ backend, because nothing is applied locally.
540
+
541
+ `session.open` returns before any offer exists. The offer must name the profile
542
+ the stream actually is (`packetization-mode=1` and the SPS's own
543
+ `profile-level-id`), and the only place that is written down is the stream's own
544
+ SPS, so the engine sends `session.description` **after** the first fed access
545
+ unit that carries one. A consumer must therefore not wait for the offer before
546
+ it starts feeding, and a consumer that never feeds an SPS gets no offer at all.
547
+
548
+ ### Feeding access units
549
+
550
+ ```jsonc
551
+ {"id":8,"method":"session.feed","params":{
552
+ "session_id":"…",
553
+ "keyframe":true, // this unit is an IDR
554
+ "data_b64":"AAAA…" // one Annex-B access unit, base64
555
+ }}
556
+ ```
557
+
558
+ ```jsonc
559
+ {"id":8,"result":{"accepted":true}}
560
+ ```
561
+
562
+ One request is one access unit: start codes and all, exactly as the encoder
563
+ emitted it, with SPS/PPS in the unit that carries the IDR. `keyframe` is the
564
+ consumer's own statement about the unit; the engine uses it for `session.metrics`
565
+ and for nothing else, because the RTP payloader reads the NAL types itself. The
566
+ reply acknowledges the hand-off, not the picture: units are packetized behind
567
+ the request, and `session.metrics` reports what was sent and what was dropped.
568
+
569
+ Base64 in the same JSON line keeps one ordered channel and one inherited fd.
570
+ Measured, that is not a close call: one second of a 2 Mbit/s stream is 250 KB,
571
+ and base64 costs about 0.09 ms to encode on the consumer's side and 1.0 ms to
572
+ decode on the engine's — roughly a tenth of one per cent of a core, against the
573
+ 5 % that would have justified a second fd. Decoded units are
574
+ limited to 8 MiB (`malformed` beyond that); a unit the buffer cannot hold right
575
+ away is **dropped, not queued** — a live picture is worth more than a backlog of
576
+ stale ones — and is counted in `dropped_frames`.
577
+
578
+ An access unit is dropped until the transport connects, because nothing sent
579
+ before it arrives. The engine therefore asks for a key frame the moment the
580
+ transport connects (below); a consumer that ignores that request shows a black
581
+ picture.
582
+
583
+ ### Key frames the engine cannot make
584
+
585
+ ```jsonc
586
+ {"event":"session.keyframeRequest","params":{"sessionId":"…","generation":1}}
587
+ ```
588
+
589
+ Raised when the receiver reports a loss it cannot repair (RTCP PLI or FIR), and
590
+ once when the transport connects. The consumer responds by feeding a keyframe
591
+ access unit — for a device helper, "reset the video stream". This is the encoded
592
+ source's version of the reference frame the engine's own encode loop sends when
593
+ the same thing happens on a captured desktop.
594
+
595
+ ### Input belongs to the consumer
596
+
597
+ For this source only, the control channel is **forwarded, not applied**:
598
+ `pointer`, `wheel`, `key`, `text` and `release_all` arrive as events, and the
599
+ consumer injects them wherever the picture came from (a device helper's own
600
+ touch injection, for instance). Coordinates are the encoded surface's own
601
+ pixels, exactly as a client sends them.
602
+
603
+ ```jsonc
604
+ {"event":"session.input","params":{"sessionId":"…",
605
+ "input":{"kind":"pointer","phase":"down","x":100,"y":200,"button":1,"seq":4}}}
606
+ ```
607
+
608
+ The event carries the control message as it arrived. The engine still validates
609
+ permission, ordering and replay before forwarding, so the client keeps the same
610
+ `ack`/`rejected` behaviour and a replayed gesture still cannot be delivered
611
+ twice. `ack` means "accepted for this session", not "applied to a desktop" —
612
+ `session.metrics.input_forwarded` counts what was handed on, and `input_applied`
613
+ stays zero. Clipboard messages are answered with an empty `clipboard` reply and
614
+ an error, because this source has no desktop clipboard: the device's clipboard
615
+ is the consumer's to carry.
616
+
617
+ `session.frame` and `session.wait_frame` are refused with `error.code =
618
+ "operation"` — there is no lossless frame to read — and `captured_frames`,
619
+ `refined_frames`, `halved_frames`, `motion_frames`, `target_kbps`, `capture_micros`,
620
+ `convert_micros`, `queue_micros` and `send_micros` stay at zero while
621
+ `fed_frames` counts what the consumer handed over and `encoded_frames`,
622
+ `encoded_bytes`, `key_frames` and `encode_micros` describe what was packetized
623
+ and sent — on this path `encode_micros` covers packetizing and sending too,
624
+ because packetization is the only stage the engine itself runs.
625
+
357
626
  ## Latest frame over the local protocol
358
627
 
359
628
  `session.frame` requires `view` permission. It writes the latest pre-encode frame as
@@ -372,7 +641,8 @@ of stillness. When both are supplied, both conditions must hold.
372
641
  Each changed capture emits a local `session.frame.changed` event with `sessionId`,
373
642
  `seq` and `damage`, allowing a co-located consumer to track animation across
374
643
  observations. Neither the event nor this file-writing method is forwarded by
375
- the remote WebSocket bridge.
644
+ the remote WebSocket bridge. A session opened with `local_frames: false` keeps
645
+ no frame: both are refused with `error.code = "operation"` (see `session.open`).
376
646
 
377
647
  ```json
378
648
  {"id":20,"method":"session.frame","params":{"session_id":"…","since":7,"path":"/run/user/1000/frame.raw"}}
@@ -416,11 +686,64 @@ session that has already ended is refused with `error.code = "session"`.
416
686
 
417
687
  Closes every session and exits.
418
688
 
689
+ ## Display keeper
690
+
691
+ ```
692
+ desklink-host keep --display :42
693
+ ```
694
+
695
+ A kiosk window manager for one X screen the consumer created itself — typically
696
+ one `Xvfb` per screen, with its own `-auth` cookie file. It is not for the
697
+ user's desktop: it takes SubstructureRedirect on the root window, so it is that
698
+ screen's window manager, and a display that already has one refuses the keeper.
699
+ Authentication is the ambient `XAUTHORITY`; a screen the consumer created should
700
+ have its own cookie file, and the keeper inherits whatever the consumer's
701
+ environment carries. There is no handshake and no stdin: the contract is a
702
+ stdout JSON-lines stream, and closing the consumer's stdout end ends the keeper.
703
+
704
+ The keeper fills the screen with every normal (non-override-redirect,
705
+ input-output) top-level window at `0,0,W,H`, raises the newest one and keeps the
706
+ input focus on the newest content window, so keyboard events an engine session
707
+ injects on that display land where a person looking at the screen would expect. A top-level window
708
+ smaller than 120 px on either side is not content — an emulator draws its side
709
+ toolbar as a tiny second window — and is parked far off-screen instead, so a
710
+ capture of the screen never shows it.
711
+
712
+ On startup, and after every change on the screen, it writes one line:
713
+
714
+ ```jsonc
715
+ {"windows":[{"id":4194305,"title":"Pricing — Acme Store - Chromium","class":"Chromium","pid":31671,"width":1280,"height":800}]}
716
+ ```
717
+
718
+ `windows` lists every visible top-level window, bottom of the stacking order
719
+ first, newest last. `id` is the X window id; `title` comes from `_NET_WM_NAME`
720
+ with `WM_NAME` as the fallback; `class` is the second string of `WM_CLASS` (the
721
+ one consumers filter on, e.g. `Chromium`, `Google-chrome`); `pid` is
722
+ `_NET_WM_PID`. Fields the window does not carry are `null`, never absent. A
723
+ change is reported after 100 ms of quiet, and never later than 300 ms after the
724
+ first unreported change, so a busy screen cannot starve its consumer.
725
+
726
+ A refused startup is one error line and a non-zero exit:
727
+
728
+ ```jsonc
729
+ {"error":"another window manager"}
730
+ ```
731
+
732
+ The same shape reports a display that could not be opened. When the display
733
+ goes away — an `Xvfb` the consumer stopped, most commonly — the keeper exits 0
734
+ on its own; a consumer treats the process ending as the screen ending.
735
+
736
+ The keeper deliberately does not decorate, move, resize on request, or remember
737
+ anything across runs. A screen with no windows produces one `"windows":[]` line
738
+ at startup and then nothing: the absence of a report is the absence of
739
+ anything to show.
740
+
419
741
  ## What the engine deliberately does not do
420
742
 
421
743
  - It does not decide *who* the user is. It trusts the consumer's local
422
744
  permission decision and enforces the resulting scope; there is no second
423
- account, token or pairing ceremony.
745
+ account, token or pairing ceremony. For consumer integration, see the
746
+ [BYOKit guidance](../../../README.md#why-desklink-exists).
424
747
  - It does not open ports, register a service, install udev rules, join groups or
425
748
  raise capabilities. Kernel input access is a separate, explicit, guided step
426
749
  (`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,13 +536,15 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
517
536
 
518
537
  [[package]]
519
538
  name = "desklink-host"
520
- version = "0.1.1"
539
+ version = "0.3.0"
521
540
  dependencies = [
522
541
  "anyhow",
523
542
  "ashpd",
524
543
  "async-trait",
544
+ "base64 0.22.1",
525
545
  "block2",
526
546
  "bytes",
547
+ "bzip2-rs",
527
548
  "cc",
528
549
  "futures-util",
529
550
  "libc",
@@ -533,12 +554,16 @@ dependencies = [
533
554
  "objc2-core-media",
534
555
  "objc2-foundation",
535
556
  "objc2-screen-capture-kit",
557
+ "openh264-sys2",
536
558
  "pipewire",
537
559
  "rand 0.8.8",
538
560
  "rtc",
539
561
  "serde",
540
562
  "serde_json",
563
+ "sha2",
541
564
  "tokio",
565
+ "wayland-client",
566
+ "wayland-protocols",
542
567
  "webrtc",
543
568
  "wl-clipboard-rs",
544
569
  "x11rb",
@@ -1429,6 +1454,16 @@ version = "1.21.4"
1429
1454
  source = "registry+https://github.com/rust-lang/crates.io-index"
1430
1455
  checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1431
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
+
1432
1467
  [[package]]
1433
1468
  name = "ordered-stream"
1434
1469
  version = "0.2.0"
@@ -2238,6 +2273,17 @@ dependencies = [
2238
2273
  "digest",
2239
2274
  ]
2240
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
+
2241
2287
  [[package]]
2242
2288
  name = "shlex"
2243
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.1.1"
3
+ version = "0.3.0"
4
4
  edition = "2021"
5
5
  rust-version = "1.97.1"
6
6
  license = "Apache-2.0"
@@ -14,6 +14,7 @@ path = "src/main.rs"
14
14
  [dependencies]
15
15
  serde = { version = "1", features = ["derive"] }
16
16
  serde_json = "1"
17
+ base64 = "0.22"
17
18
 
18
19
  [target.'cfg(target_os = "linux")'.dependencies]
19
20
  anyhow = "1"
@@ -29,8 +30,18 @@ rand = "0.8"
29
30
  ashpd = { version = "0.11", default-features = false, features = ["tokio"] }
30
31
  pipewire = { version = "0.10", features = ["v0_3_44"] }
31
32
  xkbcommon = "0.8"
32
- x11rb = { version = "0.14", default-features = false, features = ["allow-unsafe-code", "xtest"] }
33
+ x11rb = { version = "0.14", default-features = false, features = ["allow-unsafe-code", "xtest", "shm", "damage"] }
33
34
  wl-clipboard-rs = "0.9"
35
+ # H.264 fallback: Cisco's prebuilt openh264, loaded at runtime (never built
36
+ # from source), fetched once as bzip2 and checked against a pinned sha256.
37
+ openh264-sys2 = { version = "0.9.8", default-features = false, features = ["libloading"] }
38
+ sha2 = "0.10"
39
+ bzip2-rs = "0.1.2"
40
+
41
+ [target.'cfg(target_os = "linux")'.dev-dependencies]
42
+ # The Wayland stamp example only; already in the tree through wl-clipboard-rs.
43
+ wayland-client = "0.31"
44
+ wayland-protocols = { version = "0.32", features = ["client"] }
34
45
 
35
46
  [target.'cfg(target_os = "macos")'.dependencies]
36
47
  anyhow = "1"