Skip to main content

ios_core/services/screenshot/
mod.rs

1//! Screenshot service.
2//!
3//! Connects to `com.apple.mobile.screenshotr` and captures a screenshot.
4//!
5//! Protocol: plist-framed (same 4-byte BE length prefix as lockdown).
6//! 1. Send DL message: `{"MessageType":"DLMessageVersionExchange", "SupportedVersions":[1]}`
7//! 2. Recv version exchange response
8//! 3. Send DL ready: {"MessageType":"DLMessageDeviceReady"}
9//! 4. Recv: screenshot plist with "ScreenShotData" key (TIFF/PNG/JPEG bytes)
10//!
11//! Reference: libimobiledevice screenshotr protocol
12
13use bytes::Bytes;
14use serde::Serialize;
15use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
16
17pub const SERVICE_NAME: &str = "com.apple.mobile.screenshotr";
18
19service_error!(ScreenshotError);
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ScreenshotFormat {
24    Png,
25    Jpeg,
26    Tiff,
27    Unknown,
28}
29
30impl ScreenshotFormat {
31    pub fn mime_type(self) -> &'static str {
32        match self {
33            Self::Png => "image/png",
34            Self::Jpeg => "image/jpeg",
35            Self::Tiff => "image/tiff",
36            Self::Unknown => "application/octet-stream",
37        }
38    }
39
40    pub fn detect(bytes: &[u8]) -> Self {
41        if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
42            Self::Png
43        } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
44            Self::Jpeg
45        } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
46            Self::Tiff
47        } else {
48            Self::Unknown
49        }
50    }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ScreenshotImage {
55    pub data: Bytes,
56    pub format: ScreenshotFormat,
57}
58
59impl ScreenshotImage {
60    pub fn from_bytes(data: Bytes) -> Self {
61        let format = ScreenshotFormat::detect(&data);
62        Self { data, format }
63    }
64
65    pub fn mime_type(&self) -> &'static str {
66        self.format.mime_type()
67    }
68
69    pub fn byte_len(&self) -> usize {
70        self.data.len()
71    }
72}
73
74#[derive(Serialize)]
75#[serde(rename_all = "PascalCase")]
76struct VersionExchangeRequest {
77    message_type: &'static str,
78    supported_versions: Vec<u64>,
79}
80
81#[derive(Serialize)]
82#[serde(rename_all = "PascalCase")]
83struct DeviceReadyRequest {
84    message_type: &'static str,
85}
86
87/// Capture a screenshot from the device.
88///
89/// Returns raw image bytes plus detected format metadata.
90pub async fn take_screenshot<S>(stream: &mut S) -> Result<ScreenshotImage, ScreenshotError>
91where
92    S: AsyncRead + AsyncWrite + Unpin,
93{
94    // 1. Send version exchange
95    send_plist(
96        stream,
97        &VersionExchangeRequest {
98            message_type: "DLMessageVersionExchange",
99            supported_versions: vec![1],
100        },
101    )
102    .await?;
103
104    // 2. Recv version exchange response (ignore content)
105    recv_plist_raw(stream).await?;
106
107    // 3. Send device ready
108    send_plist(
109        stream,
110        &DeviceReadyRequest {
111            message_type: "DLMessageDeviceReady",
112        },
113    )
114    .await?;
115
116    // 4. Recv screenshot plist
117    let data = recv_plist_raw(stream).await?;
118
119    // Parse plist to find ScreenShotData
120    let val: plist::Value = plist::from_bytes(&data)?;
121
122    // The plist is an array: [MessageType, {ScreenShotData: <data>}]
123    if let Some(arr) = val.as_array() {
124        for item in arr {
125            if let Some(dict) = item.as_dictionary() {
126                if let Some(img) = dict.get("ScreenShotData") {
127                    if let Some(bytes) = img.as_data() {
128                        return Ok(ScreenshotImage::from_bytes(Bytes::copy_from_slice(bytes)));
129                    }
130                }
131            }
132        }
133    }
134
135    Err(ScreenshotError::Protocol(
136        "ScreenShotData not found in response".into(),
137    ))
138}
139
140// ── plist framing (same as lockdown: 4-byte BE length prefix) ─────────────────
141
142async fn send_plist<S, T>(stream: &mut S, value: &T) -> Result<(), ScreenshotError>
143where
144    S: AsyncWrite + Unpin,
145    T: Serialize,
146{
147    let mut buf = Vec::new();
148    plist::to_writer_xml(&mut buf, value)?;
149    stream.write_all(&(buf.len() as u32).to_be_bytes()).await?;
150    stream.write_all(&buf).await?;
151    stream.flush().await?;
152    Ok(())
153}
154
155async fn recv_plist_raw<S>(stream: &mut S) -> Result<Vec<u8>, ScreenshotError>
156where
157    S: AsyncRead + Unpin,
158{
159    let mut len_buf = [0u8; 4];
160    stream.read_exact(&mut len_buf).await?;
161    let len = u32::from_be_bytes(len_buf) as usize;
162    const MAX_PLIST_SIZE: usize = 4 * 1024 * 1024;
163    if len > MAX_PLIST_SIZE {
164        return Err(ScreenshotError::Protocol(format!(
165            "plist length {len} exceeds maximum of {MAX_PLIST_SIZE}"
166        )));
167    }
168    let mut buf = vec![0u8; len];
169    stream.read_exact(&mut buf).await?;
170    Ok(buf)
171}
172
173#[cfg(test)]
174mod tests {
175    use bytes::Bytes;
176
177    use super::{ScreenshotFormat, ScreenshotImage};
178
179    #[test]
180    fn detects_png_signature() {
181        let format = ScreenshotFormat::detect(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
182        assert_eq!(format, ScreenshotFormat::Png);
183        assert_eq!(format.mime_type(), "image/png");
184    }
185
186    #[test]
187    fn detects_jpeg_signature() {
188        let format = ScreenshotFormat::detect(&[0xFF, 0xD8, 0xFF, 0xE0]);
189        assert_eq!(format, ScreenshotFormat::Jpeg);
190        assert_eq!(format.mime_type(), "image/jpeg");
191    }
192
193    #[test]
194    fn detects_tiff_signatures() {
195        assert_eq!(
196            ScreenshotFormat::detect(b"II*\0rest"),
197            ScreenshotFormat::Tiff
198        );
199        assert_eq!(
200            ScreenshotFormat::detect(b"MM\0*rest"),
201            ScreenshotFormat::Tiff
202        );
203    }
204
205    #[test]
206    fn unknown_signature_falls_back_to_octet_stream() {
207        let image = ScreenshotImage::from_bytes(Bytes::from_static(b"not-an-image"));
208        assert_eq!(image.format, ScreenshotFormat::Unknown);
209        assert_eq!(image.mime_type(), "application/octet-stream");
210        assert_eq!(image.byte_len(), 12);
211    }
212}