@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.
- package/LICENSE +202 -0
- package/NOTICE +44 -0
- package/README.md +266 -0
- package/bin/desklink-host.mjs +113 -0
- package/dist/bridge.d.ts +57 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +208 -0
- package/dist/bridge.js.map +1 -0
- package/dist/engineProcess.d.ts +82 -0
- package/dist/engineProcess.d.ts.map +1 -0
- package/dist/engineProcess.js +243 -0
- package/dist/engineProcess.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +169 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +18 -0
- package/dist/protocol.js.map +1 -0
- package/dist/resolveEngine.d.ts +34 -0
- package/dist/resolveEngine.d.ts.map +1 -0
- package/dist/resolveEngine.js +103 -0
- package/dist/resolveEngine.js.map +1 -0
- package/docs/BOUNDARY.md +145 -0
- package/docs/PROTOCOL.md +363 -0
- package/engine/Cargo.lock +3077 -0
- package/engine/Cargo.toml +37 -0
- package/engine/build.rs +107 -0
- package/engine/native/inputtino_shim.cpp +11 -0
- package/engine/native/vpx_shim.c +184 -0
- package/engine/native/wrapper.h +7 -0
- package/engine/src/capture.rs +510 -0
- package/engine/src/clipboard.rs +132 -0
- package/engine/src/convert.rs +299 -0
- package/engine/src/encoder.rs +204 -0
- package/engine/src/input.rs +355 -0
- package/engine/src/keymap.rs +338 -0
- package/engine/src/main.rs +614 -0
- package/engine/src/peer.rs +775 -0
- package/engine/src/portal.rs +115 -0
- package/engine/src/protocol.rs +331 -0
- package/engine/src/session.rs +2159 -0
- package/engine/src/x11.rs +244 -0
- package/engine/vendor/inputtino/.clang-format +166 -0
- package/engine/vendor/inputtino/.clang-tidy +30 -0
- package/engine/vendor/inputtino/.dockerignore +11 -0
- package/engine/vendor/inputtino/.github/workflows/mirror-repo.yml +14 -0
- package/engine/vendor/inputtino/.github/workflows/python-lint-format-test-typecheck.yml +124 -0
- package/engine/vendor/inputtino/.github/workflows/python-test-install.yaml +49 -0
- package/engine/vendor/inputtino/.github/workflows/rust.yml +40 -0
- package/engine/vendor/inputtino/CMakeLists.txt +187 -0
- package/engine/vendor/inputtino/LICENSE +21 -0
- package/engine/vendor/inputtino/README.md +110 -0
- package/engine/vendor/inputtino/bindings/python/.devcontainer/Dockerfile +23 -0
- package/engine/vendor/inputtino/bindings/python/.devcontainer/devcontainer.json +28 -0
- package/engine/vendor/inputtino/bindings/python/.python-version +1 -0
- package/engine/vendor/inputtino/bindings/python/CMakeLists.txt +23 -0
- package/engine/vendor/inputtino/bindings/python/CPPLINT.cfg +2 -0
- package/engine/vendor/inputtino/bindings/python/Makefile +49 -0
- package/engine/vendor/inputtino/bindings/python/README.md +215 -0
- package/engine/vendor/inputtino/bindings/python/pyproject.toml +178 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/__init__.py +43 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/_core.pyi +533 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/base.py +63 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/joypad.py +248 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/keyboard.py +256 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/mouse.py +106 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/pentablet.py +73 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/py.typed +0 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/touchscreen.py +61 -0
- package/engine/vendor/inputtino/bindings/python/src/inputtino/trackpad.py +74 -0
- package/engine/vendor/inputtino/bindings/python/src/main.cpp +217 -0
- package/engine/vendor/inputtino/bindings/python/tests/__init__.py +0 -0
- package/engine/vendor/inputtino/bindings/python/tests/helpers.py +3 -0
- package/engine/vendor/inputtino/bindings/python/tests/test_base.py +60 -0
- package/engine/vendor/inputtino/bindings/python/tests/test_inputtino.py +5 -0
- package/engine/vendor/inputtino/bindings/python/tests/test_joypad.py +332 -0
- package/engine/vendor/inputtino/bindings/python/tests/test_keyboard.py +164 -0
- package/engine/vendor/inputtino/bindings/python/tests/test_mouse.py +185 -0
- package/engine/vendor/inputtino/bindings/python/tests/test_pentablet.py +198 -0
- package/engine/vendor/inputtino/bindings/python/tests/test_touchscreen.py +144 -0
- package/engine/vendor/inputtino/bindings/python/tests/test_trackpad.py +146 -0
- package/engine/vendor/inputtino/bindings/python/uv.lock +390 -0
- package/engine/vendor/inputtino/bindings/rust/Cargo.toml +6 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/.dockerignore +1 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/Cargo.toml +18 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/examples/keyboard.rs +33 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/examples/mouse.rs +17 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/src/common.rs +116 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/src/error.rs +17 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad.rs +74 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad_nintendo.rs +135 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad_ps5.rs +280 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad_xbox.rs +134 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/src/keyboard.rs +76 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/src/lib.rs +32 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/src/mouse.rs +178 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/tests/common.rs +38 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/tests/joypads.rs +150 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/tests/keyboard.rs +73 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino/tests/mouse.rs +139 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino-sys/.dockerignore +1 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino-sys/Cargo.toml +12 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino-sys/build.rs +48 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino-sys/src/lib.rs +5 -0
- package/engine/vendor/inputtino/bindings/rust/inputtino-sys/wrapper.h +1 -0
- package/engine/vendor/inputtino/cmake/utils.cmake +9 -0
- package/engine/vendor/inputtino/docker/Dockerfile +70 -0
- package/engine/vendor/inputtino/include/inputtino/input.h +324 -0
- package/engine/vendor/inputtino/include/inputtino/input.hpp +466 -0
- package/engine/vendor/inputtino/include/inputtino/result.hpp +37 -0
- package/engine/vendor/inputtino/share/pkgconfig/libinputtino.pc.in +17 -0
- package/engine/vendor/inputtino/src/c-bindings/helpers.hpp +19 -0
- package/engine/vendor/inputtino/src/c-bindings/joypad_ps5.cpp +115 -0
- package/engine/vendor/inputtino/src/c-bindings/joypad_switch.cpp +63 -0
- package/engine/vendor/inputtino/src/c-bindings/joypad_xone.cpp +62 -0
- package/engine/vendor/inputtino/src/c-bindings/keyboard.cpp +42 -0
- package/engine/vendor/inputtino/src/c-bindings/mouse.cpp +67 -0
- package/engine/vendor/inputtino/src/c-bindings/pen_tablet.cpp +50 -0
- package/engine/vendor/inputtino/src/c-bindings/touchscreen.cpp +43 -0
- package/engine/vendor/inputtino/src/c-bindings/trackpad.cpp +49 -0
- package/engine/vendor/inputtino/src/uhid/README.adoc +63 -0
- package/engine/vendor/inputtino/src/uhid/include/crc32.hpp +38 -0
- package/engine/vendor/inputtino/src/uhid/include/uhid/protected_types.hpp +36 -0
- package/engine/vendor/inputtino/src/uhid/include/uhid/ps5.hpp +550 -0
- package/engine/vendor/inputtino/src/uhid/include/uhid/uhid.hpp +168 -0
- package/engine/vendor/inputtino/src/uhid/joypad_ps5.cpp +580 -0
- package/engine/vendor/inputtino/src/uinput/include/inputtino/keyboard.hpp +84 -0
- package/engine/vendor/inputtino/src/uinput/include/inputtino/protected_ps5_types.hpp +7 -0
- package/engine/vendor/inputtino/src/uinput/include/inputtino/protected_types.hpp +110 -0
- package/engine/vendor/inputtino/src/uinput/joypad_nintendo.cpp +190 -0
- package/engine/vendor/inputtino/src/uinput/joypad_ps.cpp +203 -0
- package/engine/vendor/inputtino/src/uinput/joypad_utils.hpp +246 -0
- package/engine/vendor/inputtino/src/uinput/joypad_xbox.cpp +190 -0
- package/engine/vendor/inputtino/src/uinput/keyboard.cpp +120 -0
- package/engine/vendor/inputtino/src/uinput/mouse.cpp +199 -0
- package/engine/vendor/inputtino/src/uinput/pentablet.cpp +162 -0
- package/engine/vendor/inputtino/src/uinput/touchscreen.cpp +140 -0
- package/engine/vendor/inputtino/src/uinput/trackpad.cpp +192 -0
- package/examples/reference-client.html +428 -0
- 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
|
+
}
|