@desklink/host 0.1.1 → 0.2.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
@@ -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.2.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
@@ -105,7 +108,7 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
105
108
  ```jsonc
106
109
  {
107
110
  "protocol": 3,
108
- "engine": "desklink-host/0.1.0",
111
+ "engine": "desklink-host/0.2.0",
109
112
  "platform": "linux",
110
113
  "session": {"kind": "wayland"},
111
114
  "x11": {"available": true, "size": [2560, 1440]},
@@ -125,10 +128,15 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
125
128
  "unavailable_reason": null,
126
129
  "grant": "granted"
127
130
  },
128
- "clipboard": {"read": true, "write": true, "mime": ["text/plain;charset=utf-8"], "maxBytes": 262144}
131
+ "clipboard": {"read": true, "write": true, "mime": ["text/plain;charset=utf-8"], "maxBytes": 262144},
132
+ "encoded": {"codecs": ["h264"]}
129
133
  }
130
134
  ```
131
135
 
136
+ `encoded` lists the codecs this build will carry when the consumer supplies the
137
+ video itself (see *Encoded sources*): a consumer reads it rather than inferring
138
+ support from the engine's version string.
139
+
132
140
  `input.mechanism` names the build's portal input path, `inputtino/uinput`,
133
141
  where the engine creates its own virtual devices. A session opened against an X
134
142
  display applies input through XTest instead, where the X server applies the
@@ -156,6 +164,7 @@ trigger a capture request.
156
164
  {"id":4,"method":"session.open","params":{
157
165
  "source": {"kind":"portal"}, // or {"kind":"x11","display":":99"}
158
166
  // macOS: {"kind":"display","display_id":123}
167
+ // or {"kind":"encoded","codec":"h264","width":486,"height":1080}
159
168
  "permissions": ["view","control","clipboard"],
160
169
  "max_width": 3840, "max_height": 2160, // encode box (default); never upscales
161
170
  "bitrate_kbps": 0, // 0 (default): sized to the encoded surface
@@ -182,8 +191,9 @@ Result:
182
191
  screen cast, which is the only path that carries a Wayland user's consent;
183
192
  `x11` reads a named X display's root window and applies input through XTest;
184
193
  `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.
194
+ main display when omitted); `encoded` carries video the consumer encoded itself.
195
+ The choice is normally the *consumer's* decision, because a client is not in a
196
+ position to know which backend a machine can offer.
187
197
 
188
198
  `permissions` are the engine's authority for this session. `view` is required
189
199
  to open a session. The engine enforces the remaining scopes on every input and
@@ -233,6 +243,22 @@ the SDP and candidates to the client and brings back the answer.
233
243
 
234
244
  A candidate that arrives before the remote description is buffered, not dropped.
235
245
 
246
+ A client whose ICE connection goes `disconnected` may restart ICE on the same
247
+ peer connection instead of opening a new session: it sends
248
+ `session.restart_ice` with the session's id and generation, and the engine
249
+ re-offers — a fresh offer with a new ICE generation — as a new
250
+ `session.description` event, which the client answers as usual. The session
251
+ and its generation survive; only the ICE generation turns over. Fresh
252
+ candidates from the restart trickle through `session.candidate` on both sides
253
+ exactly as the initial ones do.
254
+
255
+ ```jsonc
256
+ {"id":7,"method":"session.restart_ice","params":{"session_id":"…","generation":1}}
257
+ // → {"id":7,"result":{"accepted":true}}
258
+ // …then {"event":"session.description","params":{"sessionId":"…","generation":1,
259
+ // "description":{"type":"offer","sdp":"v=0\r\n…"}}}
260
+ ```
261
+
236
262
  With `loopback_tcp`, the engine also listens for ICE over TCP on
237
263
  `127.0.0.1` and offers that passive candidate. It is for a client whose only
238
264
  way to this computer is a forward of its loopback, such as an SSH tunnel: the
@@ -246,10 +272,21 @@ always does.
246
272
  ```jsonc
247
273
  {"event":"session.state","params":{"sessionId":"…",
248
274
  "capture":"streaming", // consented|streaming|ended
249
- "transport":"connected", // new|connecting|connected|failed|closed
275
+ "transport":"connected", // new|connecting|connected|disconnected|failed|closed
250
276
  "firstFrame":true}} // false until a frame has been encoded
251
277
  ```
252
278
 
279
+ `disconnected` is ICE losing the path: the picture is frozen but the session
280
+ 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.
283
+
284
+ While its control channel is open the engine also sends a heartbeat twice a
285
+ second, `{"kind":"ping"}`. It lets a client notice a stalled path within
286
+ about a second and a half, long before ICE consent timers declare it
287
+ `disconnected` — and a resumed heartbeat is the path working again. Clients
288
+ that predate it ignore the unknown kind.
289
+
253
290
  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
291
  and the one the control channel's `hello` repeats. A session keeps the one
255
292
  `generation` it was opened with: a different source or scale is a new
@@ -287,7 +324,8 @@ Metrics are a request, not a notification:
287
324
  ```jsonc
288
325
  {"id":7,"result":{"captured_frames":812,"dropped_frames":3,"encoded_frames":809,
289
326
  "encoded_bytes":12345678,"key_frames":1,"refined_frames":37,
290
- "encode_micros":7390000,"target_kbps":16430,"input_applied":44,"input_rejected":0}}
327
+ "encode_micros":7390000,"target_kbps":16430,"input_applied":44,"input_rejected":0,
328
+ "fed_frames":0,"input_forwarded":0}}
291
329
  ```
292
330
 
293
331
  `dropped_frames` counts frames superseded by a newer one before they were coded;
@@ -327,10 +365,11 @@ are JSON, one per message:
327
365
  | `release_all` | — | explicit safety net; the engine also does this on close and on channel loss |
328
366
  | `clipboard_read` / `clipboard_write` | `request` (echoed back), `text` | explicit, on user action; never polled |
329
367
 
330
- The engine answers on the same channel:
368
+ The engine answers on the same channel, and sends two things unprompted:
331
369
 
332
370
  ```jsonc
333
371
  {"kind":"hello","protocol":3,"geometry":{…}} // once, when the channel opens
372
+ {"kind":"ping"} // heartbeat, twice a second; see "State" above
334
373
  {"kind":"ack","seq":44}
335
374
  {"kind":"rejected","seq":44,"code":"coordinates","message":"(9000,4) is outside the 1280x720 surface"}
336
375
  {"kind":"clipboard","request":"…","text":"…","truncated":false} // an empty "text" with "error" when the read failed
@@ -354,6 +393,111 @@ layout can produce. macOS reports Unicode text support and currently maps
354
393
  layout is diagnostic, not proof that this map matches it. Anything outside the
355
394
  map is refused with `text-unsupported`.
356
395
 
396
+ ## Encoded sources
397
+
398
+ An `encoded` source is video the **consumer** produced: a phone mirror from a
399
+ device helper, a simulator, a camera. The engine captures nothing, encodes
400
+ nothing and holds no input device for it; it packetizes what it is handed and
401
+ returns the client's control messages to the consumer, which owns whatever
402
+ device the picture came from. That is the whole point of the source: the helper
403
+ that already speaks H.264 keeps its own encoder, and the engine keeps the
404
+ WebRTC, congestion and teardown logic that is already proven on this path.
405
+
406
+ ```jsonc
407
+ {"source": {"kind":"encoded","codec":"h264","width":486,"height":1080}}
408
+ ```
409
+
410
+ `codec` is `h264`, the only one this build carries (`capabilities.encoded`).
411
+ `width`/`height` are the stream's own pixel size, chosen by the consumer because
412
+ it is the only party that knows them (`1..7680` by `1..4320`). Nothing is scaled:
413
+ `geometry.source` and `geometry.encoded` are both that size, the client aims at
414
+ those pixels, and the consumer maps them to its device. `max_width`/
415
+ `max_height`/`bitrate_kbps` are ignored for this source, and `permissions` works
416
+ as it does anywhere else — with one difference: `control` needs no local input
417
+ backend, because nothing is applied locally.
418
+
419
+ `session.open` returns before any offer exists. The offer must name the profile
420
+ the stream actually is (`packetization-mode=1` and the SPS's own
421
+ `profile-level-id`), and the only place that is written down is the stream's own
422
+ SPS, so the engine sends `session.description` **after** the first fed access
423
+ unit that carries one. A consumer must therefore not wait for the offer before
424
+ it starts feeding, and a consumer that never feeds an SPS gets no offer at all.
425
+
426
+ ### Feeding access units
427
+
428
+ ```jsonc
429
+ {"id":8,"method":"session.feed","params":{
430
+ "session_id":"…",
431
+ "keyframe":true, // this unit is an IDR
432
+ "data_b64":"AAAA…" // one Annex-B access unit, base64
433
+ }}
434
+ ```
435
+
436
+ ```jsonc
437
+ {"id":8,"result":{"accepted":true}}
438
+ ```
439
+
440
+ One request is one access unit: start codes and all, exactly as the encoder
441
+ emitted it, with SPS/PPS in the unit that carries the IDR. `keyframe` is the
442
+ consumer's own statement about the unit; the engine uses it for `session.metrics`
443
+ and for nothing else, because the RTP payloader reads the NAL types itself. The
444
+ reply acknowledges the hand-off, not the picture: units are packetized behind
445
+ the request, and `session.metrics` reports what was sent and what was dropped.
446
+
447
+ Base64 in the same JSON line keeps one ordered channel and one inherited fd.
448
+ Measured, that is not a close call: one second of a 2 Mbit/s stream is 250 KB,
449
+ and base64 costs about 0.09 ms to encode on the consumer's side and 1.0 ms to
450
+ decode on the engine's — roughly a tenth of one per cent of a core, against the
451
+ 5 % that would have justified a second fd. Decoded units are
452
+ limited to 8 MiB (`malformed` beyond that); a unit the buffer cannot hold right
453
+ away is **dropped, not queued** — a live picture is worth more than a backlog of
454
+ stale ones — and is counted in `dropped_frames`.
455
+
456
+ An access unit is dropped until the transport connects, because nothing sent
457
+ before it arrives. The engine therefore asks for a key frame the moment the
458
+ transport connects (below); a consumer that ignores that request shows a black
459
+ picture.
460
+
461
+ ### Key frames the engine cannot make
462
+
463
+ ```jsonc
464
+ {"event":"session.keyframeRequest","params":{"sessionId":"…","generation":1}}
465
+ ```
466
+
467
+ Raised when the receiver reports a loss it cannot repair (RTCP PLI or FIR), and
468
+ once when the transport connects. The consumer responds by feeding a keyframe
469
+ access unit — for a device helper, "reset the video stream". This is the encoded
470
+ source's version of the reference frame the engine's own encode loop sends when
471
+ the same thing happens on a captured desktop.
472
+
473
+ ### Input belongs to the consumer
474
+
475
+ For this source only, the control channel is **forwarded, not applied**:
476
+ `pointer`, `wheel`, `key`, `text` and `release_all` arrive as events, and the
477
+ consumer injects them wherever the picture came from (a device helper's own
478
+ touch injection, for instance). Coordinates are the encoded surface's own
479
+ pixels, exactly as a client sends them.
480
+
481
+ ```jsonc
482
+ {"event":"session.input","params":{"sessionId":"…",
483
+ "input":{"kind":"pointer","phase":"down","x":100,"y":200,"button":1,"seq":4}}}
484
+ ```
485
+
486
+ The event carries the control message as it arrived. The engine still validates
487
+ permission, ordering and replay before forwarding, so the client keeps the same
488
+ `ack`/`rejected` behaviour and a replayed gesture still cannot be delivered
489
+ twice. `ack` means "accepted for this session", not "applied to a desktop" —
490
+ `session.metrics.input_forwarded` counts what was handed on, and `input_applied`
491
+ stays zero. Clipboard messages are answered with an empty `clipboard` reply and
492
+ an error, because this source has no desktop clipboard: the device's clipboard
493
+ is the consumer's to carry.
494
+
495
+ `session.frame` and `session.wait_frame` are refused with `error.code =
496
+ "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.
500
+
357
501
  ## Latest frame over the local protocol
358
502
 
359
503
  `session.frame` requires `view` permission. It writes the latest pre-encode frame as
@@ -416,6 +560,58 @@ session that has already ended is refused with `error.code = "session"`.
416
560
 
417
561
  Closes every session and exits.
418
562
 
563
+ ## Display keeper
564
+
565
+ ```
566
+ desklink-host keep --display :42
567
+ ```
568
+
569
+ A kiosk window manager for one X screen the consumer created itself — typically
570
+ one `Xvfb` per screen, with its own `-auth` cookie file. It is not for the
571
+ user's desktop: it takes SubstructureRedirect on the root window, so it is that
572
+ screen's window manager, and a display that already has one refuses the keeper.
573
+ Authentication is the ambient `XAUTHORITY`; a screen the consumer created should
574
+ have its own cookie file, and the keeper inherits whatever the consumer's
575
+ environment carries. There is no handshake and no stdin: the contract is a
576
+ stdout JSON-lines stream, and closing the consumer's stdout end ends the keeper.
577
+
578
+ The keeper fills the screen with every normal (non-override-redirect,
579
+ input-output) top-level window at `0,0,W,H`, raises the newest one and keeps the
580
+ input focus on the newest content window, so keyboard events an engine session
581
+ injects on that display land where a person looking at the screen would expect. A top-level window
582
+ smaller than 120 px on either side is not content — an emulator draws its side
583
+ toolbar as a tiny second window — and is parked far off-screen instead, so a
584
+ capture of the screen never shows it.
585
+
586
+ On startup, and after every change on the screen, it writes one line:
587
+
588
+ ```jsonc
589
+ {"windows":[{"id":4194305,"title":"Pricing — Acme Store - Chromium","class":"Chromium","pid":31671,"width":1280,"height":800}]}
590
+ ```
591
+
592
+ `windows` lists every visible top-level window, bottom of the stacking order
593
+ first, newest last. `id` is the X window id; `title` comes from `_NET_WM_NAME`
594
+ with `WM_NAME` as the fallback; `class` is the second string of `WM_CLASS` (the
595
+ one consumers filter on, e.g. `Chromium`, `Google-chrome`); `pid` is
596
+ `_NET_WM_PID`. Fields the window does not carry are `null`, never absent. A
597
+ change is reported after 100 ms of quiet, and never later than 300 ms after the
598
+ first unreported change, so a busy screen cannot starve its consumer.
599
+
600
+ A refused startup is one error line and a non-zero exit:
601
+
602
+ ```jsonc
603
+ {"error":"another window manager"}
604
+ ```
605
+
606
+ The same shape reports a display that could not be opened. When the display
607
+ goes away — an `Xvfb` the consumer stopped, most commonly — the keeper exits 0
608
+ on its own; a consumer treats the process ending as the screen ending.
609
+
610
+ The keeper deliberately does not decorate, move, resize on request, or remember
611
+ anything across runs. A screen with no windows produces one `"windows":[]` line
612
+ at startup and then nothing: the absence of a report is the absence of
613
+ anything to show.
614
+
419
615
  ## What the engine deliberately does not do
420
616
 
421
617
  - It does not decide *who* the user is. It trusts the consumer's local
package/engine/Cargo.lock CHANGED
@@ -517,11 +517,12 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
517
517
 
518
518
  [[package]]
519
519
  name = "desklink-host"
520
- version = "0.1.1"
520
+ version = "0.2.0"
521
521
  dependencies = [
522
522
  "anyhow",
523
523
  "ashpd",
524
524
  "async-trait",
525
+ "base64 0.22.1",
525
526
  "block2",
526
527
  "bytes",
527
528
  "cc",
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.2.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"