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