1use 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 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}