@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,510 @@
1
+ //! PipeWire capture of the portal's screen cast node.
2
+ //!
3
+ //! The portal hands us a PipeWire remote fd and a node id. We connect that
4
+ //! remote, ask the node for a format we can read on the CPU, and pull frames.
5
+ //! Everything PipeWire lives on this module's own thread because its objects are
6
+ //! not `Send`; frames and state changes leave through channels.
7
+
8
+ use crate::convert::{to_i420, I420};
9
+ use crate::portal::{PortalSession, SelectedSource};
10
+ use anyhow::{Context, Result};
11
+ use pipewire as pw;
12
+ use pw::spa;
13
+ use pw::spa::pod::{serialize::PodSerializer, Pod};
14
+ use std::io::Cursor;
15
+ use std::os::fd::OwnedFd;
16
+ use std::sync::atomic::{AtomicU64, Ordering};
17
+ use std::sync::{mpsc, Arc, Mutex};
18
+ use std::thread::JoinHandle;
19
+
20
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
21
+ #[serde(rename_all = "snake_case")]
22
+ pub enum PixelFormat {
23
+ Bgrx,
24
+ Rgbx,
25
+ Bgra,
26
+ Rgba,
27
+ Bgr,
28
+ Rgb,
29
+ Unsupported,
30
+ }
31
+
32
+ impl PixelFormat {
33
+ fn from_spa(raw: u32) -> Self {
34
+ match raw {
35
+ v if v == spa::param::video::VideoFormat::BGRx.as_raw() => Self::Bgrx,
36
+ v if v == spa::param::video::VideoFormat::RGBx.as_raw() => Self::Rgbx,
37
+ v if v == spa::param::video::VideoFormat::BGRA.as_raw() => Self::Bgra,
38
+ v if v == spa::param::video::VideoFormat::RGBA.as_raw() => Self::Rgba,
39
+ v if v == spa::param::video::VideoFormat::BGR.as_raw() => Self::Bgr,
40
+ v if v == spa::param::video::VideoFormat::RGB.as_raw() => Self::Rgb,
41
+ _ => Self::Unsupported,
42
+ }
43
+ }
44
+ }
45
+
46
+ /// Everything we learned about the negotiated stream, reported to the consumer
47
+ /// so it can map input against the real surface.
48
+ #[derive(Debug, Clone, serde::Serialize)]
49
+ pub struct StreamGeometry {
50
+ pub source_width: usize,
51
+ pub source_height: usize,
52
+ pub origin_x: i32,
53
+ pub origin_y: i32,
54
+ pub format: String,
55
+ pub buffer_type: String,
56
+ }
57
+
58
+ pub type FrameSink = Box<dyn Fn(I420, u64) + Send + 'static>;
59
+
60
+ /// A running capture. Dropping it quits the PipeWire loop and joins its thread.
61
+ pub struct Capture {
62
+ quit: pw::channel::Sender<()>,
63
+ thread: Option<JoinHandle<()>>,
64
+ pub source: SelectedSource,
65
+ geometry: Arc<Mutex<Option<StreamGeometry>>>,
66
+ frames: Arc<AtomicU64>,
67
+ dropped: Arc<AtomicU64>,
68
+ pub encoded_width: usize,
69
+ pub encoded_height: usize,
70
+ }
71
+
72
+ impl Capture {
73
+ pub fn geometry(&self) -> Option<StreamGeometry> {
74
+ self.geometry.lock().ok().and_then(|g| g.clone())
75
+ }
76
+
77
+ pub fn frame_count(&self) -> u64 {
78
+ self.frames.load(Ordering::Relaxed)
79
+ }
80
+
81
+ pub fn dropped(&self) -> u64 {
82
+ self.dropped.load(Ordering::Relaxed)
83
+ }
84
+ }
85
+
86
+ impl Drop for Capture {
87
+ fn drop(&mut self) {
88
+ // The channel queues a stop even before the loop starts, and remains
89
+ // safe if setup already failed. A shared raw loop pointer does neither.
90
+ let _ = self.quit.send(());
91
+ if let Some(thread) = self.thread.take() {
92
+ let _ = thread.join();
93
+ }
94
+ }
95
+ }
96
+
97
+ /// A raw `spa_format`/parameter key, so `property!` can take the `spa_sys`
98
+ /// constants that have no `as_raw()` of their own.
99
+ struct RawKey(u32);
100
+
101
+ impl RawKey {
102
+ fn as_raw(&self) -> u32 {
103
+ self.0
104
+ }
105
+ }
106
+
107
+ /// Build the `EnumFormat` we can consume: packed 4-byte colour, at whatever
108
+ /// size the compositor picked.
109
+ ///
110
+ /// Deliberately carries **no** `modifier` property. A compositor's screen cast
111
+ /// node reads that as "the client wants a DMA-BUF" and hands back a buffer this
112
+ /// process could not read without a GPU download step; without it the same node
113
+ /// produces a CPU-mappable shared-memory buffer.
114
+ fn format_pod(buffer: &mut Vec<u8>, max_fps: u32) -> Result<()> {
115
+ use spa::param::format::{FormatProperties, MediaSubtype, MediaType};
116
+ use spa::param::video::VideoFormat;
117
+ use spa::pod::{object, property, Value};
118
+ use spa::utils::{Fraction, Rectangle, SpaTypes};
119
+
120
+ let obj = object!(
121
+ SpaTypes::ObjectParamFormat,
122
+ spa::param::ParamType::EnumFormat,
123
+ property!(FormatProperties::MediaType, Id, MediaType::Video),
124
+ property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
125
+ property!(
126
+ FormatProperties::VideoFormat,
127
+ Choice,
128
+ Enum,
129
+ Id,
130
+ VideoFormat::BGRx,
131
+ VideoFormat::BGRA,
132
+ VideoFormat::RGBx,
133
+ VideoFormat::RGBA
134
+ ),
135
+ property!(
136
+ FormatProperties::VideoSize,
137
+ Choice,
138
+ Range,
139
+ Rectangle,
140
+ Rectangle {
141
+ width: 2560,
142
+ height: 1440
143
+ },
144
+ Rectangle {
145
+ width: 1,
146
+ height: 1
147
+ },
148
+ Rectangle {
149
+ width: 7680,
150
+ height: 4320
151
+ }
152
+ ),
153
+ property!(
154
+ FormatProperties::VideoFramerate,
155
+ Choice,
156
+ Range,
157
+ Fraction,
158
+ Fraction {
159
+ num: max_fps.min(1000),
160
+ denom: 1
161
+ },
162
+ Fraction { num: 0, denom: 1 },
163
+ Fraction {
164
+ num: 1000,
165
+ denom: 1
166
+ }
167
+ ),
168
+ );
169
+ PodSerializer::serialize(Cursor::new(buffer), &Value::Object(obj))
170
+ .map(|_| ())
171
+ .map_err(|e| anyhow::anyhow!("failed to build EnumFormat pod: {e}"))
172
+ }
173
+
174
+ /// Ask for CPU-mappable buffers first. A compositor that only produces
175
+ /// DMA-BUFs will ignore the choice and we report that instead of reading garbage.
176
+ fn buffer_pod(buffer: &mut Vec<u8>) -> Result<()> {
177
+ use spa::pod::ChoiceValue;
178
+ use spa::pod::{object, property, Value};
179
+ use spa::utils::{Choice, ChoiceEnum, ChoiceFlags, SpaTypes};
180
+ let obj = object!(
181
+ SpaTypes::ObjectParamBuffers,
182
+ spa::param::ParamType::Buffers,
183
+ property!(
184
+ RawKey(spa::sys::SPA_PARAM_BUFFERS_dataType),
185
+ Value::Choice(ChoiceValue::Int(Choice(
186
+ ChoiceFlags::empty(),
187
+ ChoiceEnum::Flags {
188
+ default: (1 << spa::buffer::DataType::MemFd.as_raw()) as i32,
189
+ flags: vec![
190
+ (1 << spa::buffer::DataType::MemFd.as_raw()) as i32,
191
+ (1 << spa::buffer::DataType::DmaBuf.as_raw()) as i32,
192
+ ],
193
+ }
194
+ )))
195
+ ),
196
+ );
197
+ PodSerializer::serialize(Cursor::new(buffer), &Value::Object(obj))
198
+ .map(|_| ())
199
+ .map_err(|e| anyhow::anyhow!("failed to build Buffers pod: {e}"))
200
+ }
201
+
202
+ /// Start capturing `session`'s node, delivering I420 frames to `sink`.
203
+ pub fn start(
204
+ session: PortalSession,
205
+ encoded_width: usize,
206
+ encoded_height: usize,
207
+ max_fps: u32,
208
+ sink: FrameSink,
209
+ ) -> Result<Capture> {
210
+ let PortalSession { fd, source, .. } = session;
211
+ let geometry = Arc::new(Mutex::new(None));
212
+ let frames = Arc::new(AtomicU64::new(0));
213
+ let dropped = Arc::new(AtomicU64::new(0));
214
+ let (quit, stop) = pw::channel::channel();
215
+ let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
216
+
217
+ let node_id = source.node_id;
218
+ let thread = {
219
+ let geometry = geometry.clone();
220
+ let frames = frames.clone();
221
+ let dropped = dropped.clone();
222
+ let ready = ready_tx;
223
+ std::thread::Builder::new()
224
+ .name("desklink-capture".into())
225
+ .spawn(move || {
226
+ let result = run_loop(
227
+ fd,
228
+ node_id,
229
+ encoded_width,
230
+ encoded_height,
231
+ max_fps,
232
+ sink,
233
+ geometry,
234
+ frames,
235
+ dropped,
236
+ stop,
237
+ ready.clone(),
238
+ );
239
+ // Only a delivered frame means ready. A loop that ended without
240
+ // one must not turn an early exit into a successful start.
241
+ let error = result
242
+ .err()
243
+ .map(|e| format!("{e:#}"))
244
+ .unwrap_or_else(|| String::from("capture ended before its first frame"));
245
+ let _ = ready.send(Err(error));
246
+ })
247
+ .context("failed to spawn capture thread")?
248
+ };
249
+
250
+ // Own cleanup before waiting: timeout, setup failure and panic all stop and
251
+ // join the worker, not just a successfully returned Capture.
252
+ let capture = Capture {
253
+ quit,
254
+ thread: Some(thread),
255
+ source,
256
+ geometry,
257
+ frames,
258
+ dropped,
259
+ encoded_width,
260
+ encoded_height,
261
+ };
262
+ match ready_rx.recv_timeout(std::time::Duration::from_secs(10)) {
263
+ Ok(Ok(())) => Ok(capture),
264
+ Ok(Err(e)) => anyhow::bail!("capture failed: {e}"),
265
+ Err(mpsc::RecvTimeoutError::Timeout) => anyhow::bail!("capture did not start within 10s"),
266
+ Err(mpsc::RecvTimeoutError::Disconnected) => anyhow::bail!("capture thread died"),
267
+ }
268
+ }
269
+
270
+ #[allow(clippy::too_many_arguments)]
271
+ fn run_loop(
272
+ fd: OwnedFd,
273
+ node_id: u32,
274
+ encoded_width: usize,
275
+ encoded_height: usize,
276
+ max_fps: u32,
277
+ sink: FrameSink,
278
+ geometry: Arc<Mutex<Option<StreamGeometry>>>,
279
+ frames: Arc<AtomicU64>,
280
+ dropped: Arc<AtomicU64>,
281
+ stop: pw::channel::Receiver<()>,
282
+ ready: mpsc::Sender<Result<(), String>>,
283
+ ) -> Result<()> {
284
+ pw::init();
285
+ let main_loop = pw::main_loop::MainLoopRc::new(None).context("pw_main_loop_new failed")?;
286
+ let _stop = stop.attach(main_loop.loop_(), {
287
+ let main_loop = main_loop.clone();
288
+ move |_| main_loop.quit()
289
+ });
290
+
291
+ let context =
292
+ pw::context::ContextBox::new(main_loop.loop_(), None).context("pw_context_new failed")?;
293
+ let core = context
294
+ .connect_fd(fd, None)
295
+ .context("pw_context_connect_fd failed")?;
296
+
297
+ let mut props = pw::properties::properties! {
298
+ *pw::keys::MEDIA_TYPE => "Video",
299
+ *pw::keys::MEDIA_CATEGORY => "Capture",
300
+ *pw::keys::MEDIA_ROLE => "Screen",
301
+ };
302
+ props.insert(*pw::keys::TARGET_OBJECT, node_id.to_string());
303
+ let stream = pw::stream::StreamBox::new(&core, "desklink-capture", props)
304
+ .context("pw_stream_new failed")?;
305
+
306
+ let mut state = StreamState {
307
+ box_w: encoded_width,
308
+ box_h: encoded_height,
309
+ format: None,
310
+ buffer_type: String::from("unknown"),
311
+ sink,
312
+ geometry,
313
+ frames,
314
+ dropped,
315
+ logical_w: 0,
316
+ logical_h: 0,
317
+ };
318
+
319
+ let mut ready = Some(ready);
320
+ let _listener = stream
321
+ .add_local_listener_with_user_data(&mut state)
322
+ .param_changed(|_, state, id, param| {
323
+ if id != pw::spa::param::ParamType::Format.as_raw() {
324
+ return;
325
+ }
326
+ let Some(param) = param else { return };
327
+ // SAFETY: PipeWire guarantees `param` is a valid spa_pod for the
328
+ // duration of this callback; `Pod` is a transparent wrapper over it.
329
+ let pod = param;
330
+ if let Ok((media_type, media_subtype)) = spa::param::format_utils::parse_format(pod) {
331
+ if media_type != spa::param::format::MediaType::Video
332
+ || media_subtype != spa::param::format::MediaSubtype::Raw
333
+ {
334
+ return;
335
+ }
336
+ }
337
+ let mut info = spa::param::video::VideoInfoRaw::new();
338
+ if info.parse(pod).is_err() {
339
+ return;
340
+ }
341
+ state.format = Some(PixelFormat::from_spa(info.format().as_raw()));
342
+ state.logical_w = info.size().width as usize;
343
+ state.logical_h = info.size().height as usize;
344
+ })
345
+ .process(move |stream, state| {
346
+ let Some(mut buffer) = stream.dequeue_buffer() else {
347
+ return;
348
+ };
349
+ let Some(format) = state.format else { return };
350
+ let datas = buffer.datas_mut();
351
+ if datas.is_empty() {
352
+ return;
353
+ }
354
+ let data = &mut datas[0];
355
+ let kind = data.type_();
356
+ let chunk = data.chunk();
357
+ let stride = chunk.stride().max(0) as usize;
358
+ let offset = chunk.offset() as usize;
359
+ let size = chunk.size() as usize;
360
+ state.buffer_type = format!("{kind:?}");
361
+ let (w, h) = (state.logical_w, state.logical_h);
362
+ if w == 0 || h == 0 {
363
+ return;
364
+ }
365
+ let Some(bytes) = data.data() else {
366
+ // Not a CPU-mappable buffer (a DMA-BUF the compositor refused to
367
+ // convert). Count it and stay honest; the consumer reads
368
+ // `buffer_type` to report an unsupported source.
369
+ state.dropped.fetch_add(1, Ordering::Relaxed);
370
+ return;
371
+ };
372
+ let stride = if stride == 0 { w * 4 } else { stride };
373
+ let Some(chunk_bytes) = bytes.get(offset..offset.saturating_add(size)) else {
374
+ state.dropped.fetch_add(1, Ordering::Relaxed);
375
+ return;
376
+ };
377
+ // Exactly the encoder's size: fitting again here would round a
378
+ // second time and hand the encoder a frame it refuses whenever the
379
+ // stream's size differs from the one the portal reported.
380
+ let seq = state.frames.load(Ordering::Relaxed);
381
+ match to_i420(chunk_bytes, w, h, stride, format, state.box_w, state.box_h) {
382
+ Some(i420) => {
383
+ if state.geometry.lock().map(|g| g.is_none()).unwrap_or(false) {
384
+ if let Ok(mut g) = state.geometry.lock() {
385
+ *g = Some(StreamGeometry {
386
+ source_width: w,
387
+ source_height: h,
388
+ origin_x: 0,
389
+ origin_y: 0,
390
+ format: format!("{format:?}").to_lowercase(),
391
+ buffer_type: state.buffer_type.clone(),
392
+ });
393
+ }
394
+ }
395
+ state.frames.fetch_add(1, Ordering::Relaxed);
396
+ (state.sink)(i420, seq);
397
+ // The first frame the loop actually delivers is the only
398
+ // proof capture started; sending this when `run_loop`
399
+ // returns would be after the main loop quits, too late for
400
+ // `start` to wait on.
401
+ if let Some(sender) = ready.take() {
402
+ let _ = sender.send(Ok(()));
403
+ }
404
+ }
405
+ None => {
406
+ state.dropped.fetch_add(1, Ordering::Relaxed);
407
+ }
408
+ }
409
+ })
410
+ .register()
411
+ .context("pw_stream_add_listener failed")?;
412
+
413
+ let mut format_buf = Vec::new();
414
+ format_pod(&mut format_buf, max_fps)?;
415
+ let mut buffer_buf = Vec::new();
416
+ buffer_pod(&mut buffer_buf)?;
417
+ let mut params = [
418
+ Pod::from_bytes(&format_buf).context("invalid EnumFormat pod")?,
419
+ Pod::from_bytes(&buffer_buf).context("invalid Buffers pod")?,
420
+ ];
421
+
422
+ stream
423
+ .connect(
424
+ spa::utils::Direction::Input,
425
+ Some(node_id),
426
+ pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
427
+ &mut params,
428
+ )
429
+ .context("pw_stream_connect failed")?;
430
+
431
+ main_loop.run();
432
+ Ok(())
433
+ }
434
+
435
+ struct StreamState {
436
+ box_w: usize,
437
+ box_h: usize,
438
+ format: Option<PixelFormat>,
439
+ buffer_type: String,
440
+ sink: FrameSink,
441
+ geometry: Arc<Mutex<Option<StreamGeometry>>>,
442
+ frames: Arc<AtomicU64>,
443
+ dropped: Arc<AtomicU64>,
444
+ logical_w: usize,
445
+ logical_h: usize,
446
+ }
447
+
448
+ // PipeWire callbacks only touch this from the capture thread.
449
+ unsafe impl Send for StreamState {}
450
+
451
+ #[cfg(test)]
452
+ mod tests {
453
+ use super::*;
454
+ use std::time::Duration;
455
+
456
+ #[test]
457
+ fn dropping_capture_stops_and_joins_its_live_pipewire_loop() {
458
+ // Exercise the real event-loop/channel lifetime without a portal,
459
+ // compositor, screen capture or input device.
460
+ let (quit, stop) = pw::channel::channel();
461
+ let (ready_tx, ready_rx) = mpsc::channel();
462
+ let (ended_tx, ended_rx) = mpsc::channel();
463
+ let thread = std::thread::spawn(move || {
464
+ pw::init();
465
+ let main_loop = pw::main_loop::MainLoopRc::new(None).unwrap();
466
+ let _stop = stop.attach(main_loop.loop_(), {
467
+ let main_loop = main_loop.clone();
468
+ move |_| main_loop.quit()
469
+ });
470
+ let timer = main_loop.loop_().add_timer(move |_| {
471
+ let _ = ready_tx.send(());
472
+ });
473
+ timer
474
+ .update_timer(Some(Duration::from_millis(1)), None)
475
+ .into_result()
476
+ .unwrap();
477
+ main_loop.run();
478
+ });
479
+ let capture = Capture {
480
+ quit,
481
+ thread: Some(thread),
482
+ source: SelectedSource {
483
+ node_id: 0,
484
+ width: 2,
485
+ height: 2,
486
+ position: None,
487
+ source_type: None,
488
+ origin_x: 0,
489
+ origin_y: 0,
490
+ },
491
+ geometry: Arc::new(Mutex::new(None)),
492
+ frames: Arc::new(AtomicU64::new(0)),
493
+ dropped: Arc::new(AtomicU64::new(0)),
494
+ encoded_width: 2,
495
+ encoded_height: 2,
496
+ };
497
+ ready_rx
498
+ .recv_timeout(Duration::from_secs(2))
499
+ .expect("the loop is running");
500
+ // Keep a broken join bounded so a missing stop is a failed check rather
501
+ // than a test process that hangs forever.
502
+ std::thread::spawn(move || {
503
+ drop(capture);
504
+ ended_tx.send(()).unwrap();
505
+ });
506
+ ended_rx
507
+ .recv_timeout(Duration::from_secs(2))
508
+ .expect("capture stopped and joined");
509
+ }
510
+ }
@@ -0,0 +1,132 @@
1
+ //! Explicit, user-invoked clipboard transfer.
2
+ //!
3
+ //! Read and write are separate requests the consumer makes on a user action.
4
+ //! The clipboard is never polled, never watched, and never used as a hidden way
5
+ //! to type: a phone that silently replaced the user's clipboard would be worse
6
+ //! than one that could not copy at all.
7
+
8
+ use anyhow::{Context, Result};
9
+ use std::io::{Read, Write};
10
+ use std::process::{Command, Stdio};
11
+ use wl_clipboard_rs::paste::{ClipboardType, MimeType as PasteMime, Seat};
12
+
13
+ /// The engine's bound on a single clipboard transfer. Larger than any real
14
+ /// paste of text, small enough that a hostile client cannot use it as a file
15
+ /// transfer.
16
+ pub const MAX_CLIPBOARD_BYTES: usize = 256 * 1024;
17
+
18
+ pub fn read() -> Result<(String, bool)> {
19
+ let options = wl_clipboard_rs::paste::get_contents(
20
+ ClipboardType::Regular,
21
+ Seat::Unspecified,
22
+ PasteMime::Text,
23
+ );
24
+ let (mut pipe, _mime) = options.context("no text on the desktop clipboard")?;
25
+ let mut raw = Vec::new();
26
+ std::io::Read::take(&mut pipe, MAX_CLIPBOARD_BYTES as u64 + 1)
27
+ .read_to_end(&mut raw)
28
+ .context("the clipboard did not read")?;
29
+ bounded_text(&raw, MAX_CLIPBOARD_BYTES)
30
+ }
31
+
32
+ /// The text within `limit` bytes, cut on a character boundary, and whether
33
+ /// anything past the limit was dropped.
34
+ fn bounded_text(raw: &[u8], limit: usize) -> Result<(String, bool)> {
35
+ let truncated = raw.len() > limit;
36
+ let bytes = if truncated { &raw[..limit] } else { raw };
37
+ match std::str::from_utf8(bytes) {
38
+ Ok(text) => Ok((text.to_owned(), truncated)),
39
+ Err(error) if truncated && error.error_len().is_none() => {
40
+ // The invalid sequence is an incomplete one at the cut, so the bytes
41
+ // before it are the text the desktop held.
42
+ let valid = error.valid_up_to();
43
+ Ok((String::from_utf8_lossy(&bytes[..valid]).into_owned(), true))
44
+ }
45
+ Err(_) => Err(anyhow::anyhow!(
46
+ "the clipboard contents are not valid UTF-8 text"
47
+ )),
48
+ }
49
+ }
50
+
51
+ pub fn write(text: &str) -> Result<()> {
52
+ if text.len() > MAX_CLIPBOARD_BYTES {
53
+ anyhow::bail!("refusing to place {} bytes on the clipboard", text.len());
54
+ }
55
+ // wl-copy acknowledges setup before forking its selection server. The
56
+ // explicit copy survives Back/engine exit, until another app replaces it.
57
+ let mut child = Command::new("wl-copy")
58
+ .args(["--type", "text/plain;charset=utf-8"])
59
+ .stdin(Stdio::piped())
60
+ .stdout(Stdio::null())
61
+ .stderr(Stdio::null())
62
+ .spawn()
63
+ .context("wl-clipboard is required to write the desktop clipboard")?;
64
+ let written = child
65
+ .stdin
66
+ .take()
67
+ .expect("piped clipboard input")
68
+ .write_all(text.as_bytes());
69
+ let status = child.wait().context("the clipboard writer did not exit")?;
70
+ written.context("the clipboard writer did not accept the text")?;
71
+ anyhow::ensure!(status.success(), "the compositor refused a clipboard write");
72
+ Ok(())
73
+ }
74
+
75
+ pub fn writer_available() -> bool {
76
+ Command::new("wl-copy")
77
+ .arg("--version")
78
+ .stdout(Stdio::null())
79
+ .stderr(Stdio::null())
80
+ .status()
81
+ .is_ok_and(|status| status.success())
82
+ }
83
+
84
+ /// Distinguishing "nothing there" from "unsupported" matters for the message the
85
+ /// user sees, so the caller can report which one happened.
86
+ pub fn read_or_explain() -> Result<(String, bool), String> {
87
+ match read() {
88
+ Ok(result) => Ok(result),
89
+ Err(error) => {
90
+ let root = error.root_cause().to_string();
91
+ if root.contains("no suitable") || root.contains("was provided") {
92
+ Err(String::from("the desktop clipboard has no text"))
93
+ } else {
94
+ Err(root)
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ #[cfg(test)]
101
+ mod tests {
102
+ use super::*;
103
+
104
+ #[test]
105
+ fn an_oversized_clipboard_is_cut_cleanly_and_reported() {
106
+ let mut raw = vec![b'a'; 4];
107
+ raw.extend_from_slice("\u{e9}".as_bytes());
108
+ raw.extend_from_slice(b"zzzz");
109
+ let (text, truncated) =
110
+ bounded_text(&raw, 5).expect("a character-boundary cut is valid text");
111
+ assert!(truncated, "a cut past the limit must be reported");
112
+ assert_eq!(
113
+ text, "aaaa",
114
+ "the partial character is dropped, not invented"
115
+ );
116
+
117
+ // A byte that is not valid UTF-8 before the cut is not a partial
118
+ // character; the whole read is refused.
119
+ let mut invalid = vec![b'a'; 4];
120
+ invalid.push(0xff);
121
+ invalid.extend_from_slice(b"zzzz");
122
+ assert!(bounded_text(&invalid, 5).is_err());
123
+
124
+ let (text, truncated) = bounded_text(b"hello", 5).unwrap();
125
+ assert!(!truncated);
126
+ assert_eq!(text, "hello");
127
+
128
+ let (text, truncated) = bounded_text(b"hello world", 5).unwrap();
129
+ assert!(truncated);
130
+ assert_eq!(text, "hello");
131
+ }
132
+ }