Skip to main content

smix_simctl/
surface_capture.rs

1//! Persistent CoreSimulator framebuffer capture via a resident per-sim
2//! `smix-capture-host` process in request-response ("serve") mode.
3//!
4//! The per-shot `xcrun simctl io <udid> screenshot` path pays a ~74 ms
5//! process-spawn + dyld floor and a ~66 ms PNG encode every shot, both
6//! measured by decomposing that path stage by stage. This module holds one
7//! resident host per booted UDID that resolves the display `IOSurface` once
8//! (~58 ms, amortized) and then answers each capture request by locking +
9//! copying the framebuffer directly — ~0.3 ms for a raw BGRA frame, ~66 ms
10//! for an in-host ImageIO PNG encode, with **no** per-shot process spawn.
11//!
12//! Correctness: the host **revalidates the surface on every grab** (re-fetches
13//! the device's current `framebufferSurface` and adopts it if the sim rebooted
14//! and vended a new one). If the surface can no longer be resolved (sim shut
15//! down, framework layout changed) the host answers with a
16//! surface-unavailable status and exits, and the caller falls back to the
17//! `xcrun simctl io screenshot` path. A stale/garbage frame is never returned.
18//!
19//! Wire protocol (host `serve` mode):
20//!   - host emits `<W>x<H>\n` on stderr once, then loops.
21//!   - request:  one opcode byte on stdin — [`OP_RAW`](crate::surface_capture::OP_RAW) (raw BGRA) or
22//!     [`OP_PNG`](crate::surface_capture::OP_PNG) (ImageIO PNG). EOF ends the host.
23//!   - response: one status byte — [`STATUS_OK`](crate::surface_capture::STATUS_OK) or
24//!     [`STATUS_UNAVAILABLE`](crate::surface_capture::STATUS_UNAVAILABLE).
25//!     On OK, followed by a 12-byte header `w:u32 h:u32 len:u32` (little
26//!     endian) then `len` payload bytes. On UNAVAILABLE the host exits.
27
28use std::collections::HashMap;
29use std::path::PathBuf;
30use std::process::Stdio;
31use std::time::Duration;
32
33use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
34use tokio::process::{Child, ChildStdin, ChildStdout, Command};
35
36/// Opcode: grab a raw BGRA frame (row padding stripped).
37pub const OP_RAW: u8 = b'R';
38/// Opcode: grab an in-host ImageIO-encoded PNG frame.
39pub const OP_PNG: u8 = b'P';
40/// Response status: an `w/h/len` header + payload follow.
41pub const STATUS_OK: u8 = 0;
42/// Response status: the surface could not be resolved; the host is exiting
43/// and the caller must fall back to `simctl`.
44pub const STATUS_UNAVAILABLE: u8 = 1;
45
46/// A captured frame — either raw BGRA pixels (the fast diff-loop path) or a
47/// PNG (the file-save path, or the `simctl` fallback which is always PNG).
48#[derive(Clone, PartialEq, Eq)]
49pub enum CapturedFrame {
50    /// Raw BGRA8888 pixels, `width * height * 4` bytes, row padding stripped.
51    Bgra {
52        /// Frame width in pixels.
53        width: u32,
54        /// Frame height in pixels.
55        height: u32,
56        /// `width * height * 4` bytes, BGRA order, no row padding.
57        data: Vec<u8>,
58    },
59    /// PNG-encoded frame.
60    Png(Vec<u8>),
61}
62
63impl std::fmt::Debug for CapturedFrame {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            CapturedFrame::Bgra {
67                width,
68                height,
69                data,
70            } => f
71                .debug_struct("CapturedFrame::Bgra")
72                .field("width", width)
73                .field("height", height)
74                .field("bytes", &data.len())
75                .finish(),
76            CapturedFrame::Png(b) => f
77                .debug_struct("CapturedFrame::Png")
78                .field("bytes", &b.len())
79                .finish(),
80        }
81    }
82}
83
84/// Why a resident-host capture could not be produced. Every variant means
85/// "fall back to `simctl`", but they are distinguished for diagnostics.
86#[derive(Debug)]
87pub enum HostError {
88    /// The `smix-capture-host` binary was not found on disk.
89    BinaryMissing(PathBuf),
90    /// The host was spawned but never emitted its `WxH` geometry header
91    /// (surface resolve failed, or it crashed on launch).
92    ResolveFailed(String),
93    /// The host process died mid-conversation (stdout EOF on a grab).
94    HostGone,
95    /// An I/O error talking to the host.
96    Io(std::io::Error),
97    /// The host sent a malformed response header.
98    Protocol(String),
99}
100
101impl std::fmt::Display for HostError {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            HostError::BinaryMissing(p) => write!(f, "smix-capture-host not found at {p:?}"),
105            HostError::ResolveFailed(s) => write!(f, "capture-host surface resolve failed: {s}"),
106            HostError::HostGone => write!(f, "capture-host process gone"),
107            HostError::Io(e) => write!(f, "capture-host io: {e}"),
108            HostError::Protocol(s) => write!(f, "capture-host protocol: {s}"),
109        }
110    }
111}
112
113impl std::error::Error for HostError {}
114
115impl From<std::io::Error> for HostError {
116    fn from(e: std::io::Error) -> Self {
117        HostError::Io(e)
118    }
119}
120
121/// Parsed `w:u32 h:u32 len:u32` little-endian frame header.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct FrameHeader {
124    /// Frame width in pixels.
125    pub width: u32,
126    /// Frame height in pixels.
127    pub height: u32,
128    /// Payload length in bytes.
129    pub len: u32,
130}
131
132impl FrameHeader {
133    /// Parse a 12-byte little-endian `w/h/len` header.
134    pub fn parse(buf: &[u8; 12]) -> FrameHeader {
135        FrameHeader {
136            width: u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
137            height: u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
138            len: u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]),
139        }
140    }
141}
142
143/// Locate the `smix-capture-host` binary the same way the `/live` pipeline
144/// does: honor `SMIX_CAPTURE_HOST_BIN`, else the in-repo release build path.
145pub fn capture_host_bin() -> PathBuf {
146    std::env::var_os("SMIX_CAPTURE_HOST_BIN").map_or_else(
147        || PathBuf::from("swift-bridge/.build/release/smix-capture-host"),
148        PathBuf::from,
149    )
150}
151
152/// A resident `smix-capture-host` in `serve` mode for one booted UDID.
153///
154/// Holds the child + its stdin/stdout. Dropping it kills the child
155/// (`kill_on_drop`), so a dropped [`SimctlClient`](crate::SimctlClient) leaves
156/// no stray capture host behind.
157pub struct SurfaceCaptureHost {
158    child: Child,
159    stdin: ChildStdin,
160    stdout: BufReader<ChildStdout>,
161    /// Geometry from the startup header (informational; each frame carries its
162    /// own `w/h`, which is authoritative across a rotation/reboot).
163    pub width: u32,
164    /// Startup-header height (see [`SurfaceCaptureHost::width`]).
165    pub height: u32,
166}
167
168impl SurfaceCaptureHost {
169    /// Spawn a resident host for `udid` and wait (≤5 s) for its `WxH`
170    /// geometry header, which confirms the surface resolved.
171    pub async fn spawn(udid: &str) -> Result<SurfaceCaptureHost, HostError> {
172        let bin = capture_host_bin();
173        if !bin.exists() {
174            return Err(HostError::BinaryMissing(bin));
175        }
176        let mut child = Command::new(&bin)
177            .arg(udid)
178            .arg("serve")
179            .stdin(Stdio::piped())
180            .stdout(Stdio::piped())
181            .stderr(Stdio::piped())
182            .kill_on_drop(true)
183            .spawn()
184            .map_err(HostError::Io)?;
185
186        let stdin = child
187            .stdin
188            .take()
189            .ok_or_else(|| HostError::ResolveFailed("stdin not piped".into()))?;
190        let stdout = child
191            .stdout
192            .take()
193            .ok_or_else(|| HostError::ResolveFailed("stdout not piped".into()))?;
194        let stderr = child
195            .stderr
196            .take()
197            .ok_or_else(|| HostError::ResolveFailed("stderr not piped".into()))?;
198
199        let mut stderr_reader = BufReader::new(stderr);
200        let mut header = String::new();
201        let read =
202            tokio::time::timeout(Duration::from_secs(5), stderr_reader.read_line(&mut header))
203                .await;
204        let (width, height) = match read {
205            Ok(Ok(n)) if n > 0 => parse_geometry_line(&header)
206                .ok_or_else(|| HostError::ResolveFailed(format!("bad WxH header: {header:?}")))?,
207            Ok(Ok(_)) => {
208                return Err(HostError::ResolveFailed(
209                    "host exited before WxH header".into(),
210                ));
211            }
212            Ok(Err(e)) => return Err(HostError::ResolveFailed(format!("read header: {e}"))),
213            Err(_) => {
214                return Err(HostError::ResolveFailed(
215                    "WxH header not received within 5s".into(),
216                ));
217            }
218        };
219
220        // Drain the rest of stderr in the background so the host never blocks
221        // on a full pipe. Ends naturally when the host's stderr closes.
222        tokio::spawn(async move {
223            let mut lines = stderr_reader.lines();
224            while let Ok(Some(_line)) = lines.next_line().await {}
225        });
226
227        Ok(SurfaceCaptureHost {
228            child,
229            stdin,
230            stdout: BufReader::new(stdout),
231            width,
232            height,
233        })
234    }
235
236    /// Request one frame. `want_png` selects an in-host ImageIO PNG encode;
237    /// otherwise a raw BGRA frame.
238    ///
239    /// Returns `Ok(Some(frame))` on success, `Ok(None)` when the host reports
240    /// the surface is no longer resolvable (caller falls back to `simctl`),
241    /// and `Err` on a transport failure (caller drops this host + falls back).
242    pub async fn grab(&mut self, want_png: bool) -> Result<Option<CapturedFrame>, HostError> {
243        let op = if want_png { OP_PNG } else { OP_RAW };
244        self.stdin.write_all(&[op]).await?;
245        self.stdin.flush().await?;
246
247        let mut status = [0u8; 1];
248        if let Err(e) = self.stdout.read_exact(&mut status).await {
249            return if e.kind() == std::io::ErrorKind::UnexpectedEof {
250                Err(HostError::HostGone)
251            } else {
252                Err(HostError::Io(e))
253            };
254        }
255        match status[0] {
256            STATUS_UNAVAILABLE => Ok(None),
257            STATUS_OK => {
258                let mut hdr = [0u8; 12];
259                self.read_exact_or_gone(&mut hdr).await?;
260                let h = FrameHeader::parse(&hdr);
261                let len = h.len as usize;
262                // Guard against a corrupt length demanding an unbounded read.
263                // A full BGRA frame is w*h*4; a PNG is smaller. 128 MB ceiling
264                // is generous for any realistic device framebuffer.
265                if len > 128 * 1024 * 1024 {
266                    return Err(HostError::Protocol(format!("payload len too large: {len}")));
267                }
268                let mut payload = vec![0u8; len];
269                self.read_exact_or_gone(&mut payload).await?;
270                let frame = if want_png {
271                    CapturedFrame::Png(payload)
272                } else {
273                    CapturedFrame::Bgra {
274                        width: h.width,
275                        height: h.height,
276                        data: payload,
277                    }
278                };
279                Ok(Some(frame))
280            }
281            other => Err(HostError::Protocol(format!("unknown status byte {other}"))),
282        }
283    }
284
285    async fn read_exact_or_gone(&mut self, buf: &mut [u8]) -> Result<(), HostError> {
286        match self.stdout.read_exact(buf).await {
287            Ok(_) => Ok(()),
288            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Err(HostError::HostGone),
289            Err(e) => Err(HostError::Io(e)),
290        }
291    }
292
293    /// Best-effort clean shutdown: close stdin (host sees EOF → exits 0) and
294    /// reap. `kill_on_drop` is the backstop if this is skipped.
295    pub async fn shutdown(mut self) {
296        drop(self.stdin);
297        let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
298    }
299}
300
301/// Parse a `WxH\n` geometry line.
302pub fn parse_geometry_line(s: &str) -> Option<(u32, u32)> {
303    let (w, h) = s.trim().split_once('x')?;
304    Some((w.parse().ok()?, h.parse().ok()?))
305}
306
307/// Per-UDID resident-host registry. Held behind an async mutex inside
308/// [`SimctlClient`](crate::SimctlClient).
309#[derive(Default)]
310pub struct CaptureHostRegistry {
311    hosts: HashMap<String, SurfaceCaptureHost>,
312}
313
314impl std::fmt::Debug for CaptureHostRegistry {
315    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        f.debug_struct("CaptureHostRegistry")
317            .field("resident_hosts", &self.hosts.len())
318            .finish()
319    }
320}
321
322impl CaptureHostRegistry {
323    /// Take (removing) the host for `udid`, if resident.
324    pub fn take(&mut self, udid: &str) -> Option<SurfaceCaptureHost> {
325        self.hosts.remove(udid)
326    }
327
328    /// Re-insert a live host for `udid`.
329    pub fn put(&mut self, udid: &str, host: SurfaceCaptureHost) {
330        self.hosts.insert(udid.to_string(), host);
331    }
332
333    /// Drop the resident host for `udid` (e.g. on a lifecycle change).
334    pub fn evict(&mut self, udid: &str) -> Option<SurfaceCaptureHost> {
335        self.hosts.remove(udid)
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn frame_header_parses_little_endian() {
345        // w=1206 (0x000004B6), h=2622 (0x00000A3E), len=12648528 (0x00C10050)
346        let buf = [
347            0xB6, 0x04, 0x00, 0x00, 0x3E, 0x0A, 0x00, 0x00, 0x50, 0x00, 0xC1, 0x00,
348        ];
349        let h = FrameHeader::parse(&buf);
350        assert_eq!(h.width, 1206);
351        assert_eq!(h.height, 2622);
352        assert_eq!(h.len, 12_648_528);
353    }
354
355    #[test]
356    fn status_constants_are_distinct_and_ops_are_ascii() {
357        assert_ne!(STATUS_OK, STATUS_UNAVAILABLE);
358        assert_eq!(OP_RAW, b'R');
359        assert_eq!(OP_PNG, b'P');
360    }
361
362    #[test]
363    fn geometry_line_parses_and_rejects_junk() {
364        assert_eq!(parse_geometry_line("1206x2622\n"), Some((1206, 2622)));
365        assert_eq!(parse_geometry_line("  800x600  "), Some((800, 600)));
366        assert_eq!(parse_geometry_line("not-a-size"), None);
367        assert_eq!(parse_geometry_line("1206x"), None);
368    }
369
370    #[test]
371    fn registry_take_put_evict_roundtrip_key() {
372        // No live host needed: exercise the key bookkeeping (put/take/evict)
373        // which is the fallback-selection state the async path relies on.
374        let mut reg = CaptureHostRegistry::default();
375        assert!(reg.take("UDID-A").is_none());
376        assert!(reg.evict("UDID-A").is_none());
377        // A resident host cannot be fabricated without a child; assert the
378        // empty-registry contract that drives "spawn on miss".
379        assert!(reg.hosts.is_empty());
380    }
381
382    #[test]
383    fn bin_path_honors_env_override() {
384        // The env var is process-global; set + clear around the assertion.
385        let prev = std::env::var_os("SMIX_CAPTURE_HOST_BIN");
386        // SAFETY: single-threaded test; restored below.
387        unsafe { std::env::set_var("SMIX_CAPTURE_HOST_BIN", "/tmp/custom-host") };
388        assert_eq!(capture_host_bin(), PathBuf::from("/tmp/custom-host"));
389        unsafe {
390            match prev {
391                Some(v) => std::env::set_var("SMIX_CAPTURE_HOST_BIN", v),
392                None => std::env::remove_var("SMIX_CAPTURE_HOST_BIN"),
393            }
394        }
395    }
396}