Skip to main content

ios_core/services/springboard/
mod.rs

1//! Minimal SpringBoard services client.
2//!
3//! Current scope: fetch the Home Screen icon layout via
4//! `com.apple.springboardservices` and present it as typed pages/items.
5
6use serde::Serialize;
7use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
8
9pub const SERVICE_NAME: &str = "com.apple.springboardservices";
10
11#[derive(Debug, thiserror::Error)]
12pub enum SpringboardError {
13    #[error("IO error: {0}")]
14    Io(#[from] std::io::Error),
15    #[error("plist error: {0}")]
16    Plist(String),
17    #[error("protocol error: {0}")]
18    Protocol(String),
19    #[error("service error: {0}")]
20    Service(String),
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Icon {
25    App(AppIcon),
26    Folder(Folder),
27    WebClip(WebClip),
28    Custom(CustomIcon),
29}
30
31impl Icon {
32    pub fn display_name(&self) -> &str {
33        match self {
34            Icon::App(app) => &app.display_name,
35            Icon::Folder(folder) => &folder.display_name,
36            Icon::WebClip(web_clip) => &web_clip.display_name,
37            Icon::Custom(_) => "",
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct AppIcon {
44    pub display_name: String,
45    pub display_identifier: Option<String>,
46    pub bundle_identifier: String,
47    pub bundle_version: Option<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Folder {
52    pub display_name: String,
53    pub pages: Vec<Vec<Icon>>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct WebClip {
58    pub display_name: String,
59    pub display_identifier: Option<String>,
60    pub url: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CustomIcon {
65    pub icon_type: Option<String>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum InterfaceOrientation {
70    Portrait,
71    PortraitUpsideDown,
72    Landscape,
73    LandscapeHomeToLeft,
74    Unknown(u64),
75}
76
77impl InterfaceOrientation {
78    pub fn from_raw(value: u64) -> Self {
79        match value {
80            1 => Self::Portrait,
81            2 => Self::PortraitUpsideDown,
82            3 => Self::Landscape,
83            4 => Self::LandscapeHomeToLeft,
84            other => Self::Unknown(other),
85        }
86    }
87
88    pub fn label(&self) -> &'static str {
89        match self {
90            Self::Portrait => "portrait",
91            Self::PortraitUpsideDown => "portrait_upside_down",
92            Self::Landscape => "landscape",
93            Self::LandscapeHomeToLeft => "landscape_home_to_left",
94            Self::Unknown(_) => "unknown",
95        }
96    }
97
98    pub fn raw_value(&self) -> u64 {
99        match self {
100            Self::Portrait => 1,
101            Self::PortraitUpsideDown => 2,
102            Self::Landscape => 3,
103            Self::LandscapeHomeToLeft => 4,
104            Self::Unknown(value) => *value,
105        }
106    }
107}
108
109#[derive(Debug)]
110pub struct SpringboardClient<S> {
111    stream: S,
112}
113
114impl<S: AsyncRead + AsyncWrite + Unpin> SpringboardClient<S> {
115    pub fn new(stream: S) -> Self {
116        Self { stream }
117    }
118
119    pub async fn list_icons(&mut self) -> Result<Vec<Vec<Icon>>, SpringboardError> {
120        let response = self.get_icon_state_raw("2").await?;
121        parse_screens(response)
122    }
123
124    pub async fn get_icon_state_raw(
125        &mut self,
126        format_version: &str,
127    ) -> Result<plist::Value, SpringboardError> {
128        self.send_plist(&GetIconStateRequest {
129            command: "getIconState",
130            format_version,
131        })
132        .await?;
133
134        self.recv_plist().await
135    }
136
137    pub async fn get_icon_png_data(
138        &mut self,
139        bundle_id: &str,
140    ) -> Result<Vec<u8>, SpringboardError> {
141        self.send_plist(&GetIconPngDataRequest {
142            command: "getIconPNGData",
143            bundle_id,
144        })
145        .await?;
146
147        let response: plist::Value = self.recv_plist().await?;
148        parse_png_data(response)
149    }
150
151    pub async fn get_interface_orientation(
152        &mut self,
153    ) -> Result<InterfaceOrientation, SpringboardError> {
154        self.send_plist(&CommandRequest {
155            command: "getInterfaceOrientation",
156        })
157        .await?;
158
159        let response: plist::Value = self.recv_plist().await?;
160        parse_interface_orientation(response)
161    }
162
163    pub async fn get_homescreen_icon_metrics(&mut self) -> Result<plist::Value, SpringboardError> {
164        self.send_plist(&CommandRequest {
165            command: "getHomeScreenIconMetrics",
166        })
167        .await?;
168
169        let response: plist::Value = self.recv_plist().await?;
170        parse_metrics(response)
171    }
172
173    pub async fn get_wallpaper_info(
174        &mut self,
175        wallpaper_name: &str,
176    ) -> Result<plist::Value, SpringboardError> {
177        self.send_plist(&WallpaperCommandRequest {
178            command: "getWallpaperInfo",
179            wallpaper_name,
180        })
181        .await?;
182
183        let response: plist::Value = self.recv_plist().await?;
184        parse_metrics(response)
185    }
186
187    pub async fn get_wallpaper_preview_image(
188        &mut self,
189        wallpaper_name: &str,
190    ) -> Result<Vec<u8>, SpringboardError> {
191        self.send_plist(&WallpaperCommandRequest {
192            command: "getWallpaperPreviewImage",
193            wallpaper_name,
194        })
195        .await?;
196
197        let response: plist::Value = self.recv_plist().await?;
198        parse_png_data(response)
199    }
200
201    pub async fn set_icon_state(
202        &mut self,
203        icon_state: &plist::Value,
204    ) -> Result<(), SpringboardError> {
205        self.send_plist(&SetIconStateRequest {
206            command: "setIconState",
207            icon_state,
208        })
209        .await?;
210
211        // setIconState may not send a response; tolerate EOF
212        match self.recv_plist::<plist::Value>().await {
213            Ok(_) => Ok(()),
214            Err(SpringboardError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(()),
215            Err(e) => Err(e),
216        }
217    }
218
219    pub async fn get_homescreen_wallpaper_pngdata(&mut self) -> Result<Vec<u8>, SpringboardError> {
220        self.send_plist(&CommandRequest {
221            command: "getHomeScreenWallpaperPNGData",
222        })
223        .await?;
224
225        let response: plist::Value = self.recv_plist().await?;
226        parse_png_data(response)
227    }
228
229    async fn send_plist<T: Serialize>(&mut self, value: &T) -> Result<(), SpringboardError> {
230        let mut buf = Vec::new();
231        plist::to_writer_xml(&mut buf, value)
232            .map_err(|e| SpringboardError::Plist(e.to_string()))?;
233        let len = buf.len() as u32;
234        self.stream.write_all(&len.to_be_bytes()).await?;
235        self.stream.write_all(&buf).await?;
236        self.stream.flush().await?;
237        Ok(())
238    }
239
240    async fn recv_plist<T>(&mut self) -> Result<T, SpringboardError>
241    where
242        T: for<'de> serde::Deserialize<'de>,
243    {
244        let mut len_buf = [0u8; 4];
245        self.stream.read_exact(&mut len_buf).await?;
246        let len = u32::from_be_bytes(len_buf) as usize;
247        const MAX_PLIST_SIZE: usize = 16 * 1024 * 1024;
248        if len > MAX_PLIST_SIZE {
249            return Err(SpringboardError::Protocol(format!(
250                "plist length {len} exceeds max {MAX_PLIST_SIZE}"
251            )));
252        }
253        let mut buf = vec![0u8; len];
254        self.stream.read_exact(&mut buf).await?;
255        plist::from_bytes(&buf).map_err(|e| SpringboardError::Plist(e.to_string()))
256    }
257}
258
259#[derive(Serialize)]
260#[serde(rename_all = "camelCase")]
261struct GetIconStateRequest<'a> {
262    command: &'static str,
263    format_version: &'a str,
264}
265
266#[derive(Serialize)]
267#[serde(rename_all = "camelCase")]
268struct GetIconPngDataRequest<'a> {
269    command: &'static str,
270    bundle_id: &'a str,
271}
272
273#[derive(Serialize)]
274#[serde(rename_all = "camelCase")]
275struct WallpaperCommandRequest<'a> {
276    command: &'static str,
277    wallpaper_name: &'a str,
278}
279
280#[derive(Serialize)]
281struct CommandRequest {
282    command: &'static str,
283}
284
285#[derive(Serialize)]
286#[serde(rename_all = "camelCase")]
287struct SetIconStateRequest<'a> {
288    command: &'static str,
289    icon_state: &'a plist::Value,
290}
291
292fn parse_screens(value: plist::Value) -> Result<Vec<Vec<Icon>>, SpringboardError> {
293    let screens = value.into_array().ok_or_else(|| {
294        SpringboardError::Protocol("springboard response was not an array".into())
295    })?;
296
297    screens
298        .into_iter()
299        .map(|screen| {
300            let icons = screen.into_array().ok_or_else(|| {
301                SpringboardError::Protocol("screen entry was not an array".into())
302            })?;
303            icons.into_iter().map(parse_icon).collect()
304        })
305        .collect()
306}
307
308fn parse_png_data(value: plist::Value) -> Result<Vec<u8>, SpringboardError> {
309    let dict = value.into_dictionary().ok_or_else(|| {
310        SpringboardError::Protocol("springboard icon response was not a dictionary".into())
311    })?;
312
313    if let Some(error) = string_field(&dict, "Error") {
314        return Err(SpringboardError::Service(error));
315    }
316
317    dict.get("pngData")
318        .and_then(plist::Value::as_data)
319        .map(|data| data.to_vec())
320        .ok_or_else(|| SpringboardError::Protocol("springboard response missing pngData".into()))
321}
322
323fn parse_interface_orientation(
324    value: plist::Value,
325) -> Result<InterfaceOrientation, SpringboardError> {
326    let dict = value.into_dictionary().ok_or_else(|| {
327        SpringboardError::Protocol("springboard orientation response was not a dictionary".into())
328    })?;
329
330    let raw = dict
331        .get("interfaceOrientation")
332        .and_then(plist_integer_to_u64)
333        .ok_or_else(|| {
334            SpringboardError::Protocol("springboard response missing interfaceOrientation".into())
335        })?;
336    Ok(InterfaceOrientation::from_raw(raw))
337}
338
339fn parse_metrics(value: plist::Value) -> Result<plist::Value, SpringboardError> {
340    let dict = value.into_dictionary().ok_or_else(|| {
341        SpringboardError::Protocol("springboard metrics response was not a dictionary".into())
342    })?;
343    Ok(plist::Value::Dictionary(dict))
344}
345
346fn parse_icon(value: plist::Value) -> Result<Icon, SpringboardError> {
347    let dict = value
348        .into_dictionary()
349        .ok_or_else(|| SpringboardError::Protocol("icon entry was not a dictionary".into()))?;
350
351    if let Some(bundle_identifier) = string_field(&dict, "bundleIdentifier") {
352        return Ok(Icon::App(AppIcon {
353            display_name: string_field(&dict, "displayName").unwrap_or_default(),
354            display_identifier: string_field(&dict, "displayIdentifier"),
355            bundle_identifier,
356            bundle_version: string_field(&dict, "bundleVersion"),
357        }));
358    }
359
360    if let Some(url) = string_field(&dict, "webClipURL") {
361        return Ok(Icon::WebClip(WebClip {
362            display_name: string_field(&dict, "displayName").unwrap_or_default(),
363            display_identifier: string_field(&dict, "displayIdentifier"),
364            url,
365        }));
366    }
367
368    if string_field(&dict, "listType").as_deref() == Some("folder") {
369        let pages = dict
370            .get("iconLists")
371            .and_then(plist::Value::as_array)
372            .ok_or_else(|| SpringboardError::Protocol("folder iconLists missing".into()))?;
373        let pages = pages
374            .iter()
375            .map(|page| {
376                let page_icons = page.as_array().ok_or_else(|| {
377                    SpringboardError::Protocol("folder page was not an array".into())
378                })?;
379                page_icons.iter().cloned().map(parse_icon).collect()
380            })
381            .collect::<Result<Vec<Vec<Icon>>, SpringboardError>>()?;
382        return Ok(Icon::Folder(Folder {
383            display_name: string_field(&dict, "displayName").unwrap_or_default(),
384            pages,
385        }));
386    }
387
388    if string_field(&dict, "iconType").as_deref() == Some("custom") {
389        return Ok(Icon::Custom(CustomIcon {
390            icon_type: string_field(&dict, "iconType"),
391        }));
392    }
393
394    Err(SpringboardError::Protocol(
395        "unrecognized springboard icon entry".into(),
396    ))
397}
398
399fn string_field(dict: &plist::Dictionary, key: &str) -> Option<String> {
400    dict.get(key)
401        .and_then(plist::Value::as_string)
402        .map(ToOwned::to_owned)
403}
404
405fn plist_integer_to_u64(value: &plist::Value) -> Option<u64> {
406    match value {
407        plist::Value::Integer(value) => value
408            .as_unsigned()
409            .or_else(|| value.as_signed().and_then(|signed| signed.try_into().ok())),
410        _ => None,
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    async fn read_plist_frame<S>(stream: &mut S) -> Vec<u8>
419    where
420        S: AsyncRead + Unpin,
421    {
422        let mut len_buf = [0u8; 4];
423        stream.read_exact(&mut len_buf).await.unwrap();
424        let len = u32::from_be_bytes(len_buf) as usize;
425        let mut buf = vec![0u8; len];
426        stream.read_exact(&mut buf).await.unwrap();
427        buf
428    }
429
430    #[test]
431    fn test_service_name_matches_go_ios() {
432        assert_eq!(SERVICE_NAME, "com.apple.springboardservices");
433    }
434
435    #[tokio::test]
436    async fn list_icons_roundtrips_app_folder_and_custom_items() {
437        let (client_side, mut server_side) = tokio::io::duplex(8192);
438
439        tokio::spawn(async move {
440            let request = read_plist_frame(&mut server_side).await;
441            let req_value: plist::Value = plist::from_bytes(&request).unwrap();
442            let dict = req_value.into_dictionary().unwrap();
443            assert_eq!(
444                dict.get("command").and_then(|v| v.as_string()),
445                Some("getIconState")
446            );
447            assert_eq!(
448                dict.get("formatVersion").and_then(|v| v.as_string()),
449                Some("2")
450            );
451
452            let response = plist::Value::Array(vec![plist::Value::Array(vec![
453                plist::Value::Dictionary(plist::Dictionary::from_iter([
454                    (
455                        "displayName".to_string(),
456                        plist::Value::String("Phone".into()),
457                    ),
458                    (
459                        "displayIdentifier".to_string(),
460                        plist::Value::String("com.apple.mobilephone".into()),
461                    ),
462                    (
463                        "bundleIdentifier".to_string(),
464                        plist::Value::String("com.apple.mobilephone".into()),
465                    ),
466                ])),
467                plist::Value::Dictionary(plist::Dictionary::from_iter([
468                    (
469                        "displayName".to_string(),
470                        plist::Value::String("Utilities".into()),
471                    ),
472                    (
473                        "listType".to_string(),
474                        plist::Value::String("folder".into()),
475                    ),
476                    (
477                        "iconLists".to_string(),
478                        plist::Value::Array(vec![plist::Value::Array(vec![
479                            plist::Value::Dictionary(plist::Dictionary::from_iter([
480                                (
481                                    "displayName".to_string(),
482                                    plist::Value::String("Calculator".into()),
483                                ),
484                                (
485                                    "bundleIdentifier".to_string(),
486                                    plist::Value::String("com.apple.calculator".into()),
487                                ),
488                            ])),
489                        ])]),
490                    ),
491                ])),
492                plist::Value::Dictionary(plist::Dictionary::from_iter([(
493                    "iconType".to_string(),
494                    plist::Value::String("custom".into()),
495                )])),
496            ])]);
497
498            let mut buf = Vec::new();
499            plist::to_writer_xml(&mut buf, &response).unwrap();
500            let len = buf.len() as u32;
501            server_side.write_all(&len.to_be_bytes()).await.unwrap();
502            server_side.write_all(&buf).await.unwrap();
503        });
504
505        let mut client = SpringboardClient::new(client_side);
506        let screens = client.list_icons().await.unwrap();
507        assert_eq!(screens.len(), 1);
508        assert_eq!(screens[0].len(), 3);
509        match &screens[0][0] {
510            Icon::App(app) => {
511                assert_eq!(app.display_name, "Phone");
512                assert_eq!(app.bundle_identifier, "com.apple.mobilephone");
513            }
514            other => panic!("unexpected first icon: {other:?}"),
515        }
516        match &screens[0][1] {
517            Icon::Folder(folder) => {
518                assert_eq!(folder.display_name, "Utilities");
519                assert_eq!(folder.pages.len(), 1);
520                assert_eq!(folder.pages[0].len(), 1);
521            }
522            other => panic!("unexpected second icon: {other:?}"),
523        }
524        match &screens[0][2] {
525            Icon::Custom(custom) => assert_eq!(custom.icon_type.as_deref(), Some("custom")),
526            other => panic!("unexpected third icon: {other:?}"),
527        }
528    }
529
530    #[tokio::test]
531    async fn get_icon_png_data_roundtrips_png_bytes() {
532        let (client_side, mut server_side) = tokio::io::duplex(8192);
533
534        tokio::spawn(async move {
535            let request = read_plist_frame(&mut server_side).await;
536            let req_value: plist::Value = plist::from_bytes(&request).unwrap();
537            let dict = req_value.into_dictionary().unwrap();
538            assert_eq!(
539                dict.get("command").and_then(|v| v.as_string()),
540                Some("getIconPNGData")
541            );
542            assert_eq!(
543                dict.get("bundleId").and_then(|v| v.as_string()),
544                Some("com.apple.Preferences")
545            );
546
547            let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
548                "pngData".to_string(),
549                plist::Value::Data(vec![0x89, b'P', b'N', b'G']),
550            )]));
551
552            let mut buf = Vec::new();
553            plist::to_writer_xml(&mut buf, &response).unwrap();
554            let len = buf.len() as u32;
555            server_side.write_all(&len.to_be_bytes()).await.unwrap();
556            server_side.write_all(&buf).await.unwrap();
557        });
558
559        let mut client = SpringboardClient::new(client_side);
560        let png = client
561            .get_icon_png_data("com.apple.Preferences")
562            .await
563            .unwrap();
564        assert_eq!(png, vec![0x89, b'P', b'N', b'G']);
565    }
566
567    #[tokio::test]
568    async fn get_interface_orientation_roundtrips_orientation_value() {
569        let (client_side, mut server_side) = tokio::io::duplex(8192);
570
571        tokio::spawn(async move {
572            let request = read_plist_frame(&mut server_side).await;
573            let req_value: plist::Value = plist::from_bytes(&request).unwrap();
574            let dict = req_value.into_dictionary().unwrap();
575            assert_eq!(
576                dict.get("command").and_then(|v| v.as_string()),
577                Some("getInterfaceOrientation")
578            );
579
580            let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
581                "interfaceOrientation".to_string(),
582                plist::Value::Integer(3.into()),
583            )]));
584
585            let mut buf = Vec::new();
586            plist::to_writer_xml(&mut buf, &response).unwrap();
587            let len = buf.len() as u32;
588            server_side.write_all(&len.to_be_bytes()).await.unwrap();
589            server_side.write_all(&buf).await.unwrap();
590        });
591
592        let mut client = SpringboardClient::new(client_side);
593        let orientation = client.get_interface_orientation().await.unwrap();
594        assert_eq!(orientation, InterfaceOrientation::Landscape);
595    }
596
597    #[tokio::test]
598    async fn get_homescreen_icon_metrics_roundtrips_metric_dictionary() {
599        let (client_side, mut server_side) = tokio::io::duplex(8192);
600
601        tokio::spawn(async move {
602            let request = read_plist_frame(&mut server_side).await;
603            let req_value: plist::Value = plist::from_bytes(&request).unwrap();
604            let dict = req_value.into_dictionary().unwrap();
605            assert_eq!(
606                dict.get("command").and_then(|v| v.as_string()),
607                Some("getHomeScreenIconMetrics")
608            );
609
610            let response = plist::Value::Dictionary(plist::Dictionary::from_iter([
611                ("iconWidth".to_string(), plist::Value::Real(60.0)),
612                ("iconHeight".to_string(), plist::Value::Integer(60.into())),
613            ]));
614
615            let mut buf = Vec::new();
616            plist::to_writer_xml(&mut buf, &response).unwrap();
617            let len = buf.len() as u32;
618            server_side.write_all(&len.to_be_bytes()).await.unwrap();
619            server_side.write_all(&buf).await.unwrap();
620        });
621
622        let mut client = SpringboardClient::new(client_side);
623        let metrics = client.get_homescreen_icon_metrics().await.unwrap();
624        let dict = metrics.into_dictionary().unwrap();
625        assert_eq!(dict["iconWidth"].as_real(), Some(60.0));
626        assert_eq!(dict["iconHeight"].as_signed_integer(), Some(60));
627    }
628
629    #[tokio::test]
630    async fn get_wallpaper_info_roundtrips_dictionary() {
631        let (client_side, mut server_side) = tokio::io::duplex(8192);
632
633        tokio::spawn(async move {
634            let request = read_plist_frame(&mut server_side).await;
635            let req_value: plist::Value = plist::from_bytes(&request).unwrap();
636            let dict = req_value.into_dictionary().unwrap();
637            assert_eq!(
638                dict.get("command").and_then(|v| v.as_string()),
639                Some("getWallpaperInfo")
640            );
641            assert_eq!(
642                dict.get("wallpaperName").and_then(|v| v.as_string()),
643                Some("homescreen")
644            );
645
646            let response = plist::Value::Dictionary(plist::Dictionary::from_iter([
647                (
648                    "wallpaperName".to_string(),
649                    plist::Value::String("homescreen".into()),
650                ),
651                ("isDark".to_string(), plist::Value::Boolean(false)),
652                (
653                    "variation".to_string(),
654                    plist::Value::String("default".into()),
655                ),
656            ]));
657
658            let mut buf = Vec::new();
659            plist::to_writer_xml(&mut buf, &response).unwrap();
660            let len = buf.len() as u32;
661            server_side.write_all(&len.to_be_bytes()).await.unwrap();
662            server_side.write_all(&buf).await.unwrap();
663        });
664
665        let mut client = SpringboardClient::new(client_side);
666        let info = client.get_wallpaper_info("homescreen").await.unwrap();
667        let dict = info.into_dictionary().unwrap();
668        assert_eq!(dict["wallpaperName"].as_string(), Some("homescreen"));
669        assert_eq!(dict["isDark"].as_boolean(), Some(false));
670        assert_eq!(dict["variation"].as_string(), Some("default"));
671    }
672
673    #[tokio::test]
674    async fn get_wallpaper_preview_image_roundtrips_png_bytes() {
675        let (client_side, mut server_side) = tokio::io::duplex(8192);
676
677        tokio::spawn(async move {
678            let request = read_plist_frame(&mut server_side).await;
679            let req_value: plist::Value = plist::from_bytes(&request).unwrap();
680            let dict = req_value.into_dictionary().unwrap();
681            assert_eq!(
682                dict.get("command").and_then(|v| v.as_string()),
683                Some("getWallpaperPreviewImage")
684            );
685            assert_eq!(
686                dict.get("wallpaperName").and_then(|v| v.as_string()),
687                Some("lockscreen")
688            );
689
690            let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
691                "pngData".to_string(),
692                plist::Value::Data(vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a]),
693            )]));
694
695            let mut buf = Vec::new();
696            plist::to_writer_xml(&mut buf, &response).unwrap();
697            let len = buf.len() as u32;
698            server_side.write_all(&len.to_be_bytes()).await.unwrap();
699            server_side.write_all(&buf).await.unwrap();
700        });
701
702        let mut client = SpringboardClient::new(client_side);
703        let png = client
704            .get_wallpaper_preview_image("lockscreen")
705            .await
706            .unwrap();
707        assert_eq!(png, vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a]);
708    }
709
710    #[tokio::test]
711    async fn get_icon_state_raw_roundtrips_unparsed_state() {
712        let (client_side, mut server_side) = tokio::io::duplex(8192);
713
714        tokio::spawn(async move {
715            let request = read_plist_frame(&mut server_side).await;
716            let req_value: plist::Value = plist::from_bytes(&request).unwrap();
717            let dict = req_value.into_dictionary().unwrap();
718            assert_eq!(
719                dict.get("command").and_then(|v| v.as_string()),
720                Some("getIconState")
721            );
722            assert_eq!(
723                dict.get("formatVersion").and_then(|v| v.as_string()),
724                Some("2")
725            );
726
727            let response = plist::Value::Array(vec![plist::Value::Dictionary(
728                plist::Dictionary::from_iter([
729                    (
730                        "bundleIdentifier".to_string(),
731                        plist::Value::String("com.apple.Preferences".into()),
732                    ),
733                    (
734                        "unknownField".to_string(),
735                        plist::Value::String("preserved".into()),
736                    ),
737                ]),
738            )]);
739
740            let mut buf = Vec::new();
741            plist::to_writer_xml(&mut buf, &response).unwrap();
742            let len = buf.len() as u32;
743            server_side.write_all(&len.to_be_bytes()).await.unwrap();
744            server_side.write_all(&buf).await.unwrap();
745        });
746
747        let mut client = SpringboardClient::new(client_side);
748        let state = client.get_icon_state_raw("2").await.unwrap();
749        let entries = state.as_array().unwrap();
750        let dict = entries[0].as_dictionary().unwrap();
751        assert_eq!(
752            dict["bundleIdentifier"].as_string(),
753            Some("com.apple.Preferences")
754        );
755        assert_eq!(dict["unknownField"].as_string(), Some("preserved"));
756    }
757
758    #[test]
759    fn parse_png_data_surfaces_service_error() {
760        let value = plist::Value::Dictionary(plist::Dictionary::from_iter([(
761            "Error".to_string(),
762            plist::Value::String("No such bundle".into()),
763        )]));
764
765        let err = parse_png_data(value).unwrap_err();
766        assert!(matches!(err, SpringboardError::Service(message) if message == "No such bundle"));
767    }
768}