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