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