@desklink/host 0.1.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 (142) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +44 -0
  3. package/README.md +266 -0
  4. package/bin/desklink-host.mjs +113 -0
  5. package/dist/bridge.d.ts +57 -0
  6. package/dist/bridge.d.ts.map +1 -0
  7. package/dist/bridge.js +208 -0
  8. package/dist/bridge.js.map +1 -0
  9. package/dist/engineProcess.d.ts +82 -0
  10. package/dist/engineProcess.d.ts.map +1 -0
  11. package/dist/engineProcess.js +243 -0
  12. package/dist/engineProcess.js.map +1 -0
  13. package/dist/index.d.ts +12 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +12 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/protocol.d.ts +169 -0
  18. package/dist/protocol.d.ts.map +1 -0
  19. package/dist/protocol.js +18 -0
  20. package/dist/protocol.js.map +1 -0
  21. package/dist/resolveEngine.d.ts +34 -0
  22. package/dist/resolveEngine.d.ts.map +1 -0
  23. package/dist/resolveEngine.js +103 -0
  24. package/dist/resolveEngine.js.map +1 -0
  25. package/docs/BOUNDARY.md +145 -0
  26. package/docs/PROTOCOL.md +363 -0
  27. package/engine/Cargo.lock +3077 -0
  28. package/engine/Cargo.toml +37 -0
  29. package/engine/build.rs +107 -0
  30. package/engine/native/inputtino_shim.cpp +11 -0
  31. package/engine/native/vpx_shim.c +184 -0
  32. package/engine/native/wrapper.h +7 -0
  33. package/engine/src/capture.rs +510 -0
  34. package/engine/src/clipboard.rs +132 -0
  35. package/engine/src/convert.rs +299 -0
  36. package/engine/src/encoder.rs +204 -0
  37. package/engine/src/input.rs +355 -0
  38. package/engine/src/keymap.rs +338 -0
  39. package/engine/src/main.rs +614 -0
  40. package/engine/src/peer.rs +775 -0
  41. package/engine/src/portal.rs +115 -0
  42. package/engine/src/protocol.rs +331 -0
  43. package/engine/src/session.rs +2159 -0
  44. package/engine/src/x11.rs +244 -0
  45. package/engine/vendor/inputtino/.clang-format +166 -0
  46. package/engine/vendor/inputtino/.clang-tidy +30 -0
  47. package/engine/vendor/inputtino/.dockerignore +11 -0
  48. package/engine/vendor/inputtino/.github/workflows/mirror-repo.yml +14 -0
  49. package/engine/vendor/inputtino/.github/workflows/python-lint-format-test-typecheck.yml +124 -0
  50. package/engine/vendor/inputtino/.github/workflows/python-test-install.yaml +49 -0
  51. package/engine/vendor/inputtino/.github/workflows/rust.yml +40 -0
  52. package/engine/vendor/inputtino/CMakeLists.txt +187 -0
  53. package/engine/vendor/inputtino/LICENSE +21 -0
  54. package/engine/vendor/inputtino/README.md +110 -0
  55. package/engine/vendor/inputtino/bindings/python/.devcontainer/Dockerfile +23 -0
  56. package/engine/vendor/inputtino/bindings/python/.devcontainer/devcontainer.json +28 -0
  57. package/engine/vendor/inputtino/bindings/python/.python-version +1 -0
  58. package/engine/vendor/inputtino/bindings/python/CMakeLists.txt +23 -0
  59. package/engine/vendor/inputtino/bindings/python/CPPLINT.cfg +2 -0
  60. package/engine/vendor/inputtino/bindings/python/Makefile +49 -0
  61. package/engine/vendor/inputtino/bindings/python/README.md +215 -0
  62. package/engine/vendor/inputtino/bindings/python/pyproject.toml +178 -0
  63. package/engine/vendor/inputtino/bindings/python/src/inputtino/__init__.py +43 -0
  64. package/engine/vendor/inputtino/bindings/python/src/inputtino/_core.pyi +533 -0
  65. package/engine/vendor/inputtino/bindings/python/src/inputtino/base.py +63 -0
  66. package/engine/vendor/inputtino/bindings/python/src/inputtino/joypad.py +248 -0
  67. package/engine/vendor/inputtino/bindings/python/src/inputtino/keyboard.py +256 -0
  68. package/engine/vendor/inputtino/bindings/python/src/inputtino/mouse.py +106 -0
  69. package/engine/vendor/inputtino/bindings/python/src/inputtino/pentablet.py +73 -0
  70. package/engine/vendor/inputtino/bindings/python/src/inputtino/py.typed +0 -0
  71. package/engine/vendor/inputtino/bindings/python/src/inputtino/touchscreen.py +61 -0
  72. package/engine/vendor/inputtino/bindings/python/src/inputtino/trackpad.py +74 -0
  73. package/engine/vendor/inputtino/bindings/python/src/main.cpp +217 -0
  74. package/engine/vendor/inputtino/bindings/python/tests/__init__.py +0 -0
  75. package/engine/vendor/inputtino/bindings/python/tests/helpers.py +3 -0
  76. package/engine/vendor/inputtino/bindings/python/tests/test_base.py +60 -0
  77. package/engine/vendor/inputtino/bindings/python/tests/test_inputtino.py +5 -0
  78. package/engine/vendor/inputtino/bindings/python/tests/test_joypad.py +332 -0
  79. package/engine/vendor/inputtino/bindings/python/tests/test_keyboard.py +164 -0
  80. package/engine/vendor/inputtino/bindings/python/tests/test_mouse.py +185 -0
  81. package/engine/vendor/inputtino/bindings/python/tests/test_pentablet.py +198 -0
  82. package/engine/vendor/inputtino/bindings/python/tests/test_touchscreen.py +144 -0
  83. package/engine/vendor/inputtino/bindings/python/tests/test_trackpad.py +146 -0
  84. package/engine/vendor/inputtino/bindings/python/uv.lock +390 -0
  85. package/engine/vendor/inputtino/bindings/rust/Cargo.toml +6 -0
  86. package/engine/vendor/inputtino/bindings/rust/inputtino/.dockerignore +1 -0
  87. package/engine/vendor/inputtino/bindings/rust/inputtino/Cargo.toml +18 -0
  88. package/engine/vendor/inputtino/bindings/rust/inputtino/examples/keyboard.rs +33 -0
  89. package/engine/vendor/inputtino/bindings/rust/inputtino/examples/mouse.rs +17 -0
  90. package/engine/vendor/inputtino/bindings/rust/inputtino/src/common.rs +116 -0
  91. package/engine/vendor/inputtino/bindings/rust/inputtino/src/error.rs +17 -0
  92. package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad.rs +74 -0
  93. package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad_nintendo.rs +135 -0
  94. package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad_ps5.rs +280 -0
  95. package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad_xbox.rs +134 -0
  96. package/engine/vendor/inputtino/bindings/rust/inputtino/src/keyboard.rs +76 -0
  97. package/engine/vendor/inputtino/bindings/rust/inputtino/src/lib.rs +32 -0
  98. package/engine/vendor/inputtino/bindings/rust/inputtino/src/mouse.rs +178 -0
  99. package/engine/vendor/inputtino/bindings/rust/inputtino/tests/common.rs +38 -0
  100. package/engine/vendor/inputtino/bindings/rust/inputtino/tests/joypads.rs +150 -0
  101. package/engine/vendor/inputtino/bindings/rust/inputtino/tests/keyboard.rs +73 -0
  102. package/engine/vendor/inputtino/bindings/rust/inputtino/tests/mouse.rs +139 -0
  103. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/.dockerignore +1 -0
  104. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/Cargo.toml +12 -0
  105. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/build.rs +48 -0
  106. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/src/lib.rs +5 -0
  107. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/wrapper.h +1 -0
  108. package/engine/vendor/inputtino/cmake/utils.cmake +9 -0
  109. package/engine/vendor/inputtino/docker/Dockerfile +70 -0
  110. package/engine/vendor/inputtino/include/inputtino/input.h +324 -0
  111. package/engine/vendor/inputtino/include/inputtino/input.hpp +466 -0
  112. package/engine/vendor/inputtino/include/inputtino/result.hpp +37 -0
  113. package/engine/vendor/inputtino/share/pkgconfig/libinputtino.pc.in +17 -0
  114. package/engine/vendor/inputtino/src/c-bindings/helpers.hpp +19 -0
  115. package/engine/vendor/inputtino/src/c-bindings/joypad_ps5.cpp +115 -0
  116. package/engine/vendor/inputtino/src/c-bindings/joypad_switch.cpp +63 -0
  117. package/engine/vendor/inputtino/src/c-bindings/joypad_xone.cpp +62 -0
  118. package/engine/vendor/inputtino/src/c-bindings/keyboard.cpp +42 -0
  119. package/engine/vendor/inputtino/src/c-bindings/mouse.cpp +67 -0
  120. package/engine/vendor/inputtino/src/c-bindings/pen_tablet.cpp +50 -0
  121. package/engine/vendor/inputtino/src/c-bindings/touchscreen.cpp +43 -0
  122. package/engine/vendor/inputtino/src/c-bindings/trackpad.cpp +49 -0
  123. package/engine/vendor/inputtino/src/uhid/README.adoc +63 -0
  124. package/engine/vendor/inputtino/src/uhid/include/crc32.hpp +38 -0
  125. package/engine/vendor/inputtino/src/uhid/include/uhid/protected_types.hpp +36 -0
  126. package/engine/vendor/inputtino/src/uhid/include/uhid/ps5.hpp +550 -0
  127. package/engine/vendor/inputtino/src/uhid/include/uhid/uhid.hpp +168 -0
  128. package/engine/vendor/inputtino/src/uhid/joypad_ps5.cpp +580 -0
  129. package/engine/vendor/inputtino/src/uinput/include/inputtino/keyboard.hpp +84 -0
  130. package/engine/vendor/inputtino/src/uinput/include/inputtino/protected_ps5_types.hpp +7 -0
  131. package/engine/vendor/inputtino/src/uinput/include/inputtino/protected_types.hpp +110 -0
  132. package/engine/vendor/inputtino/src/uinput/joypad_nintendo.cpp +190 -0
  133. package/engine/vendor/inputtino/src/uinput/joypad_ps.cpp +203 -0
  134. package/engine/vendor/inputtino/src/uinput/joypad_utils.hpp +246 -0
  135. package/engine/vendor/inputtino/src/uinput/joypad_xbox.cpp +190 -0
  136. package/engine/vendor/inputtino/src/uinput/keyboard.cpp +120 -0
  137. package/engine/vendor/inputtino/src/uinput/mouse.cpp +199 -0
  138. package/engine/vendor/inputtino/src/uinput/pentablet.cpp +162 -0
  139. package/engine/vendor/inputtino/src/uinput/touchscreen.cpp +140 -0
  140. package/engine/vendor/inputtino/src/uinput/trackpad.cpp +192 -0
  141. package/examples/reference-client.html +428 -0
  142. package/package.json +38 -0
@@ -0,0 +1,363 @@
1
+ # desklink local control protocol (v2)
2
+
3
+ The host engine is a per-user, on-demand process. A consumer that already owns
4
+ the user's session starts it and talks to it over an inherited private channel —
5
+ the engine never opens a public listener, never runs as root, and never installs
6
+ a service.
7
+
8
+ This document is the engine's whole public surface. It contains no application
9
+ concepts: no accounts, no chat, no machine ids, no pane ids.
10
+
11
+ ## Starting the engine
12
+
13
+ ```
14
+ desklink-host serve
15
+ ```
16
+
17
+ The consumer spawns this process, keeps its `stdin`/`stdout` and parses them as
18
+ newline-delimited JSON. `stdout` carries protocol messages only; diagnostics go
19
+ to `stderr`. One JSON object per line, no length prefix, UTF-8.
20
+
21
+ Why stdio rather than a socket: the consumer already holds the process, so the
22
+ channel inherits the consumer's own access control (same uid, same session, no
23
+ filesystem permission to get wrong) and no second authorization mechanism has to
24
+ exist. A consumer that wants a socket can wrap this process.
25
+
26
+ Cancellation is by closing the consumer's end. The engine treats EOF on `stdin`
27
+ exactly like `shutdown`: it releases held input, stops capture, closes peers, and
28
+ exits.
29
+
30
+ ## Message shape
31
+
32
+ Requests (consumer → engine) carry an `id`; responses repeat it. Engine-initiated
33
+ notifications carry `event` and never an `id`.
34
+
35
+ ```jsonc
36
+ // request
37
+ {"id": 7, "method": "session.open", "params": { }}
38
+ // success
39
+ {"id": 7, "result": { }}
40
+ // failure — the request was refused or could not run
41
+ {"id": 7, "error": {"code": "unsupported-source", "message": "human readable"}}
42
+ // notification
43
+ {"event": "session.state", "params": { }}
44
+ ```
45
+
46
+ `error.code` is a stable machine token; `message` is for logs, not for display.
47
+
48
+ Every engine notification carries the `sessionId` it belongs to, so a consumer
49
+ that has served more than one session in a process can attribute it to the
50
+ right one.
51
+
52
+ Request parameters use the engine's own snake_case names (`session_id`,
53
+ `max_width`, `sdp_mid`, …); the examples below are the wire names to send.
54
+
55
+ ## Handshake
56
+
57
+ ```jsonc
58
+ {"id":1,"method":"hello","params":{"protocol":2}}
59
+ ```
60
+
61
+ Result:
62
+
63
+ ```jsonc
64
+ {"protocol":2,"engine":"desklink-host/0.1.0","platform":"linux", /* the rest of Capabilities */ }
65
+ ```
66
+
67
+ The consumer must send `hello` first, and a version the engine does not speak is
68
+ refused with `error.code = "unsupported-protocol"`. Until a supported `hello`
69
+ arrives, every other request is refused the same way; `capabilities` and
70
+ `shutdown` are the two that answer without a handshake.
71
+
72
+ ## Capabilities
73
+
74
+ ```jsonc
75
+ {"id":2,"method":"capabilities"}
76
+ ```
77
+
78
+ ```jsonc
79
+ {
80
+ "protocol": 2,
81
+ "engine": "desklink-host/0.1.0",
82
+ "platform": "linux",
83
+ "session": {"kind": "wayland"},
84
+ "x11": {"available": true, "size": [2560, 1440]},
85
+ "capture": {
86
+ "mechanism": "portal-screencast+pipewire",
87
+ "backends": ["portal-screencast+pipewire", "x11-root"],
88
+ "formats": ["bgrx", "bgra", "rgbx", "rgba"],
89
+ "cursor": "embedded",
90
+ "audio": false
91
+ },
92
+ "encode": {"codecs": ["vp9"], "hardware": false},
93
+ "input": {
94
+ "mechanism": "inputtino/uinput",
95
+ "pointer": true, "wheel": true, "keyboard": true,
96
+ "text": ["layout-reachable"],
97
+ "layout": "us",
98
+ "unavailable_reason": null,
99
+ "grant": "granted"
100
+ },
101
+ "clipboard": {"read": true, "write": true, "mime": ["text/plain;charset=utf-8"], "maxBytes": 262144}
102
+ }
103
+ ```
104
+
105
+ `input.mechanism` names the build's portal input path, `inputtino/uinput`,
106
+ where the engine creates its own virtual devices. A session opened against an X
107
+ display applies input through XTest instead, where the X server applies the
108
+ events and nothing the engine does can reach another session's keyboard.
109
+ `input.layout` is the keymap the engine compiled for typing, taken from the
110
+ session's `XKB_DEFAULT_*` variables and `us` on `evdev`/`pc105` when it exports
111
+ none. It is not yet the compositor's live layout, so a mismatch with the
112
+ desktop is visible here rather than silent. `capture.backends` lists what this
113
+ build has, not what this machine can necessarily use.
114
+
115
+ `input.unavailable_reason` and `input.grant` describe the portal/uinput path:
116
+ without kernel input access a portal session may request `view`, but an open
117
+ requesting `control` is refused. X display capture uses XTest instead and does
118
+ not require that grant. The muxr host and WebSocket bridge report clipboard
119
+ unavailable for an X display, regardless of the engine's Wayland clipboard
120
+ probe. The engine never asks for privileges on its own.
121
+
122
+ `capabilities` is safe to call before any consent has been given and must not
123
+ trigger a capture request.
124
+
125
+ ## Opening a session
126
+
127
+ ```jsonc
128
+ {"id":4,"method":"session.open","params":{
129
+ "source": {"kind":"portal"}, // or {"kind":"x11","display":":99"}
130
+ "permissions": ["view","control","clipboard"],
131
+ "max_width": 3840, "max_height": 2160, // encode box (default); never upscales
132
+ "bitrate_kbps": 0, // 0 (default): sized to the encoded surface
133
+ "max_fps": 30,
134
+ "ice_servers": [{"urls":["stun:..."],"username":null,"credential":null}],
135
+ "restore_token": null, // from a previous session.restoreToken
136
+ "ttl_seconds": 3600, // session lease; default 3600
137
+ "loopback_tcp": false // also offer ICE over TCP on 127.0.0.1; default false
138
+ }}
139
+ ```
140
+
141
+ Result:
142
+
143
+ ```jsonc
144
+ {"sessionId":"<opaque>","generation":1,
145
+ "source":{"kind":"monitor","width":2560,"height":1440,"origin":{"x":0,"y":0}},
146
+ "geometry":{"source":{"width":2560,"height":1440},
147
+ "encoded":{"width":2560,"height":1440},
148
+ "origin":{"x":0,"y":0}}}
149
+ ```
150
+
151
+ `source` selects the desktop. `portal` (or absent) asks the compositor for a
152
+ screen cast, which is the only path that carries a Wayland user's consent;
153
+ `x11` reads a named X display's root window and applies input through XTest. The
154
+ choice is capability-based rather than a table of desktops: the portal when there
155
+ is one, an X display otherwise. It is normally the *consumer's* decision, because
156
+ a client is not in a position to know which of the two a machine can offer.
157
+
158
+ `permissions` are the engine's authority for this session. The engine enforces
159
+ them on every input and clipboard action and never infers them from a source, a
160
+ peer or an SDP. `control` without a working input backend is refused at
161
+ `session.open` with `error.code = "input-unavailable"` — the consumer must ask
162
+ for `view` explicitly instead.
163
+
164
+ `max_fps` defaults to 30. It caps the encoded frame rate and the X11 capture
165
+ loop; portal capture offers it as the preferred PipeWire frame rate (clamped
166
+ to the offered 0–1000 fps range). The source may still supply fewer frames.
167
+
168
+ `session.open` is where the capture request happens, which is where the user's
169
+ consent appears. It is not implicit and it is not retried silently.
170
+
171
+ ### Description and ICE exchange
172
+
173
+ The engine is the media offerer; the consumer's authenticated signaling carries
174
+ the SDP and candidates to the client and brings back the answer.
175
+
176
+ ```jsonc
177
+ {"event":"session.description","params":{"sessionId":"…","generation":1,
178
+ "description":{"type":"offer","sdp":"v=0\r\n…"}}}
179
+ ```
180
+
181
+ ```jsonc
182
+ {"id":5,"method":"session.description","params":{"session_id":"…","generation":1,
183
+ "description":{"type":"answer","sdp":"v=0\r\n…"}}}
184
+ ```
185
+
186
+ ```jsonc
187
+ {"event":"session.candidate","params":{"sessionId":"…","generation":1,
188
+ "candidate":"candidate:…","sdpMid":"0","sdpMLineIndex":0}}
189
+ {"id":6,"method":"session.candidate","params":{"session_id":"…","generation":1,
190
+ "candidate":"candidate:…","sdp_mid":"0","sdp_m_line_index":0}}
191
+ ```
192
+
193
+ A candidate that arrives before the remote description is buffered, not dropped.
194
+
195
+ With `loopback_tcp`, the engine also listens for ICE over TCP on
196
+ `127.0.0.1` and offers that passive candidate. It is for a client whose only
197
+ way to this computer is a forward of its loopback, such as an SSH tunnel: the
198
+ client forwards that port over the connection it already has and connects to
199
+ its own end of the forward. The picture and control channel then ride that
200
+ forward, still inside DTLS. A client with a UDP path prefers it, as ICE
201
+ always does.
202
+
203
+ ### State
204
+
205
+ ```jsonc
206
+ {"event":"session.state","params":{"sessionId":"…",
207
+ "capture":"streaming", // consented|streaming|ended
208
+ "transport":"connected", // new|connecting|connected|failed|closed
209
+ "firstFrame":true}} // false until a frame has been encoded
210
+ ```
211
+
212
+ The `geometry` a client maps touch against is the one `session.open` returned,
213
+ and the one the control channel's `hello` repeats. A session keeps the one
214
+ `generation` it was opened with: a different source or scale is a new
215
+ `session.open`, not a generation change within a session.
216
+
217
+ ```jsonc
218
+ {"event":"session.restoreToken","params":{"sessionId":"…","token":"…"}}
219
+ ```
220
+
221
+ The portal handed back a restore token for a later `session.open`. The engine
222
+ requests persistence until explicitly revoked (`persist_mode = 2`); the portal
223
+ still decides whether to grant it. Tokens are single-use: consume a saved token
224
+ before sending it, and atomically persist each replacement in private host-local
225
+ storage, even if it arrives before the open response. Never log tokens or forward
226
+ them to a remote controller. A consumer that does not persist one can ignore this
227
+ notification and request consent again next time. The WebSocket bridge delivers
228
+ this event only to its host-local `engineOptions.onEvent`, not to sockets.
229
+
230
+ A revoked grant or unavailable source falls back to the portal's normal consent
231
+ picker; restoration is silent only where the backend supports it. Cancellation
232
+ is an error, not a reason to retry or bypass consent.
233
+
234
+ ```jsonc
235
+ {"event":"session.revoked","params":{"sessionId":"…","reason":"…"}}
236
+ ```
237
+
238
+ The session ended on the engine's side; there is nothing further to drain.
239
+
240
+ Metrics are a request, not a notification:
241
+
242
+ ```jsonc
243
+ {"id":7,"method":"session.metrics","params":{"session_id":"…"}}
244
+ ```
245
+
246
+ ```jsonc
247
+ {"id":7,"result":{"captured_frames":812,"dropped_frames":3,"encoded_frames":809,
248
+ "encoded_bytes":12345678,"key_frames":1,"refined_frames":37,
249
+ "encode_micros":7390000,"target_kbps":16430,"input_applied":44,"input_rejected":0}}
250
+ ```
251
+
252
+ `dropped_frames` counts frames superseded by a newer one before they were coded;
253
+ the newest frame is always coded. `refined_frames` counts refinement passes: a
254
+ desktop that stops changing is coded once more at a fine quantizer, so it reads
255
+ sharp. Key frames are sent only for the first frame and when the receiver asks
256
+ (RTCP PLI or FIR). `target_kbps` is the current rate target after any back-off
257
+ for loss the receiver reported.
258
+
259
+ ## Input and clipboard: the session's control channel
260
+
261
+ Input does **not** travel the local protocol. It rides the WebRTC data channel
262
+ the engine creates for the session, which is inside the DTLS/SRTP session that
263
+ was opened for one authorized grant: it inherits that session's identity and dies
264
+ with it, so a client that kept a socket open cannot keep driving a session that
265
+ was revoked, and the consumer's request path never carries a pointer move.
266
+
267
+ The engine is the offerer and creates a data channel labelled `control`. Messages
268
+ are JSON, one per message:
269
+
270
+ ```jsonc
271
+ {"kind":"pointer","phase":"down","x":812,"y":433,"button":1,"seq":44}
272
+ {"kind":"wheel","dx":0,"dy":2,"seq":45}
273
+ {"kind":"key","name":"Enter","down":true,"modifiers":["Control"],"seq":46}
274
+ {"kind":"text","text":"hello","seq":47}
275
+ {"kind":"release_all"}
276
+ {"kind":"clipboard_read","request":"<opaque>","seq":48}
277
+ {"kind":"clipboard_write","request":"<opaque>","text":"…","seq":49}
278
+ ```
279
+
280
+ | kind | fields | notes |
281
+ |---|---|---|
282
+ | `pointer` | `phase`: `move`\|`down`\|`up`\|`cancel`, `x`, `y`, `button` (default 1) | `x`/`y` are integers in the **encoded surface's** own pixels, i.e. `geometry.encoded` |
283
+ | `wheel` | `dx`, `dy` (detents; fractions allowed) | Positive `dx` scrolls right; positive `dy` scrolls down. A fraction is a smooth partial scroll on a desktop with high-resolution wheel support (120 units per detent); an X display receives whole detents as they accumulate. Native adapters normalize platform wheel signs. |
284
+ | `key` | `name` (a named key or modifier) or `character` (one character), `down`, `modifiers` | the engine maps `character` through the layout it compiled (`input.layout` in `capabilities`), and refuses a character that layout cannot produce |
285
+ | `text` | `text` (≤ 4096 bytes) | applied as real key events, not as a clipboard paste |
286
+ | `release_all` | — | explicit safety net; the engine also does this on close and on channel loss |
287
+ | `clipboard_read` / `clipboard_write` | `request` (echoed back), `text` | explicit, on user action; never polled |
288
+
289
+ The engine answers on the same channel:
290
+
291
+ ```jsonc
292
+ {"kind":"hello","protocol":2,"geometry":{…}} // once, when the channel opens
293
+ {"kind":"ack","seq":44}
294
+ {"kind":"rejected","seq":44,"code":"coordinates","message":"(9000,4) is outside the 1280x720 surface"}
295
+ {"kind":"clipboard","request":"…","text":"…","truncated":false} // an empty "text" with "error" when the read failed
296
+ {"kind":"revoked","reason":"…"}
297
+ ```
298
+
299
+ Refusal codes: `permission`, `session`, `input-replay`, `coordinates`,
300
+ `text-too-large`, `text-unsupported`, `input-unavailable`, `operation`.
301
+
302
+ `seq` is monotonic per session. A repeat or a lower value is refused with
303
+ `input-replay` and **no side effect**, so a replayed message cannot move the
304
+ pointer twice. Validation, permission and ordering checks all run before any
305
+ physical effect.
306
+
307
+ The engine holds its own pressed-key/button state. `close`, `release_all`,
308
+ control-channel loss and process exit all synthesize releases for exactly the
309
+ keys and buttons this session pressed, so a dropped connection cannot leave a
310
+ modifier stuck down on the desktop. `input.text` in `capabilities` states the
311
+ characters the compiled layout can actually produce; anything else is refused
312
+ with `text-unsupported` and the honest workaround is the explicit clipboard.
313
+
314
+ ## Clipboard over the local protocol
315
+
316
+ The control channel is the path a controller uses (above). A co-located consumer
317
+ — a test harness, or a script on the same machine — can use the local protocol
318
+ instead; both reach the same clipboard:
319
+
320
+ ```jsonc
321
+ {"id":9,"method":"session.clipboard.read","params":{"session_id":"…"}}
322
+ {"id":9,"result":{"text":"…","truncated":false}}
323
+ {"id":10,"method":"session.clipboard.write","params":{"session_id":"…","text":"…"}}
324
+ {"id":10,"result":{"written":true}}
325
+ ```
326
+
327
+ Refused with `error.code = "clipboard"` when the session lacks `clipboard`, or
328
+ when the desktop's clipboard has no text. A read is bounded by
329
+ `capabilities.clipboard.maxBytes`; when the desktop's text is longer, `truncated`
330
+ is true and the text is cut on a character boundary, so the returned text is
331
+ still valid.
332
+
333
+ ## Close
334
+
335
+ ```jsonc
336
+ {"id":12,"method":"session.close","params":{"session_id":"…"}}
337
+ ```
338
+
339
+ Closing is terminal for that session: held input is released, capture stops,
340
+ the peer is closed and the consent-bearing PipeWire stream is dropped. A close
341
+ naming a generation that is no longer current is refused with
342
+ `error.code = "generation"` and never closes a newer session; a close for a
343
+ session that has already ended is refused with `error.code = "session"`.
344
+
345
+ ```jsonc
346
+ {"id":13,"method":"shutdown"}
347
+ ```
348
+
349
+ Closes every session and exits.
350
+
351
+ ## What the engine deliberately does not do
352
+
353
+ - It does not decide *who* the user is. It trusts the consumer's local
354
+ permission decision and enforces the resulting scope; there is no second
355
+ account, token or pairing ceremony.
356
+ - It does not open ports, register a service, install udev rules, join groups or
357
+ raise capabilities. Kernel input access is a separate, explicit, guided step
358
+ (`desklink-host setup-input` reports what is missing; it does not change it).
359
+ - It does not carry audio. A phone watching and driving a desktop needs pixels
360
+ and input; audio is a different product decision.
361
+ - It does not arbitrate between two controllers. One session, one controller.
362
+ - It does not auto-reconnect with authority it no longer holds: after a lease
363
+ expiry or owner disconnect it requires a fresh `session.open`.