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 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#[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 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 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}