@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,614 @@
1
+ //! `desklink-host` — per-user, on-demand desktop capture, encode and input engine.
2
+ //!
3
+ //! The public surface is the versioned local control protocol documented in
4
+ //! `docs/PROTOCOL.md`. It knows nothing about the application that consumes it:
5
+ //! no accounts, no chat, no machine identity, no pane. A consumer starts this
6
+ //! process, owns its stdin/stdout, and carries the SDP/ICE the engine produces
7
+ //! over whatever authenticated channel it already has.
8
+
9
+ mod capture;
10
+ mod clipboard;
11
+ mod convert;
12
+ mod encoder;
13
+ mod input;
14
+ mod keymap;
15
+ mod peer;
16
+ mod portal;
17
+ mod protocol;
18
+ mod session;
19
+ mod x11;
20
+
21
+ use anyhow::Result;
22
+ use protocol::{ErrorBody, Event, Request, Response};
23
+ use std::sync::mpsc as std_mpsc;
24
+ use std::time::Duration;
25
+ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
26
+ use tokio::sync::mpsc as tokio_mpsc;
27
+
28
+ fn main() {
29
+ let args: Vec<String> = std::env::args().skip(1).collect();
30
+ let command = args.first().map(String::as_str).unwrap_or("help");
31
+ let runtime = match tokio::runtime::Builder::new_multi_thread()
32
+ .enable_all()
33
+ .build()
34
+ {
35
+ Ok(runtime) => runtime,
36
+ Err(error) => {
37
+ eprintln!("error: cannot start the runtime: {error}");
38
+ std::process::exit(1);
39
+ }
40
+ };
41
+ let code = match command {
42
+ "version" | "--version" | "-V" => {
43
+ println!("{}", env!("CARGO_PKG_VERSION"));
44
+ 0
45
+ }
46
+ "capabilities" => {
47
+ println!(
48
+ "{}",
49
+ serde_json::to_string_pretty(&session::capabilities()).unwrap()
50
+ );
51
+ 0
52
+ }
53
+ "serve" => report(runtime.block_on(serve())),
54
+ "capture-probe" => report(runtime.block_on(probe(
55
+ args.get(1).and_then(|value| value.parse().ok()),
56
+ args.get(2).map(String::as_str),
57
+ ))),
58
+ "setup-input" => {
59
+ print_input_setup();
60
+ 0
61
+ }
62
+ "help" | "--help" | "-h" => {
63
+ print_help();
64
+ 0
65
+ }
66
+ other => {
67
+ eprintln!("unknown command: {other}");
68
+ print_help();
69
+ 2
70
+ }
71
+ };
72
+ std::process::exit(code);
73
+ }
74
+
75
+ fn report(result: Result<()>) -> i32 {
76
+ match result {
77
+ Ok(()) => 0,
78
+ Err(error) => {
79
+ eprintln!("error: {error:#}");
80
+ 1
81
+ }
82
+ }
83
+ }
84
+
85
+ fn print_help() {
86
+ println!(
87
+ "desklink-host {}
88
+
89
+ USAGE:
90
+ desklink-host serve speak the local control protocol on stdin/stdout
91
+ desklink-host capabilities print what this machine can do right now
92
+ desklink-host capture-probe [seconds] [display]
93
+ capture frames and report the stream; with a
94
+ display argument it reads that X display's root
95
+ window instead of asking the portal
96
+ desklink-host setup-input explain the one-time input-access step (changes nothing)
97
+ desklink-host version
98
+
99
+ The engine is started by a consumer that already owns the user's session. It
100
+ never listens on a public interface, never runs as root, and never installs
101
+ anything. See docs/PROTOCOL.md for the protocol.",
102
+ env!("CARGO_PKG_VERSION")
103
+ );
104
+ }
105
+
106
+ /// Deliberately does not perform the change. Kernel input access is a privileged
107
+ /// decision, so the engine explains it and stops; the user runs the command.
108
+ fn print_input_setup() {
109
+ match input::probe() {
110
+ Ok(()) => {
111
+ println!("Kernel input access is already available on this machine.");
112
+ println!("Nothing to do: the engine will create its own virtual pointer and keyboard");
113
+ println!("for the duration of a session and destroy them when it ends.");
114
+ }
115
+ Err(unavailable) => {
116
+ println!(
117
+ "Kernel input access is NOT available: {}",
118
+ unavailable.reason
119
+ );
120
+ println!();
121
+ println!("This engine injects pointer and keyboard events by creating its own virtual");
122
+ println!(
123
+ "devices through the kernel's uinput interface. That is whole-desktop control"
124
+ );
125
+ println!("of the logged-in session, not control of one application, and it is not");
126
+ println!("something an installation is allowed to grant for you.");
127
+ println!();
128
+ println!("A one-time, narrowly scoped rule for the uinput device alone is:");
129
+ println!();
130
+ println!(" # /etc/udev/rules.d/70-desklink-uinput.rules");
131
+ println!(" KERNEL==\"uinput\", SUBSYSTEM==\"misc\", MODE=\"0660\", GROUP=\"desklink-input\"");
132
+ println!();
133
+ println!(" sudo groupadd --system desklink-input");
134
+ println!(" sudo usermod -aG desklink-input \"$USER\"");
135
+ println!(
136
+ " sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=uinput"
137
+ );
138
+ println!();
139
+ println!(
140
+ "That is deliberately narrower than joining the `input` group, which would also"
141
+ );
142
+ println!(
143
+ "expose the physical keyboards and mice. Group membership usually needs a fresh"
144
+ );
145
+ println!("login before this process sees it.");
146
+ println!();
147
+ println!(
148
+ "To undo it: remove the rule, remove the user from the group, reload the rules."
149
+ );
150
+ println!("Without it the engine still captures the desktop and reports view-only.");
151
+ }
152
+ }
153
+ }
154
+
155
+ /// Start the portal session, capture a few frames, and print what actually
156
+ /// arrived. This is the diagnostic to run when the picture does not appear.
157
+ async fn probe(seconds: Option<u64>, display: Option<&str>) -> Result<()> {
158
+ let seconds = seconds.unwrap_or(3);
159
+ match display {
160
+ Some(display) => probe_x11(display, seconds),
161
+ None => probe_portal(seconds).await,
162
+ }
163
+ }
164
+
165
+ /// Read a named X display's root window for a few seconds. The frame shape is
166
+ /// reported as a coarse checksum so a flat or unreadable screen is visible.
167
+ fn probe_x11(display: &str, seconds: u64) -> Result<()> {
168
+ let mut desktop = crate::x11::X11Desktop::connect(Some(display))?;
169
+ let (width, height) = desktop.screen_size();
170
+ let deadline = std::time::Instant::now() + Duration::from_secs(seconds);
171
+ let mut frames = 0u64;
172
+ let mut shapes = std::collections::BTreeSet::new();
173
+ let mut encoded = (0usize, 0usize);
174
+ while std::time::Instant::now() < deadline {
175
+ let frame = desktop.capture(0, 0)?;
176
+ encoded = (frame.width, frame.height);
177
+ shapes.insert(frame_shape(&frame));
178
+ frames += 1;
179
+ std::thread::sleep(Duration::from_millis(33));
180
+ }
181
+ println!(
182
+ "{}",
183
+ serde_json::to_string_pretty(&serde_json::json!({
184
+ "source": { "kind": "x11-root", "display": display, "width": width, "height": height },
185
+ "frames": frames,
186
+ "encoded": { "width": encoded.0, "height": encoded.1 },
187
+ "distinct_frames": shapes.len(),
188
+ }))?
189
+ );
190
+ if frames == 0 {
191
+ anyhow::bail!("no frames arrived from {display}");
192
+ }
193
+ Ok(())
194
+ }
195
+
196
+ /// A cheap fingerprint of a frame, enough to tell "the screen changed" from
197
+ /// "the screen is one flat colour".
198
+ fn frame_shape(frame: &crate::convert::I420) -> u64 {
199
+ frame
200
+ .y_plane()
201
+ .iter()
202
+ .enumerate()
203
+ .fold(0u64, |accumulator, (index, byte)| {
204
+ accumulator
205
+ .wrapping_mul(31)
206
+ .wrapping_add((*byte as u64) ^ (index as u64 & 0xff))
207
+ })
208
+ }
209
+
210
+ async fn probe_portal(seconds: u64) -> Result<()> {
211
+ let portal = portal::open(None).await?;
212
+ eprintln!("portal source: {:?}", portal.source);
213
+
214
+ let (tx, rx) = std_mpsc::channel();
215
+ let capture = capture::start(
216
+ portal,
217
+ 0,
218
+ 0,
219
+ 30,
220
+ Box::new(move |frame, seq| {
221
+ let _ = tx.send((frame.width, frame.height, seq));
222
+ }),
223
+ )?;
224
+
225
+ let deadline = std::time::Instant::now() + Duration::from_secs(seconds);
226
+ let mut seen = 0u64;
227
+ let mut last = (0usize, 0usize);
228
+ while std::time::Instant::now() < deadline {
229
+ match rx.recv_timeout(Duration::from_millis(500)) {
230
+ Ok((width, height, _)) => {
231
+ seen += 1;
232
+ last = (width, height);
233
+ }
234
+ Err(std_mpsc::RecvTimeoutError::Timeout) => continue,
235
+ Err(_) => break,
236
+ }
237
+ }
238
+
239
+ println!(
240
+ "{}",
241
+ serde_json::to_string_pretty(&serde_json::json!({
242
+ "frames": seen,
243
+ "frames_counted": capture.frame_count(),
244
+ "dropped": capture.dropped(),
245
+ "encoded": { "width": last.0, "height": last.1 },
246
+ "geometry": capture.geometry(),
247
+ }))?
248
+ );
249
+ if seen == 0 {
250
+ anyhow::bail!("no frames arrived; the compositor did not deliver a readable buffer");
251
+ }
252
+ Ok(())
253
+ }
254
+
255
+ /// The local control protocol loop.
256
+ async fn serve() -> Result<()> {
257
+ let (out_tx, mut out_rx) = tokio_mpsc::unbounded_channel::<String>();
258
+ let writer = tokio::spawn(async move {
259
+ let mut stdout = tokio::io::stdout();
260
+ while let Some(line) = out_rx.recv().await {
261
+ if stdout.write_all(line.as_bytes()).await.is_err() {
262
+ break;
263
+ }
264
+ if stdout.write_all(b"\n").await.is_err() {
265
+ break;
266
+ }
267
+ let _ = stdout.flush().await;
268
+ }
269
+ });
270
+
271
+ let (events_tx, mut events_rx) = tokio_mpsc::unbounded_channel::<session::Notice>();
272
+ {
273
+ let out_tx = out_tx.clone();
274
+ tokio::spawn(async move {
275
+ while let Some(event) = events_rx.recv().await {
276
+ if let Some(line) = render_event(event) {
277
+ if out_tx.send(line).is_err() {
278
+ break;
279
+ }
280
+ }
281
+ }
282
+ });
283
+ }
284
+
285
+ let mut current: Option<session::Session> = None;
286
+ let mut hello_seen = false;
287
+ let stdin = tokio::io::stdin();
288
+ let mut lines = BufReader::new(stdin).lines();
289
+
290
+ while let Some(line) = lines.next_line().await? {
291
+ let line = line.trim();
292
+ if line.is_empty() {
293
+ continue;
294
+ }
295
+ let request: Request = match serde_json::from_str(line) {
296
+ Ok(request) => request,
297
+ Err(error) => {
298
+ // A malformed request with no id cannot be answered in place;
299
+ // report it and keep the channel usable.
300
+ let _ = out_tx.send(
301
+ serde_json::json!({
302
+ "error": { "code": "malformed", "message": error.to_string() }
303
+ })
304
+ .to_string(),
305
+ );
306
+ continue;
307
+ }
308
+ };
309
+ let id = request.id;
310
+ let outcome = dispatch(&request, &mut hello_seen, &mut current, &events_tx).await;
311
+ if let Some(id) = id {
312
+ let payload = match outcome {
313
+ Ok(result) => serde_json::to_string(&Response {
314
+ id,
315
+ result: Some(result),
316
+ error: None,
317
+ }),
318
+ Err(error) => serde_json::to_string(&Response {
319
+ id,
320
+ result: None,
321
+ error: Some(error),
322
+ }),
323
+ };
324
+ if let Ok(payload) = payload {
325
+ if out_tx.send(payload).is_err() {
326
+ break;
327
+ }
328
+ }
329
+ }
330
+ if let Some(session) = &current {
331
+ if session.mark_closed_reason().is_some() {
332
+ current = None;
333
+ }
334
+ }
335
+ }
336
+
337
+ if let Some(session) = current.take() {
338
+ session.close("the consumer disconnected").await;
339
+ }
340
+ let _ = out_tx.send(String::new()).ok();
341
+ drop(out_tx);
342
+ let _ = writer.await;
343
+ Ok(())
344
+ }
345
+
346
+ fn render_event(notice: session::Notice) -> Option<String> {
347
+ let session::Notice { session_id, event } = notice;
348
+ let (name, params) = match event {
349
+ session::SessionEvent::Description { generation, sdp } => (
350
+ "session.description",
351
+ serde_json::json!({ "sessionId": session_id, "generation": generation, "description": { "type": "offer", "sdp": sdp } }),
352
+ ),
353
+ session::SessionEvent::Candidate {
354
+ generation,
355
+ candidate,
356
+ sdp_mid,
357
+ sdp_m_line_index,
358
+ } => (
359
+ "session.candidate",
360
+ serde_json::json!({
361
+ "sessionId": session_id,
362
+ "generation": generation,
363
+ "candidate": candidate,
364
+ "sdpMid": sdp_mid,
365
+ "sdpMLineIndex": sdp_m_line_index,
366
+ }),
367
+ ),
368
+ session::SessionEvent::State {
369
+ capture,
370
+ transport,
371
+ first_frame,
372
+ } => (
373
+ "session.state",
374
+ serde_json::json!({ "sessionId": session_id, "capture": capture, "transport": transport, "firstFrame": first_frame }),
375
+ ),
376
+ session::SessionEvent::RestoreToken(token) => (
377
+ "session.restoreToken",
378
+ serde_json::json!({ "sessionId": session_id, "token": token }),
379
+ ),
380
+ session::SessionEvent::Revoked { reason } => (
381
+ "session.revoked",
382
+ serde_json::json!({ "sessionId": session_id, "reason": reason }),
383
+ ),
384
+ };
385
+ serde_json::to_string(&Event {
386
+ event: name.to_owned(),
387
+ params,
388
+ })
389
+ .ok()
390
+ }
391
+
392
+ async fn dispatch(
393
+ request: &Request,
394
+ hello_seen: &mut bool,
395
+ current: &mut Option<session::Session>,
396
+ events: &tokio_mpsc::UnboundedSender<session::Notice>,
397
+ ) -> std::result::Result<serde_json::Value, ErrorBody> {
398
+ match request.method.as_str() {
399
+ "hello" => {
400
+ let params: protocol::HelloParams = serde_json::from_value(request.params.clone())
401
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
402
+ if params.protocol != protocol::PROTOCOL_VERSION {
403
+ return Err(ErrorBody::new(
404
+ "unsupported-protocol",
405
+ format!(
406
+ "this engine speaks protocol {} but the consumer asked for {}",
407
+ protocol::PROTOCOL_VERSION,
408
+ params.protocol
409
+ ),
410
+ ));
411
+ }
412
+ *hello_seen = true;
413
+ Ok(session::capabilities())
414
+ }
415
+ "capabilities" => Ok(session::capabilities()),
416
+ "shutdown" => {
417
+ if let Some(session) = current.take() {
418
+ session.close("the consumer shut the engine down").await;
419
+ }
420
+ std::process::exit(0);
421
+ }
422
+ _ if !*hello_seen => Err(ErrorBody::new(
423
+ "unsupported-protocol",
424
+ "send `hello` with the protocol version before anything else",
425
+ )),
426
+ "session.open" => {
427
+ if let Some(existing) = current.take() {
428
+ existing.close("replaced by a new session").await;
429
+ }
430
+ let params: protocol::OpenParams = serde_json::from_value(request.params.clone())
431
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
432
+ let open = session::OpenRequest {
433
+ source: params.source,
434
+ permissions: params.permissions,
435
+ max_width: params.max_width,
436
+ max_height: params.max_height,
437
+ bitrate_kbps: params.bitrate_kbps,
438
+ max_fps: params.max_fps,
439
+ restore_token: params.restore_token,
440
+ ttl: params.ttl_seconds.map(Duration::from_secs),
441
+ loopback_tcp: params.loopback_tcp,
442
+ ice_servers: params
443
+ .ice_servers
444
+ .into_iter()
445
+ .filter_map(|server| {
446
+ server
447
+ .urls
448
+ .first()
449
+ .map(|url| (url.clone(), server.username, server.credential))
450
+ })
451
+ .collect(),
452
+ };
453
+ let session = session::Session::open(open, events.clone())
454
+ .await
455
+ .map_err(|error| ErrorBody::new(error.code, error.message))?;
456
+ let result = serde_json::json!({
457
+ "sessionId": session.id(),
458
+ "generation": session.generation(),
459
+ "source": {
460
+ "kind": session.restore_source(),
461
+ "width": session.source().width,
462
+ "height": session.source().height,
463
+ "origin": { "x": session.source().origin_x, "y": session.source().origin_y },
464
+ },
465
+ "geometry": session.geometry(),
466
+ });
467
+ *current = Some(session);
468
+ Ok(result)
469
+ }
470
+ "session.description" => {
471
+ let session = require_session(current)?;
472
+ let params: protocol::DescriptionParams =
473
+ serde_json::from_value(request.params.clone())
474
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
475
+ check_session(session, &params.session_id, params.generation)?;
476
+ if params.description.kind != "answer" {
477
+ return Err(ErrorBody::new(
478
+ "operation",
479
+ "the engine is the offerer; send an answer",
480
+ ));
481
+ }
482
+ session
483
+ .accept_answer(params.description.sdp)
484
+ .await
485
+ .map_err(|error| ErrorBody::new("transport", format!("{error:#}")))?;
486
+ Ok(serde_json::json!({ "accepted": true }))
487
+ }
488
+ "session.candidate" => {
489
+ let session = require_session(current)?;
490
+ let params: protocol::CandidateParams = serde_json::from_value(request.params.clone())
491
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
492
+ check_session(session, &params.session_id, params.generation)?;
493
+ session
494
+ .add_candidate(params.candidate, params.sdp_mid, params.sdp_m_line_index)
495
+ .await
496
+ .map_err(|error| ErrorBody::new("transport", format!("{error:#}")))?;
497
+ Ok(serde_json::json!({ "accepted": true }))
498
+ }
499
+ "session.clipboard.read" => {
500
+ let session = require_session(current)?;
501
+ let params: protocol::ClipboardParams = serde_json::from_value(request.params.clone())
502
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
503
+ check_session(session, &params.session_id, None)?;
504
+ let (text, truncated) = session
505
+ .read_clipboard()
506
+ .await
507
+ .map_err(|reason| ErrorBody::new("clipboard", reason))?;
508
+ Ok(serde_json::json!({ "text": text, "truncated": truncated }))
509
+ }
510
+ "session.clipboard.write" => {
511
+ let session = require_session(current)?;
512
+ let params: protocol::ClipboardParams = serde_json::from_value(request.params.clone())
513
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
514
+ check_session(session, &params.session_id, None)?;
515
+ session
516
+ .write_clipboard(params.text.unwrap_or_default())
517
+ .await
518
+ .map_err(|reason| ErrorBody::new("clipboard", reason))?;
519
+ Ok(serde_json::json!({ "written": true }))
520
+ }
521
+ "session.metrics" => {
522
+ let session = require_session(current)?;
523
+ Ok(serde_json::to_value(session.metrics()).unwrap_or(serde_json::Value::Null))
524
+ }
525
+ "session.close" => {
526
+ let session = require_session(current)?;
527
+ let params: protocol::SessionRef = serde_json::from_value(request.params.clone())
528
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
529
+ check_session(session, &params.session_id, params.generation)?;
530
+ let session = current.take().expect("checked above");
531
+ session.close("the consumer closed the session").await;
532
+ Ok(serde_json::json!({ "closed": true }))
533
+ }
534
+ other => Err(ErrorBody::new(
535
+ "operation",
536
+ format!("unknown method {other}"),
537
+ )),
538
+ }
539
+ }
540
+
541
+ fn require_session(
542
+ current: &mut Option<session::Session>,
543
+ ) -> std::result::Result<&mut session::Session, ErrorBody> {
544
+ current
545
+ .as_mut()
546
+ .ok_or_else(|| ErrorBody::new("session", "no session is open"))
547
+ }
548
+
549
+ /// A late message for a session that has already ended must not revive it, and a
550
+ /// message for a different session must not reach this one.
551
+ fn check_session(
552
+ session: &session::Session,
553
+ id: &str,
554
+ generation: Option<u64>,
555
+ ) -> std::result::Result<(), ErrorBody> {
556
+ if session.id() != id {
557
+ return Err(ErrorBody::new("session", "that session id is not open"));
558
+ }
559
+ if let Some(generation) = generation {
560
+ if generation != session.generation() {
561
+ return Err(ErrorBody::new("generation", "that generation has ended"));
562
+ }
563
+ }
564
+ if session.mark_closed_reason().is_some() {
565
+ return Err(ErrorBody::new("session", "that session has ended"));
566
+ }
567
+ Ok(())
568
+ }
569
+
570
+ #[cfg(test)]
571
+ mod tests {
572
+ use super::*;
573
+
574
+ #[tokio::test]
575
+ async fn a_consumer_speaking_another_protocol_version_is_refused() {
576
+ let mut hello_seen = false;
577
+ let mut current = None;
578
+ let (events, _received) = tokio_mpsc::unbounded_channel();
579
+ let request = Request {
580
+ id: Some(1),
581
+ method: String::from("hello"),
582
+ params: serde_json::json!({ "protocol": 99, "client": "test" }),
583
+ };
584
+ let error = dispatch(&request, &mut hello_seen, &mut current, &events)
585
+ .await
586
+ .unwrap_err();
587
+ assert_eq!(error.code, "unsupported-protocol");
588
+ assert!(!hello_seen, "a refused handshake must not open the gate");
589
+ }
590
+
591
+ #[tokio::test]
592
+ async fn the_state_notification_carries_exactly_what_the_document_promises() {
593
+ let line = render_event(session::Notice {
594
+ session_id: String::from("session-1"),
595
+ event: session::SessionEvent::State {
596
+ capture: "streaming",
597
+ transport: String::from("connected"),
598
+ first_frame: true,
599
+ },
600
+ })
601
+ .expect("a state event is renderable");
602
+ let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
603
+ assert_eq!(parsed["event"], "session.state");
604
+ assert_eq!(
605
+ parsed["params"],
606
+ serde_json::json!({
607
+ "sessionId": "session-1",
608
+ "capture": "streaming",
609
+ "transport": "connected",
610
+ "firstFrame": true
611
+ })
612
+ );
613
+ }
614
+ }