1use std::collections::HashMap;
28use std::path::PathBuf;
29use std::process::Stdio;
30use std::time::Duration;
31
32use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
33use tokio::process::{Child, ChildStdin, ChildStdout, Command};
34
35pub const OP_RAW: u8 = b'R';
37pub const OP_PNG: u8 = b'P';
39pub const STATUS_OK: u8 = 0;
41pub const STATUS_UNAVAILABLE: u8 = 1;
44
45#[derive(Clone, PartialEq, Eq)]
48pub enum CapturedFrame {
49 Bgra {
51 width: u32,
53 height: u32,
55 data: Vec<u8>,
57 },
58 Png(Vec<u8>),
60}
61
62impl std::fmt::Debug for CapturedFrame {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 CapturedFrame::Bgra {
66 width,
67 height,
68 data,
69 } => f
70 .debug_struct("CapturedFrame::Bgra")
71 .field("width", width)
72 .field("height", height)
73 .field("bytes", &data.len())
74 .finish(),
75 CapturedFrame::Png(b) => f
76 .debug_struct("CapturedFrame::Png")
77 .field("bytes", &b.len())
78 .finish(),
79 }
80 }
81}
82
83#[derive(Debug)]
86pub enum HostError {
87 BinaryMissing(PathBuf),
89 ResolveFailed(String),
92 HostGone,
94 Io(std::io::Error),
96 Protocol(String),
98}
99
100impl std::fmt::Display for HostError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 HostError::BinaryMissing(p) => write!(f, "smix-capture-host not found at {p:?}"),
104 HostError::ResolveFailed(s) => write!(f, "capture-host surface resolve failed: {s}"),
105 HostError::HostGone => write!(f, "capture-host process gone"),
106 HostError::Io(e) => write!(f, "capture-host io: {e}"),
107 HostError::Protocol(s) => write!(f, "capture-host protocol: {s}"),
108 }
109 }
110}
111
112impl std::error::Error for HostError {}
113
114impl From<std::io::Error> for HostError {
115 fn from(e: std::io::Error) -> Self {
116 HostError::Io(e)
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct FrameHeader {
123 pub width: u32,
125 pub height: u32,
127 pub len: u32,
129}
130
131impl FrameHeader {
132 pub fn parse(buf: &[u8; 12]) -> FrameHeader {
134 FrameHeader {
135 width: u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
136 height: u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
137 len: u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]),
138 }
139 }
140}
141
142pub fn capture_host_bin() -> PathBuf {
145 std::env::var_os("SMIX_CAPTURE_HOST_BIN").map_or_else(
146 || PathBuf::from("swift-bridge/.build/release/smix-capture-host"),
147 PathBuf::from,
148 )
149}
150
151pub struct SurfaceCaptureHost {
157 child: Child,
158 stdin: ChildStdin,
159 stdout: BufReader<ChildStdout>,
160 pub width: u32,
163 pub height: u32,
165}
166
167impl SurfaceCaptureHost {
168 pub async fn spawn(udid: &str) -> Result<SurfaceCaptureHost, HostError> {
171 let bin = capture_host_bin();
172 if !bin.exists() {
173 return Err(HostError::BinaryMissing(bin));
174 }
175 let mut child = Command::new(&bin)
176 .arg(udid)
177 .arg("serve")
178 .stdin(Stdio::piped())
179 .stdout(Stdio::piped())
180 .stderr(Stdio::piped())
181 .kill_on_drop(true)
182 .spawn()
183 .map_err(HostError::Io)?;
184
185 let stdin = child
186 .stdin
187 .take()
188 .ok_or_else(|| HostError::ResolveFailed("stdin not piped".into()))?;
189 let stdout = child
190 .stdout
191 .take()
192 .ok_or_else(|| HostError::ResolveFailed("stdout not piped".into()))?;
193 let stderr = child
194 .stderr
195 .take()
196 .ok_or_else(|| HostError::ResolveFailed("stderr not piped".into()))?;
197
198 let mut stderr_reader = BufReader::new(stderr);
199 let mut header = String::new();
200 let read =
201 tokio::time::timeout(Duration::from_secs(5), stderr_reader.read_line(&mut header))
202 .await;
203 let (width, height) = match read {
204 Ok(Ok(n)) if n > 0 => parse_geometry_line(&header)
205 .ok_or_else(|| HostError::ResolveFailed(format!("bad WxH header: {header:?}")))?,
206 Ok(Ok(_)) => {
207 return Err(HostError::ResolveFailed(
208 "host exited before WxH header".into(),
209 ));
210 }
211 Ok(Err(e)) => return Err(HostError::ResolveFailed(format!("read header: {e}"))),
212 Err(_) => {
213 return Err(HostError::ResolveFailed(
214 "WxH header not received within 5s".into(),
215 ));
216 }
217 };
218
219 tokio::spawn(async move {
222 let mut lines = stderr_reader.lines();
223 while let Ok(Some(_line)) = lines.next_line().await {}
224 });
225
226 Ok(SurfaceCaptureHost {
227 child,
228 stdin,
229 stdout: BufReader::new(stdout),
230 width,
231 height,
232 })
233 }
234
235 pub async fn grab(&mut self, want_png: bool) -> Result<Option<CapturedFrame>, HostError> {
242 let op = if want_png { OP_PNG } else { OP_RAW };
243 self.stdin.write_all(&[op]).await?;
244 self.stdin.flush().await?;
245
246 let mut status = [0u8; 1];
247 if let Err(e) = self.stdout.read_exact(&mut status).await {
248 return if e.kind() == std::io::ErrorKind::UnexpectedEof {
249 Err(HostError::HostGone)
250 } else {
251 Err(HostError::Io(e))
252 };
253 }
254 match status[0] {
255 STATUS_UNAVAILABLE => Ok(None),
256 STATUS_OK => {
257 let mut hdr = [0u8; 12];
258 self.read_exact_or_gone(&mut hdr).await?;
259 let h = FrameHeader::parse(&hdr);
260 let len = h.len as usize;
261 if len > 128 * 1024 * 1024 {
265 return Err(HostError::Protocol(format!("payload len too large: {len}")));
266 }
267 let mut payload = vec![0u8; len];
268 self.read_exact_or_gone(&mut payload).await?;
269 let frame = if want_png {
270 CapturedFrame::Png(payload)
271 } else {
272 CapturedFrame::Bgra {
273 width: h.width,
274 height: h.height,
275 data: payload,
276 }
277 };
278 Ok(Some(frame))
279 }
280 other => Err(HostError::Protocol(format!("unknown status byte {other}"))),
281 }
282 }
283
284 async fn read_exact_or_gone(&mut self, buf: &mut [u8]) -> Result<(), HostError> {
285 match self.stdout.read_exact(buf).await {
286 Ok(_) => Ok(()),
287 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Err(HostError::HostGone),
288 Err(e) => Err(HostError::Io(e)),
289 }
290 }
291
292 pub async fn shutdown(mut self) {
295 drop(self.stdin);
296 let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
297 }
298}
299
300pub fn parse_geometry_line(s: &str) -> Option<(u32, u32)> {
302 let (w, h) = s.trim().split_once('x')?;
303 Some((w.parse().ok()?, h.parse().ok()?))
304}
305
306#[derive(Default)]
309pub struct CaptureHostRegistry {
310 hosts: HashMap<String, SurfaceCaptureHost>,
311}
312
313impl std::fmt::Debug for CaptureHostRegistry {
314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 f.debug_struct("CaptureHostRegistry")
316 .field("resident_hosts", &self.hosts.len())
317 .finish()
318 }
319}
320
321impl CaptureHostRegistry {
322 pub fn take(&mut self, udid: &str) -> Option<SurfaceCaptureHost> {
324 self.hosts.remove(udid)
325 }
326
327 pub fn put(&mut self, udid: &str, host: SurfaceCaptureHost) {
329 self.hosts.insert(udid.to_string(), host);
330 }
331
332 pub fn evict(&mut self, udid: &str) -> Option<SurfaceCaptureHost> {
334 self.hosts.remove(udid)
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn frame_header_parses_little_endian() {
344 let buf = [
346 0xB6, 0x04, 0x00, 0x00, 0x3E, 0x0A, 0x00, 0x00, 0x50, 0x00, 0xC1, 0x00,
347 ];
348 let h = FrameHeader::parse(&buf);
349 assert_eq!(h.width, 1206);
350 assert_eq!(h.height, 2622);
351 assert_eq!(h.len, 12_648_528);
352 }
353
354 #[test]
355 fn status_constants_are_distinct_and_ops_are_ascii() {
356 assert_ne!(STATUS_OK, STATUS_UNAVAILABLE);
357 assert_eq!(OP_RAW, b'R');
358 assert_eq!(OP_PNG, b'P');
359 }
360
361 #[test]
362 fn geometry_line_parses_and_rejects_junk() {
363 assert_eq!(parse_geometry_line("1206x2622\n"), Some((1206, 2622)));
364 assert_eq!(parse_geometry_line(" 800x600 "), Some((800, 600)));
365 assert_eq!(parse_geometry_line("not-a-size"), None);
366 assert_eq!(parse_geometry_line("1206x"), None);
367 }
368
369 #[test]
370 fn registry_take_put_evict_roundtrip_key() {
371 let mut reg = CaptureHostRegistry::default();
374 assert!(reg.take("UDID-A").is_none());
375 assert!(reg.evict("UDID-A").is_none());
376 assert!(reg.hosts.is_empty());
379 }
380
381 #[test]
382 fn bin_path_honors_env_override() {
383 let prev = std::env::var_os("SMIX_CAPTURE_HOST_BIN");
385 unsafe { std::env::set_var("SMIX_CAPTURE_HOST_BIN", "/tmp/custom-host") };
387 assert_eq!(capture_host_bin(), PathBuf::from("/tmp/custom-host"));
388 unsafe {
389 match prev {
390 Some(v) => std::env::set_var("SMIX_CAPTURE_HOST_BIN", v),
391 None => std::env::remove_var("SMIX_CAPTURE_HOST_BIN"),
392 }
393 }
394 }
395}