@desklink/host 0.5.3 → 0.5.6
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/README.md +33 -19
- package/dist/bridge.js +3 -3
- package/dist/bridge.js.map +1 -1
- package/dist/engineProcess.d.ts +5 -0
- package/dist/engineProcess.d.ts.map +1 -1
- package/dist/engineProcess.js +6 -3
- package/dist/engineProcess.js.map +1 -1
- package/dist/protocol.d.ts +39 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/docs/BOUNDARY.md +4 -2
- package/docs/PROTOCOL.md +70 -7
- package/engine/Cargo.lock +6 -8
- package/engine/Cargo.toml +13 -1
- package/engine/src/audio.rs +398 -0
- package/engine/src/clipboard.rs +0 -34
- package/engine/src/convert.rs +0 -170
- package/engine/src/cursor.rs +0 -23
- package/engine/src/input.rs +0 -65
- package/engine/src/input_mac.rs +0 -11
- package/engine/src/keeper.rs +0 -137
- package/engine/src/keymap.rs +0 -107
- package/engine/src/keymap_mac.rs +0 -25
- package/engine/src/mac.rs +1 -28
- package/engine/src/main.rs +22 -0
- package/engine/src/peer.rs +297 -0
- package/engine/src/portal.rs +0 -18
- package/engine/src/protocol.rs +22 -162
- package/engine/src/session.rs +176 -3
- package/engine/src/win.rs +1 -0
- package/engine/src/x11.rs +0 -13
- package/engine/vendor/rtc-ice/Cargo.toml +137 -0
- package/engine/vendor/rtc-ice/DESKLINK-PATCH.md +111 -0
- package/engine/vendor/rtc-ice/LICENSE-APACHE +201 -0
- package/engine/vendor/rtc-ice/LICENSE-MIT +21 -0
- package/engine/vendor/rtc-ice/README.md +30 -0
- package/engine/vendor/rtc-ice/examples/ping_pong.rs +376 -0
- package/engine/vendor/rtc-ice/src/agent/agent_config.rs +126 -0
- package/engine/vendor/rtc-ice/src/agent/agent_proto.rs +172 -0
- package/engine/vendor/rtc-ice/src/agent/agent_selector.rs +647 -0
- package/engine/vendor/rtc-ice/src/agent/agent_stats.rs +292 -0
- package/engine/vendor/rtc-ice/src/agent/agent_test.rs +3594 -0
- package/engine/vendor/rtc-ice/src/agent/mod.rs +1753 -0
- package/engine/vendor/rtc-ice/src/attributes/control/control_test.rs +168 -0
- package/engine/vendor/rtc-ice/src/attributes/control/mod.rs +142 -0
- package/engine/vendor/rtc-ice/src/attributes/mod.rs +6 -0
- package/engine/vendor/rtc-ice/src/attributes/priority/mod.rs +37 -0
- package/engine/vendor/rtc-ice/src/attributes/priority/priority_test.rs +39 -0
- package/engine/vendor/rtc-ice/src/attributes/use_candidate/mod.rs +33 -0
- package/engine/vendor/rtc-ice/src/attributes/use_candidate/use_candidate_test.rs +19 -0
- package/engine/vendor/rtc-ice/src/candidate/candidate_host.rs +53 -0
- package/engine/vendor/rtc-ice/src/candidate/candidate_pair.rs +215 -0
- package/engine/vendor/rtc-ice/src/candidate/candidate_pair_test.rs +201 -0
- package/engine/vendor/rtc-ice/src/candidate/candidate_peer_reflexive.rs +49 -0
- package/engine/vendor/rtc-ice/src/candidate/candidate_relay.rs +53 -0
- package/engine/vendor/rtc-ice/src/candidate/candidate_relay_test.rs +114 -0
- package/engine/vendor/rtc-ice/src/candidate/candidate_server_reflexive.rs +53 -0
- package/engine/vendor/rtc-ice/src/candidate/candidate_server_reflexive_test.rs +91 -0
- package/engine/vendor/rtc-ice/src/candidate/candidate_test.rs +540 -0
- package/engine/vendor/rtc-ice/src/candidate/mod.rs +679 -0
- package/engine/vendor/rtc-ice/src/dl_trace.rs +49 -0
- package/engine/vendor/rtc-ice/src/lib.rs +88 -0
- package/engine/vendor/rtc-ice/src/mdns/mdns_test.rs +150 -0
- package/engine/vendor/rtc-ice/src/mdns/mod.rs +69 -0
- package/engine/vendor/rtc-ice/src/network_type/mod.rs +162 -0
- package/engine/vendor/rtc-ice/src/network_type/network_type_test.rs +95 -0
- package/engine/vendor/rtc-ice/src/rand/mod.rs +91 -0
- package/engine/vendor/rtc-ice/src/rand/rand_test.rs +115 -0
- package/engine/vendor/rtc-ice/src/state/mod.rs +106 -0
- package/engine/vendor/rtc-ice/src/state/state_test.rs +46 -0
- package/engine/vendor/rtc-ice/src/stats/mod.rs +166 -0
- package/engine/vendor/rtc-ice/src/tcp_type/mod.rs +47 -0
- package/engine/vendor/rtc-ice/src/tcp_type/tcp_type_test.rs +18 -0
- package/engine/vendor/rtc-ice/src/url/mod.rs +266 -0
- package/engine/vendor/rtc-ice/src/url/url_test.rs +142 -0
- package/package.json +8 -3
package/engine/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "desklink-host"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.6"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
rust-version = "1.97.1"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -21,6 +21,10 @@ anyhow = "1"
|
|
|
21
21
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "time", "signal"] }
|
|
22
22
|
libc = "0.2"
|
|
23
23
|
bytes = "1"
|
|
24
|
+
# rtc-sctp 0.21.1 is required through this stack: 0.21.0 replaces an
|
|
25
|
+
# expired-but-unhandled T3 retransmission deadline, so the data channel stalls
|
|
26
|
+
# on the new nominated pair after an ICE restart and the client reopens the
|
|
27
|
+
# session. Cargo.lock pins 0.21.1; drop the pin when a later release carries it.
|
|
24
28
|
webrtc = { version = "0.21", default-features = false, features = ["runtime-tokio", "crypto-ring"] }
|
|
25
29
|
rtc = { version = "0.21", default-features = false, features = ["crypto-ring"] }
|
|
26
30
|
log = "0.4"
|
|
@@ -64,3 +68,11 @@ cc = "1"
|
|
|
64
68
|
[profile.release]
|
|
65
69
|
lto = "thin"
|
|
66
70
|
opt-level = 2
|
|
71
|
+
|
|
72
|
+
# The engine needs an ICE restart that does not interrupt media already flowing
|
|
73
|
+
# on the current selected pair (roam persistence, < 200 ms gap). rtc-ice 0.21.0
|
|
74
|
+
# clears the selected pair and every candidate pair in `Agent::apply_restart`,
|
|
75
|
+
# and exposes no setting to keep them, so a patched copy is vendored here. See
|
|
76
|
+
# vendor/rtc-ice/DESKLINK-PATCH.md for what changed and how to drop it.
|
|
77
|
+
[patch.crates-io]
|
|
78
|
+
rtc-ice = { path = "vendor/rtc-ice" }
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
//! System audio probing and opt-in capture of the default PipeWire sink monitor.
|
|
2
|
+
//!
|
|
3
|
+
//! Linux captures through a PipeWire monitor of a sink, so the machine fact is
|
|
4
|
+
//! "a reachable sink exposes monitor ports". `capabilities` may answer that: it
|
|
5
|
+
//! must not prompt and must not start a capture, so this connects as any client
|
|
6
|
+
//! does, reads the registry and leaves — no portal, no D-Bus, no consent dialog,
|
|
7
|
+
//! no stream. It answers for the machine and not for the build: the same binary
|
|
8
|
+
//! says `false` where no PipeWire, or no sink, is reachable, and a host running
|
|
9
|
+
//! PulseAudio without PipeWire is `false` too, because this build has no
|
|
10
|
+
//! PulseAudio client and no capture path that could use one.
|
|
11
|
+
|
|
12
|
+
use anyhow::Context as _;
|
|
13
|
+
use pipewire as pw;
|
|
14
|
+
use std::cell::RefCell;
|
|
15
|
+
use std::collections::{HashSet, VecDeque};
|
|
16
|
+
use std::rc::Rc;
|
|
17
|
+
use std::result::Result;
|
|
18
|
+
use std::sync::{Arc, Mutex, MutexGuard, mpsc};
|
|
19
|
+
use std::thread;
|
|
20
|
+
use std::time::{Duration, Instant};
|
|
21
|
+
|
|
22
|
+
/// The sample format this seam carries. It describes this build, so it is
|
|
23
|
+
/// reported whether or not this machine has a sink; the capture asks PipeWire
|
|
24
|
+
/// for exactly it.
|
|
25
|
+
pub const FORMAT: &str = "s16le";
|
|
26
|
+
pub const SAMPLE_RATE: u32 = 48_000;
|
|
27
|
+
pub const CHANNELS: u32 = 2;
|
|
28
|
+
|
|
29
|
+
/// The sample format a session would carry, best first.
|
|
30
|
+
pub const FORMATS: &[&str] = &[FORMAT];
|
|
31
|
+
|
|
32
|
+
/// What a session would capture from, and in what format.
|
|
33
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
34
|
+
pub struct AudioSource {
|
|
35
|
+
/// Names the mechanism, so a consumer can tell a sink monitor from a future
|
|
36
|
+
/// consent-carrying portal audio node.
|
|
37
|
+
pub name: &'static str,
|
|
38
|
+
pub formats: &'static [&'static str],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// The desktop's own mix, as the sink sends it to its monitor ports.
|
|
42
|
+
const MONITOR: AudioSource = AudioSource {
|
|
43
|
+
name: "pipewire-monitor",
|
|
44
|
+
formats: FORMATS,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/// How long the registry may take to answer; `capabilities` must not hang
|
|
48
|
+
/// behind a server that is up but silent.
|
|
49
|
+
const TIMEOUT: Duration = Duration::from_millis(1500);
|
|
50
|
+
|
|
51
|
+
#[derive(Default)]
|
|
52
|
+
struct Scan {
|
|
53
|
+
/// Node ids whose `media.class` is `Audio/Sink`, and the node id each
|
|
54
|
+
/// `port.monitor` output port belongs to. A sink with a monitor is the node
|
|
55
|
+
/// a capture stream would link to; a sink without one cannot be captured
|
|
56
|
+
/// this way.
|
|
57
|
+
sinks: HashSet<u32>,
|
|
58
|
+
monitors: HashSet<u32>,
|
|
59
|
+
answered: bool,
|
|
60
|
+
failed: bool,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// `Some(source)` exactly when opening a session with audio here would capture
|
|
64
|
+
/// the desktop's system audio without prompting; `None` otherwise.
|
|
65
|
+
pub fn probe() -> Option<AudioSource> {
|
|
66
|
+
pw::init();
|
|
67
|
+
let main_loop = pw::main_loop::MainLoopRc::new(None).ok()?;
|
|
68
|
+
let context = pw::context::ContextRc::new(&main_loop, None).ok()?;
|
|
69
|
+
let core = context.connect_rc(None).ok()?;
|
|
70
|
+
let registry = core.get_registry().ok()?;
|
|
71
|
+
|
|
72
|
+
// The round trip is asked for before its listener exists; a reply cannot be
|
|
73
|
+
// dispatched until the loop below iterates, so nothing is missed.
|
|
74
|
+
let pending = core.sync(0).ok();
|
|
75
|
+
let scan = Rc::new(RefCell::new(Scan::default()));
|
|
76
|
+
|
|
77
|
+
let _core = core
|
|
78
|
+
.add_listener_local()
|
|
79
|
+
.done({
|
|
80
|
+
let scan = Rc::clone(&scan);
|
|
81
|
+
move |_, seq| {
|
|
82
|
+
if Some(seq) == pending {
|
|
83
|
+
scan.borrow_mut().answered = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
.error({
|
|
88
|
+
let scan = Rc::clone(&scan);
|
|
89
|
+
move |_, _, _, _| {
|
|
90
|
+
let mut scan = scan.borrow_mut();
|
|
91
|
+
scan.failed = true;
|
|
92
|
+
scan.answered = true;
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
.register();
|
|
96
|
+
|
|
97
|
+
let _registry = registry
|
|
98
|
+
.add_listener_local()
|
|
99
|
+
.global({
|
|
100
|
+
let scan = Rc::clone(&scan);
|
|
101
|
+
move |global| {
|
|
102
|
+
let Some(props) = global.props else { return };
|
|
103
|
+
let mut scan = scan.borrow_mut();
|
|
104
|
+
match global.type_ {
|
|
105
|
+
pw::types::ObjectType::Node
|
|
106
|
+
if props.get(*pw::keys::MEDIA_CLASS) == Some("Audio/Sink") =>
|
|
107
|
+
{
|
|
108
|
+
scan.sinks.insert(global.id);
|
|
109
|
+
}
|
|
110
|
+
pw::types::ObjectType::Port
|
|
111
|
+
if props.get(*pw::keys::PORT_MONITOR) == Some("true")
|
|
112
|
+
&& props.get(*pw::keys::PORT_DIRECTION) == Some("out") =>
|
|
113
|
+
{
|
|
114
|
+
if let Some(node) = props.get(*pw::keys::NODE_ID).and_then(|id| id.parse().ok()) {
|
|
115
|
+
scan.monitors.insert(node);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
_ => {}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
.register();
|
|
123
|
+
|
|
124
|
+
let deadline = Instant::now() + TIMEOUT;
|
|
125
|
+
while Instant::now() < deadline {
|
|
126
|
+
if main_loop
|
|
127
|
+
.loop_()
|
|
128
|
+
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(50)))
|
|
129
|
+
< 0
|
|
130
|
+
{
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
let scan = scan.borrow();
|
|
134
|
+
if scan.answered {
|
|
135
|
+
let capturable = !scan.failed && scan.sinks.intersection(&scan.monitors).next().is_some();
|
|
136
|
+
return capturable.then_some(MONITOR);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
None
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// s16le interleaved: the bytes of one sample frame across every channel.
|
|
143
|
+
pub const BYTES_PER_FRAME: u64 = 2 * CHANNELS as u64;
|
|
144
|
+
|
|
145
|
+
/// 100 ms per chunk: prompt enough to show progress, calm enough that one
|
|
146
|
+
/// chunk is worth its own file write and its own change event.
|
|
147
|
+
const CHUNK_FRAMES: usize = SAMPLE_RATE as usize / 10;
|
|
148
|
+
const CHUNK_BYTES: usize = CHUNK_FRAMES * BYTES_PER_FRAME as usize;
|
|
149
|
+
|
|
150
|
+
/// Chunks that may wait for the consumer; past this the newest chunk is
|
|
151
|
+
/// dropped and counted, so what is read stays in order.
|
|
152
|
+
const QUEUE_CHUNKS: usize = 8;
|
|
153
|
+
|
|
154
|
+
/// One captured chunk, s16le interleaved, stamped on the engine clock.
|
|
155
|
+
pub struct Chunk {
|
|
156
|
+
pub seq: u64,
|
|
157
|
+
pub at: Instant,
|
|
158
|
+
pub bytes: Vec<u8>,
|
|
159
|
+
/// How far this chunk's start fell behind the previous chunk's end: an
|
|
160
|
+
/// under-run reads as a gap, never as silence.
|
|
161
|
+
pub gap_micros: u64,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// A chunk announcement that borrows nothing, so the callback runs outside
|
|
165
|
+
/// the lock.
|
|
166
|
+
#[derive(Clone, Copy)]
|
|
167
|
+
pub struct ChunkNotice {
|
|
168
|
+
pub seq: u64,
|
|
169
|
+
pub at: Instant,
|
|
170
|
+
pub bytes: u64,
|
|
171
|
+
pub gap_micros: u64,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#[derive(Default)]
|
|
175
|
+
struct Queue {
|
|
176
|
+
chunks: VecDeque<Chunk>,
|
|
177
|
+
frames: u64,
|
|
178
|
+
bytes: u64,
|
|
179
|
+
dropped_bytes: u64,
|
|
180
|
+
gap_micros: u64,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// What the capture thread and its session share.
|
|
184
|
+
struct Shared {
|
|
185
|
+
queue: Mutex<Queue>,
|
|
186
|
+
ready: Mutex<Option<mpsc::Sender<Result<(), String>>>>,
|
|
187
|
+
on_chunk: Box<dyn Fn(ChunkNotice) + Send + Sync>,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
impl Shared {
|
|
191
|
+
fn report(&self, result: Result<(), String>) {
|
|
192
|
+
if let Some(ready) = lock(&self.ready).take() {
|
|
193
|
+
let _ = ready.send(result);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// A running capture. Dropping it quits the PipeWire loop and joins the
|
|
199
|
+
/// thread; the session holds it for the session's lifetime.
|
|
200
|
+
pub struct AudioCapture {
|
|
201
|
+
shared: Arc<Shared>,
|
|
202
|
+
quit: pw::channel::Sender<()>,
|
|
203
|
+
thread: Option<thread::JoinHandle<()>>,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
impl AudioCapture {
|
|
207
|
+
/// Capture the default sink's monitor over this session's own PipeWire
|
|
208
|
+
/// connection. Returns once the stream is up on the negotiated format; a
|
|
209
|
+
/// machine with no capturable sink fails here with a readable reason.
|
|
210
|
+
pub fn start(on_chunk: Box<dyn Fn(ChunkNotice) + Send + Sync>) -> Result<Self, String> {
|
|
211
|
+
let (ready, ready_rx) = mpsc::channel();
|
|
212
|
+
let shared = Arc::new(Shared {
|
|
213
|
+
queue: Mutex::new(Queue::default()),
|
|
214
|
+
ready: Mutex::new(Some(ready)),
|
|
215
|
+
on_chunk,
|
|
216
|
+
});
|
|
217
|
+
let (quit, stop) = pw::channel::channel();
|
|
218
|
+
let thread = {
|
|
219
|
+
let shared = Arc::clone(&shared);
|
|
220
|
+
thread::Builder::new()
|
|
221
|
+
.name("desklink-audio".into())
|
|
222
|
+
.spawn(move || {
|
|
223
|
+
if let Err(error) = run(stop, Arc::clone(&shared)) {
|
|
224
|
+
// After the stream is up this is meaningless; before
|
|
225
|
+
// that it is the only path the reason has out.
|
|
226
|
+
shared.report(Err(format!("{error:#}")));
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
.map_err(|error| format!("failed to spawn audio capture: {error}"))?
|
|
230
|
+
};
|
|
231
|
+
let reason = match ready_rx.recv_timeout(Duration::from_secs(5)) {
|
|
232
|
+
Ok(Ok(())) => return Ok(Self { shared, quit, thread: Some(thread) }),
|
|
233
|
+
Ok(Err(reason)) => reason,
|
|
234
|
+
Err(_) => String::from(
|
|
235
|
+
"the audio stream did not start within 5s: no sink to capture, or no session manager to link it",
|
|
236
|
+
),
|
|
237
|
+
};
|
|
238
|
+
let _ = quit.send(());
|
|
239
|
+
let _ = thread.join();
|
|
240
|
+
Err(reason)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// The next chunk in order, or `None` once everything captured is read.
|
|
244
|
+
pub fn take(&self) -> Option<Chunk> {
|
|
245
|
+
lock(&self.shared.queue).chunks.pop_front()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/// Captured frames, captured bytes, dropped bytes, under-run microseconds.
|
|
249
|
+
pub fn totals(&self) -> (u64, u64, u64, u64) {
|
|
250
|
+
let queue = lock(&self.shared.queue);
|
|
251
|
+
(queue.frames, queue.bytes, queue.dropped_bytes, queue.gap_micros)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
impl Drop for AudioCapture {
|
|
256
|
+
fn drop(&mut self) {
|
|
257
|
+
let _ = self.quit.send(());
|
|
258
|
+
if let Some(thread) = self.thread.take() {
|
|
259
|
+
let _ = thread.join();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/// The capture thread's own state: the chunk being filled, where it started,
|
|
265
|
+
/// when the last one ended, and the next sequence number.
|
|
266
|
+
#[derive(Default)]
|
|
267
|
+
struct Stream {
|
|
268
|
+
pending: Vec<u8>,
|
|
269
|
+
started: Option<Instant>,
|
|
270
|
+
end: Option<Instant>,
|
|
271
|
+
seq: u64,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
fn chunk_span() -> Duration {
|
|
275
|
+
Duration::from_secs_f64(CHUNK_FRAMES as f64 / SAMPLE_RATE as f64)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Hand one chunk over, or drop and count it when the consumer lags; the
|
|
279
|
+
/// sequence always advances, so a drop reads as a gap, not contiguous audio.
|
|
280
|
+
fn enqueue(shared: &Shared, state: &mut Stream, bytes: Vec<u8>, started: Instant) {
|
|
281
|
+
state.seq += 1;
|
|
282
|
+
let len = bytes.len() as u64;
|
|
283
|
+
let gap = state.end.map_or(Duration::ZERO, |end| started.saturating_duration_since(end));
|
|
284
|
+
state.end = Some(started + chunk_span());
|
|
285
|
+
let notice = ChunkNotice { seq: state.seq, at: started, bytes: len, gap_micros: gap.as_micros() as u64 };
|
|
286
|
+
let chunk = Chunk { seq: notice.seq, at: started, bytes, gap_micros: notice.gap_micros };
|
|
287
|
+
{
|
|
288
|
+
let mut queue = lock(&shared.queue);
|
|
289
|
+
queue.frames += len / BYTES_PER_FRAME;
|
|
290
|
+
queue.bytes += len;
|
|
291
|
+
queue.gap_micros += notice.gap_micros;
|
|
292
|
+
if queue.chunks.len() < QUEUE_CHUNKS {
|
|
293
|
+
queue.chunks.push_back(chunk);
|
|
294
|
+
} else {
|
|
295
|
+
queue.dropped_bytes += len;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
(shared.on_chunk)(notice);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fn run(stop: pw::channel::Receiver<()>, shared: Arc<Shared>) -> anyhow::Result<()> {
|
|
302
|
+
pw::init();
|
|
303
|
+
let main_loop = pw::main_loop::MainLoopRc::new(None).context("pw_main_loop_new failed")?;
|
|
304
|
+
let _stop = stop.attach(main_loop.loop_(), {
|
|
305
|
+
let main_loop = main_loop.clone();
|
|
306
|
+
move |_| main_loop.quit()
|
|
307
|
+
});
|
|
308
|
+
let context = pw::context::ContextBox::new(main_loop.loop_(), None).context("pw_context_new failed")?;
|
|
309
|
+
// The video path arrives holding a portal fd; audio has none and connects
|
|
310
|
+
// to this session's own PipeWire, which is what makes a private runtime
|
|
311
|
+
// directory the isolation boundary.
|
|
312
|
+
let core = context.connect(None).context("pw_context_connect failed")?;
|
|
313
|
+
|
|
314
|
+
let props = pw::properties::properties! {
|
|
315
|
+
*pw::keys::MEDIA_TYPE => "Audio",
|
|
316
|
+
*pw::keys::MEDIA_CATEGORY => "Capture",
|
|
317
|
+
*pw::keys::MEDIA_ROLE => "Music",
|
|
318
|
+
// The sink's own output, not a source's input: the desktop's mix.
|
|
319
|
+
*pw::keys::STREAM_CAPTURE_SINK => "true",
|
|
320
|
+
};
|
|
321
|
+
let stream =
|
|
322
|
+
pw::stream::StreamBox::new(&core, "desklink-audio", props).context("pw_stream_new failed")?;
|
|
323
|
+
|
|
324
|
+
let listener = stream
|
|
325
|
+
.add_local_listener_with_user_data(Stream::default())
|
|
326
|
+
.state_changed({
|
|
327
|
+
let shared = Arc::clone(&shared);
|
|
328
|
+
move |_, _, _old, new| match new {
|
|
329
|
+
pw::stream::StreamState::Streaming => shared.report(Ok(())),
|
|
330
|
+
pw::stream::StreamState::Error(message) => shared.report(Err(format!("PipeWire audio stream stopped: {message}"))),
|
|
331
|
+
_ => {}
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
.process({
|
|
335
|
+
let shared = Arc::clone(&shared);
|
|
336
|
+
move |stream, state| {
|
|
337
|
+
let Some(mut buffer) = stream.dequeue_buffer() else { return };
|
|
338
|
+
let datas = buffer.datas_mut();
|
|
339
|
+
let Some(data) = datas.first_mut() else { return };
|
|
340
|
+
let c = data.chunk();
|
|
341
|
+
let (offset, size) = (c.offset() as usize, c.size() as usize);
|
|
342
|
+
let Some(bytes) = data.data() else { return };
|
|
343
|
+
let Some(bytes) = bytes.get(offset..offset.saturating_add(size)) else { return };
|
|
344
|
+
if bytes.is_empty() { return; }
|
|
345
|
+
// A chunk starts when its first byte arrives, so a stall reads
|
|
346
|
+
// as a gap; a split inside one buffer keeps the sample-derived span.
|
|
347
|
+
let mut started = Instant::now();
|
|
348
|
+
if !state.pending.is_empty() {
|
|
349
|
+
started = state.started.unwrap_or(started);
|
|
350
|
+
}
|
|
351
|
+
state.pending.extend_from_slice(bytes);
|
|
352
|
+
while state.pending.len() >= CHUNK_BYTES {
|
|
353
|
+
let rest = state.pending.split_off(CHUNK_BYTES);
|
|
354
|
+
let filled = std::mem::replace(&mut state.pending, rest);
|
|
355
|
+
enqueue(&shared, state, filled, started);
|
|
356
|
+
started += chunk_span();
|
|
357
|
+
}
|
|
358
|
+
state.started = (!state.pending.is_empty()).then_some(started);
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
.register()
|
|
362
|
+
.context("pw_stream_add_listener failed")?;
|
|
363
|
+
|
|
364
|
+
// Ask for the one format this seam carries; `audioconvert` adapts the sink.
|
|
365
|
+
let mut info = pw::spa::param::audio::AudioInfoRaw::new();
|
|
366
|
+
info.set_format(pw::spa::param::audio::AudioFormat::S16LE);
|
|
367
|
+
info.set_rate(SAMPLE_RATE);
|
|
368
|
+
info.set_channels(CHANNELS);
|
|
369
|
+
let object = pw::spa::pod::Object {
|
|
370
|
+
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
|
371
|
+
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
|
372
|
+
properties: info.into(),
|
|
373
|
+
};
|
|
374
|
+
let pod = pw::spa::pod::serialize::PodSerializer::serialize(
|
|
375
|
+
std::io::Cursor::new(Vec::new()),
|
|
376
|
+
&pw::spa::pod::Value::Object(object),
|
|
377
|
+
)
|
|
378
|
+
.map_err(|error| anyhow::anyhow!("format pod serialization failed: {error}"))?;
|
|
379
|
+
let pod = pod.0.into_inner();
|
|
380
|
+
let mut params =
|
|
381
|
+
[pw::spa::pod::Pod::from_bytes(&pod).context("the format pod is not a valid pod")?];
|
|
382
|
+
stream
|
|
383
|
+
.connect(
|
|
384
|
+
pw::spa::utils::Direction::Input,
|
|
385
|
+
None,
|
|
386
|
+
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
|
|
387
|
+
&mut params,
|
|
388
|
+
)
|
|
389
|
+
.context("pw_stream_connect failed")?;
|
|
390
|
+
|
|
391
|
+
main_loop.run();
|
|
392
|
+
drop(listener);
|
|
393
|
+
Ok(())
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
|
397
|
+
m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
398
|
+
}
|
package/engine/src/clipboard.rs
CHANGED
|
@@ -270,37 +270,3 @@ pub fn x11_read(display: &str) -> Result<(String, bool)> {
|
|
|
270
270
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
|
-
|
|
274
|
-
#[cfg(test)]
|
|
275
|
-
mod tests {
|
|
276
|
-
use super::*;
|
|
277
|
-
|
|
278
|
-
#[test]
|
|
279
|
-
fn an_oversized_clipboard_is_cut_cleanly_and_reported() {
|
|
280
|
-
let mut raw = vec![b'a'; 4];
|
|
281
|
-
raw.extend_from_slice("\u{e9}".as_bytes());
|
|
282
|
-
raw.extend_from_slice(b"zzzz");
|
|
283
|
-
let (text, truncated) =
|
|
284
|
-
bounded_text(&raw, 5).expect("a character-boundary cut is valid text");
|
|
285
|
-
assert!(truncated, "a cut past the limit must be reported");
|
|
286
|
-
assert_eq!(
|
|
287
|
-
text, "aaaa",
|
|
288
|
-
"the partial character is dropped, not invented"
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
// A byte that is not valid UTF-8 before the cut is not a partial
|
|
292
|
-
// character; the whole read is refused.
|
|
293
|
-
let mut invalid = vec![b'a'; 4];
|
|
294
|
-
invalid.push(0xff);
|
|
295
|
-
invalid.extend_from_slice(b"zzzz");
|
|
296
|
-
assert!(bounded_text(&invalid, 5).is_err());
|
|
297
|
-
|
|
298
|
-
let (text, truncated) = bounded_text(b"hello", 5).unwrap();
|
|
299
|
-
assert!(!truncated);
|
|
300
|
-
assert_eq!(text, "hello");
|
|
301
|
-
|
|
302
|
-
let (text, truncated) = bounded_text(b"hello world", 5).unwrap();
|
|
303
|
-
assert!(truncated);
|
|
304
|
-
assert_eq!(text, "hello");
|
|
305
|
-
}
|
|
306
|
-
}
|
package/engine/src/convert.rs
CHANGED
|
@@ -408,173 +408,3 @@ fn pack(
|
|
|
408
408
|
(*u, *v) = chroma(sum(0), sum(1), sum(2));
|
|
409
409
|
}
|
|
410
410
|
}
|
|
411
|
-
|
|
412
|
-
#[cfg(test)]
|
|
413
|
-
mod tests {
|
|
414
|
-
use super::*;
|
|
415
|
-
|
|
416
|
-
#[test]
|
|
417
|
-
fn downscale_keeps_average_colour_and_plane_sizes() {
|
|
418
|
-
// 4x4 opaque red desktop in BGRx, downscaled to 2x2.
|
|
419
|
-
let mut src = vec![0u8; 4 * 16];
|
|
420
|
-
for px in src.chunks_exact_mut(4) {
|
|
421
|
-
px[0] = 0; // B
|
|
422
|
-
px[1] = 0; // G
|
|
423
|
-
px[2] = 255; // R
|
|
424
|
-
px[3] = 255; // X
|
|
425
|
-
}
|
|
426
|
-
let out = to_i420(&src, 4, 4, 16, PixelFormat::Bgrx, 2, 2).unwrap();
|
|
427
|
-
assert_eq!(out.data.len(), 4 + 2 * 1);
|
|
428
|
-
assert!(
|
|
429
|
-
out.y_plane().iter().all(|&y| y > 70),
|
|
430
|
-
"red luma should be high"
|
|
431
|
-
);
|
|
432
|
-
assert!(out.u_plane().iter().all(|&u| u < 128), "red has low Cb");
|
|
433
|
-
assert!(out.v_plane().iter().all(|&v| v > 200), "red has high Cr");
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
#[test]
|
|
437
|
-
fn odd_destination_is_clamped_to_whole_chroma_blocks() {
|
|
438
|
-
let src = vec![255u8; 4 * 16];
|
|
439
|
-
let out = to_i420(&src, 4, 4, 16, PixelFormat::Bgrx, 3, 3).unwrap();
|
|
440
|
-
assert_eq!((out.width, out.height), (2, 2));
|
|
441
|
-
assert_eq!(out.data.len(), 4 + 2 * 1);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
#[test]
|
|
445
|
-
fn a_stream_a_few_pixels_off_the_encoders_size_is_converted_to_exactly_that_size() {
|
|
446
|
-
// A fractional-scaled display reports one size to the portal and
|
|
447
|
-
// streams another; the encoder only accepts its own.
|
|
448
|
-
let src = vec![200u8; 4 * 1704 * 1066];
|
|
449
|
-
let out = to_i420(&src, 1704, 1066, 1704 * 4, PixelFormat::Bgrx, 1706, 1066).unwrap();
|
|
450
|
-
assert_eq!((out.width, out.height), (1706, 1066));
|
|
451
|
-
let out = to_i420(&src, 1704, 1066, 1704 * 4, PixelFormat::Bgrx, 1702, 1064).unwrap();
|
|
452
|
-
assert_eq!((out.width, out.height), (1702, 1064));
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
#[test]
|
|
456
|
-
fn still_pixels_keep_exact_source_colours_before_encoding() {
|
|
457
|
-
let src = [1, 2, 3, 0, 4, 5, 6, 0];
|
|
458
|
-
assert_eq!(
|
|
459
|
-
to_bgrx(&src, 2, 1, 8, PixelFormat::Bgrx, 2, 1).unwrap(),
|
|
460
|
-
src
|
|
461
|
-
);
|
|
462
|
-
assert_eq!(
|
|
463
|
-
to_bgrx(&src, 2, 1, 8, PixelFormat::Rgbx, 2, 1).unwrap(),
|
|
464
|
-
[3, 2, 1, 255, 6, 5, 4, 255]
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/// The per-pixel conversion this module used to run, kept as the definition
|
|
469
|
-
/// the fast paths must reproduce bit for bit.
|
|
470
|
-
fn reference(
|
|
471
|
-
src: &[u8],
|
|
472
|
-
sw: usize,
|
|
473
|
-
sh: usize,
|
|
474
|
-
stride: usize,
|
|
475
|
-
format: PixelFormat,
|
|
476
|
-
dw: usize,
|
|
477
|
-
dh: usize,
|
|
478
|
-
) -> Vec<u8> {
|
|
479
|
-
let (bpp, o) = layout(format).unwrap();
|
|
480
|
-
let rgb = |x: usize, y: usize| {
|
|
481
|
-
let p = &src[y * stride + x * bpp..];
|
|
482
|
-
(p[o[0]] as i32, p[o[1]] as i32, p[o[2]] as i32)
|
|
483
|
-
};
|
|
484
|
-
let sample = |dx: usize, dy: usize| {
|
|
485
|
-
let x0 = dx * sw / dw;
|
|
486
|
-
let x1 = ((dx + 1) * sw).div_ceil(dw).clamp(x0 + 1, sw);
|
|
487
|
-
let y0 = dy * sh / dh;
|
|
488
|
-
let y1 = ((dy + 1) * sh).div_ceil(dh).clamp(y0 + 1, sh);
|
|
489
|
-
let (mut r, mut g, mut b) = (0, 0, 0);
|
|
490
|
-
for y in y0..y1 {
|
|
491
|
-
for x in x0..x1 {
|
|
492
|
-
let p = rgb(x, y);
|
|
493
|
-
(r, g, b) = (r + p.0, g + p.1, b + p.2);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
let n = ((x1 - x0) * (y1 - y0)) as i32;
|
|
497
|
-
(r / n, g / n, b / n)
|
|
498
|
-
};
|
|
499
|
-
let (mut y, mut u, mut v) = (vec![0; dw * dh], vec![], vec![]);
|
|
500
|
-
for cy in 0..dh / 2 {
|
|
501
|
-
for cx in 0..dw / 2 {
|
|
502
|
-
let px = [(0, 0), (1, 0), (0, 1), (1, 1)]
|
|
503
|
-
.map(|(i, j)| (cx * 2 + i, cy * 2 + j, sample(cx * 2 + i, cy * 2 + j)));
|
|
504
|
-
for (x, yy, p) in px {
|
|
505
|
-
y[yy * dw + x] = luma(p.0, p.1, p.2);
|
|
506
|
-
}
|
|
507
|
-
let s = |f: fn(&(i32, i32, i32)) -> i32| {
|
|
508
|
-
(px.iter().map(|p| f(&p.2)).sum::<i32>() + 2) >> 2
|
|
509
|
-
};
|
|
510
|
-
let (cu, cv) = chroma(s(|p| p.0), s(|p| p.1), s(|p| p.2));
|
|
511
|
-
u.push(cu);
|
|
512
|
-
v.push(cv);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
[y, u, v].concat()
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
#[test]
|
|
519
|
-
fn every_path_matches_the_per_pixel_definition_exactly() {
|
|
520
|
-
let mut seed = 0x9e3779b9u32;
|
|
521
|
-
let mut noise = |n: usize| {
|
|
522
|
-
(0..n)
|
|
523
|
-
.map(|_| {
|
|
524
|
-
seed ^= seed << 13;
|
|
525
|
-
seed ^= seed >> 17;
|
|
526
|
-
seed ^= seed << 5;
|
|
527
|
-
seed as u8
|
|
528
|
-
})
|
|
529
|
-
.collect::<Vec<u8>>()
|
|
530
|
-
};
|
|
531
|
-
let formats = [
|
|
532
|
-
PixelFormat::Bgrx,
|
|
533
|
-
PixelFormat::Rgba,
|
|
534
|
-
PixelFormat::Bgr,
|
|
535
|
-
PixelFormat::Rgb,
|
|
536
|
-
];
|
|
537
|
-
// Native, a few pixels off, box downscales, and boxes near and past the
|
|
538
|
-
// largest area the reciprocal table covers.
|
|
539
|
-
for (sw, sh, dw, dh) in [
|
|
540
|
-
(256, 126, 4, 2),
|
|
541
|
-
(400, 200, 4, 2),
|
|
542
|
-
(64, 36, 64, 36),
|
|
543
|
-
(67, 41, 66, 40),
|
|
544
|
-
(100, 60, 38, 22),
|
|
545
|
-
(90, 50, 92, 50),
|
|
546
|
-
(33, 17, 8, 6),
|
|
547
|
-
] {
|
|
548
|
-
for format in formats {
|
|
549
|
-
let bpp = layout(format).unwrap().0;
|
|
550
|
-
let stride = sw * bpp + 12;
|
|
551
|
-
let src = noise(stride * sh);
|
|
552
|
-
let fast = to_i420(&src, sw, sh, stride, format, dw, dh).unwrap();
|
|
553
|
-
assert!(
|
|
554
|
-
fast.data == reference(&src, sw, sh, stride, format, dw & !1, dh & !1),
|
|
555
|
-
"{format:?} {sw}x{sh} -> {dw}x{dh}"
|
|
556
|
-
);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
#[test]
|
|
562
|
-
fn padded_bgra_rows_remain_aligned_in_stills_and_video() {
|
|
563
|
-
// ScreenCaptureKit pads 3420 BGRA pixels from 13680 to 13696 bytes/row.
|
|
564
|
-
let src = [
|
|
565
|
-
0, 0, 255, 255, 0, 0, 255, 255, 0, 255, 0, 0, // red row + green padding
|
|
566
|
-
255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 0, // blue row + green padding
|
|
567
|
-
];
|
|
568
|
-
assert_eq!(
|
|
569
|
-
to_bgrx(&src, 2, 2, 12, PixelFormat::Bgra, 2, 2).unwrap(),
|
|
570
|
-
[src[0..8].to_vec(), src[12..20].to_vec()].concat()
|
|
571
|
-
);
|
|
572
|
-
let video = to_i420(&src, 2, 2, 12, PixelFormat::Bgra, 2, 2).unwrap();
|
|
573
|
-
assert_eq!(video.y_plane(), &[82, 82, 41, 41]);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
#[test]
|
|
577
|
-
fn unsupported_format_is_reported_not_guessed() {
|
|
578
|
-
assert!(to_i420(&vec![0u8; 64], 4, 4, 16, PixelFormat::Unsupported, 2, 2).is_none());
|
|
579
|
-
}
|
|
580
|
-
}
|
package/engine/src/cursor.rs
CHANGED
|
@@ -32,26 +32,3 @@ impl Reporter {
|
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
#[cfg(test)]
|
|
37
|
-
mod tests {
|
|
38
|
-
use super::*;
|
|
39
|
-
#[test]
|
|
40
|
-
fn reports_initial_motion_and_visibility_but_not_unchanged_polls() {
|
|
41
|
-
let (tx, rx) = std::sync::mpsc::channel();
|
|
42
|
-
let mut reporter = Reporter::new(Box::new(move |p| {
|
|
43
|
-
tx.send(p).unwrap();
|
|
44
|
-
}));
|
|
45
|
-
reporter.update(10, 20, true);
|
|
46
|
-
reporter.update(10, 20, true);
|
|
47
|
-
reporter.update(30, 40, true);
|
|
48
|
-
reporter.update(30, 40, false);
|
|
49
|
-
let notices: Vec<_> = rx.try_iter().collect();
|
|
50
|
-
assert_eq!(notices.len(), 3);
|
|
51
|
-
assert_eq!((notices[1].x, notices[1].y), (30, 40));
|
|
52
|
-
assert!(!notices[2].visible);
|
|
53
|
-
assert!(notices
|
|
54
|
-
.windows(2)
|
|
55
|
-
.all(|p| p[0].timestamp_us <= p[1].timestamp_us));
|
|
56
|
-
}
|
|
57
|
-
}
|