1use serde::{Deserialize, Serialize};
9use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
10
11pub const SERVICE_NAME: &str = "com.apple.mobile.diagnostics_relay";
12
13service_error!(
14 DiagnosticsError,
15 between {
16 #[error("mobilegestalt deprecated: {0}")]
17 Deprecated(String),
18 },
19 after {},
20);
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "PascalCase")]
24pub struct BatteryDiagnostics {
25 #[serde(default)]
26 pub instant_amperage: Option<i64>,
27 #[serde(default)]
28 pub temperature: Option<i64>,
29 #[serde(default)]
30 pub voltage: Option<i64>,
31 #[serde(default)]
32 pub is_charging: Option<bool>,
33 #[serde(default)]
34 pub current_capacity: Option<i64>,
35 #[serde(default)]
36 pub design_capacity: Option<u64>,
37 #[serde(default)]
38 pub nominal_charge_capacity: Option<u64>,
39 #[serde(default)]
40 pub absolute_capacity: Option<u64>,
41 #[serde(default)]
42 pub apple_raw_current_capacity: Option<u64>,
43 #[serde(default)]
44 pub apple_raw_max_capacity: Option<u64>,
45 #[serde(default)]
46 pub cycle_count: Option<u64>,
47 #[serde(default)]
48 pub at_critical_level: Option<bool>,
49 #[serde(default)]
50 pub at_warn_level: Option<bool>,
51}
52
53#[derive(Serialize)]
54#[serde(rename_all = "PascalCase")]
55struct MobileGestaltRequest<'a> {
56 request: &'static str,
57 #[serde(rename = "MobileGestaltKeys")]
58 keys: Vec<&'a str>,
59}
60
61#[derive(Serialize)]
62#[serde(rename_all = "PascalCase")]
63struct IoRegistryRequest<'a> {
64 request: &'static str,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 entry_class: Option<&'a str>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 entry_name: Option<&'a str>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 current_plane: Option<&'a str>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct IoRegistryQuery<'a> {
75 pub entry_class: Option<&'a str>,
76 pub entry_name: Option<&'a str>,
77 pub current_plane: Option<&'a str>,
78}
79
80impl<'a> IoRegistryQuery<'a> {
81 pub fn by_class(entry_class: &'a str) -> Self {
82 Self {
83 entry_class: Some(entry_class),
84 entry_name: None,
85 current_plane: None,
86 }
87 }
88}
89
90pub async fn query_mobile_gestalt<S>(
92 stream: &mut S,
93 keys: &[&str],
94) -> Result<plist::Value, DiagnosticsError>
95where
96 S: AsyncRead + AsyncWrite + Unpin + ?Sized,
97{
98 send_plist(
99 stream,
100 &MobileGestaltRequest {
101 request: "MobileGestalt",
102 keys: keys.to_vec(),
103 },
104 )
105 .await?;
106
107 let response = recv_response_dict(stream).await?;
108 let diagnostics = extract_diagnostics_payload(&response)?;
109
110 extract_mobile_gestalt_payload(diagnostics)
111}
112
113pub async fn query_all_values<S>(stream: &mut S) -> Result<plist::Value, DiagnosticsError>
115where
116 S: AsyncRead + AsyncWrite + Unpin + ?Sized,
117{
118 #[derive(Serialize)]
119 #[serde(rename_all = "PascalCase")]
120 struct AllRequest {
121 request: &'static str,
122 }
123
124 send_plist(stream, &AllRequest { request: "All" }).await?;
125 let response = recv_response_dict(stream).await?;
126 extract_diagnostics_payload(&response)
127}
128
129pub async fn query_battery<S>(stream: &mut S) -> Result<BatteryDiagnostics, DiagnosticsError>
131where
132 S: AsyncRead + AsyncWrite + Unpin + ?Sized,
133{
134 let io_registry = query_ioregistry(stream, "IOPMPowerSource").await?;
135 Ok(plist::from_value(&io_registry)?)
136}
137
138pub async fn query_ioregistry<S>(
140 stream: &mut S,
141 entry_class: &str,
142) -> Result<plist::Value, DiagnosticsError>
143where
144 S: AsyncRead + AsyncWrite + Unpin + ?Sized,
145{
146 query_ioregistry_with(stream, IoRegistryQuery::by_class(entry_class)).await
147}
148
149pub async fn query_ioregistry_with<S>(
150 stream: &mut S,
151 query: IoRegistryQuery<'_>,
152) -> Result<plist::Value, DiagnosticsError>
153where
154 S: AsyncRead + AsyncWrite + Unpin + ?Sized,
155{
156 if query.entry_class.is_none() && query.entry_name.is_none() {
157 return Err(DiagnosticsError::Protocol(
158 "IORegistry query requires EntryClass or EntryName".into(),
159 ));
160 }
161
162 send_plist(
163 stream,
164 &IoRegistryRequest {
165 request: "IORegistry",
166 entry_class: query.entry_class,
167 entry_name: query.entry_name,
168 current_plane: query.current_plane,
169 },
170 )
171 .await?;
172
173 let response = recv_response_dict(stream).await?;
174 let diagnostics = extract_diagnostics_payload(&response)?;
175 let dict = diagnostics.into_dictionary().ok_or_else(|| {
176 DiagnosticsError::Protocol("diagnostics payload was not a dictionary".into())
177 })?;
178 let io_registry = dict
179 .get("IORegistry")
180 .cloned()
181 .ok_or_else(|| DiagnosticsError::Protocol("diagnostics missing IORegistry".into()))?;
182 Ok(io_registry)
183}
184
185pub async fn reboot<S>(stream: &mut S) -> Result<(), DiagnosticsError>
187where
188 S: AsyncRead + AsyncWrite + Unpin + ?Sized,
189{
190 #[derive(Serialize)]
191 #[serde(rename_all = "PascalCase")]
192 struct Request {
193 request: &'static str,
194 }
195 send_plist(stream, &Request { request: "Restart" }).await?;
196 recv_plist_raw(stream).await?;
197 Ok(())
198}
199
200async fn send_plist<S, T>(stream: &mut S, value: &T) -> Result<(), DiagnosticsError>
203where
204 S: AsyncWrite + Unpin + ?Sized,
205 T: Serialize,
206{
207 let mut buf = Vec::new();
208 plist::to_writer_xml(&mut buf, value)?;
209 stream.write_all(&(buf.len() as u32).to_be_bytes()).await?;
210 stream.write_all(&buf).await?;
211 stream.flush().await?;
212 Ok(())
213}
214
215async fn recv_plist_raw<S>(stream: &mut S) -> Result<Vec<u8>, DiagnosticsError>
216where
217 S: AsyncRead + Unpin + ?Sized,
218{
219 let mut len_buf = [0u8; 4];
220 stream.read_exact(&mut len_buf).await?;
221 let len = u32::from_be_bytes(len_buf) as usize;
222 const MAX_PLIST_SIZE: usize = 4 * 1024 * 1024;
224 if len > MAX_PLIST_SIZE {
225 return Err(DiagnosticsError::Protocol(format!(
226 "plist length {len} exceeds max {MAX_PLIST_SIZE}"
227 )));
228 }
229 let mut buf = vec![0u8; len];
230 stream.read_exact(&mut buf).await?;
231 Ok(buf)
232}
233
234async fn recv_response_dict<S>(stream: &mut S) -> Result<plist::Dictionary, DiagnosticsError>
235where
236 S: AsyncRead + Unpin + ?Sized,
237{
238 let data = recv_plist_raw(stream).await?;
239 let value: plist::Value = plist::from_bytes(&data)?;
240 value.into_dictionary().ok_or_else(|| {
241 DiagnosticsError::Protocol("diagnostics response payload was not a dictionary".into())
242 })
243}
244
245fn extract_diagnostics_payload(
246 response: &plist::Dictionary,
247) -> Result<plist::Value, DiagnosticsError> {
248 response
249 .get("Diagnostics")
250 .cloned()
251 .ok_or_else(|| missing_diagnostics_error(response))
252}
253
254fn missing_diagnostics_error(response: &plist::Dictionary) -> DiagnosticsError {
255 let status = response
256 .get("Status")
257 .and_then(plist::Value::as_string)
258 .map(|value| format!(" (Status={value})"))
259 .unwrap_or_default();
260 let rendered = render_plist_value(&plist::Value::Dictionary(response.clone()));
261 DiagnosticsError::Protocol(format!(
262 "diagnostics response missing Diagnostics{status}: {rendered}"
263 ))
264}
265
266fn render_plist_value(value: &plist::Value) -> String {
267 serde_json::to_string(&plist_to_json(value))
268 .unwrap_or_else(|_| "<failed to render plist value>".to_string())
269}
270
271fn plist_to_json(value: &plist::Value) -> serde_json::Value {
272 match value {
273 plist::Value::Array(items) => {
274 serde_json::Value::Array(items.iter().map(plist_to_json).collect())
275 }
276 plist::Value::Boolean(value) => serde_json::Value::Bool(*value),
277 plist::Value::Data(bytes) => {
278 serde_json::Value::Array(bytes.iter().copied().map(serde_json::Value::from).collect())
279 }
280 plist::Value::Date(value) => serde_json::Value::String(value.to_xml_format()),
281 plist::Value::Dictionary(dict) => serde_json::Value::Object(
282 dict.iter()
283 .map(|(key, value)| (key.clone(), plist_to_json(value)))
284 .collect(),
285 ),
286 plist::Value::Integer(value) => value
287 .as_signed()
288 .map(serde_json::Value::from)
289 .or_else(|| value.as_unsigned().map(serde_json::Value::from))
290 .unwrap_or(serde_json::Value::Null),
291 plist::Value::Real(value) => serde_json::Value::from(*value),
292 plist::Value::String(value) => serde_json::Value::String(value.clone()),
293 plist::Value::Uid(value) => serde_json::Value::from(value.get()),
294 _ => serde_json::Value::Null,
295 }
296}
297
298fn extract_mobile_gestalt_payload(
299 diagnostics: plist::Value,
300) -> Result<plist::Value, DiagnosticsError> {
301 let Some(dict) = diagnostics.as_dictionary() else {
302 return Ok(diagnostics);
303 };
304
305 let Some(mobile_gestalt) = dict.get("MobileGestalt") else {
306 return Ok(diagnostics);
307 };
308
309 let mut inner = mobile_gestalt.as_dictionary().cloned().ok_or_else(|| {
310 DiagnosticsError::Protocol("MobileGestalt payload was not a dictionary".into())
311 })?;
312
313 if let Some(status) = inner.get("Status").and_then(|value| value.as_string()) {
314 match status {
315 "Success" => {
316 inner.remove("Status");
317 }
318 "MobileGestaltDeprecated" => {
319 return Err(DiagnosticsError::Deprecated(
320 "diagnostics relay reports MobileGestaltDeprecated on this OS".into(),
321 ));
322 }
323 other => {
324 return Err(DiagnosticsError::Protocol(format!(
325 "unexpected MobileGestalt status: {other}"
326 )));
327 }
328 }
329 }
330
331 Ok(plist::Value::Dictionary(inner))
332}
333
334#[cfg(test)]
335mod tests {
336 use crate::test_util::MockStream;
337
338 use super::*;
339
340 #[tokio::test]
341 async fn query_mobile_gestalt_uses_mobile_gestalt_keys_field() {
342 let mut stream =
343 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
344 "Diagnostics".to_string(),
345 plist::Value::Dictionary(plist::Dictionary::new()),
346 )])));
347
348 let _ = query_mobile_gestalt(&mut stream, &["ProductVersion"])
349 .await
350 .unwrap();
351
352 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
353 let payload = &stream.written[4..4 + len];
354 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
355 assert_eq!(dict["Request"].as_string(), Some("MobileGestalt"));
356 let keys = dict["MobileGestaltKeys"].as_array().unwrap();
357 assert_eq!(keys.len(), 1);
358 assert_eq!(keys[0].as_string(), Some("ProductVersion"));
359 assert!(!dict.contains_key("Keys"));
360 }
361
362 #[tokio::test]
363 async fn query_mobile_gestalt_returns_diagnostics_payload() {
364 let mut stream =
365 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
366 "Diagnostics".to_string(),
367 plist::Value::Dictionary(plist::Dictionary::from_iter([(
368 "ProductVersion".to_string(),
369 plist::Value::String("26.0".into()),
370 )])),
371 )])));
372
373 let value = query_mobile_gestalt(&mut stream, &["ProductVersion"])
374 .await
375 .unwrap();
376 let dict = value.into_dictionary().unwrap();
377 assert_eq!(dict["ProductVersion"].as_string(), Some("26.0"));
378 }
379
380 #[tokio::test]
381 async fn query_mobile_gestalt_strips_nested_success_status() {
382 let mut stream =
383 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
384 "Diagnostics".to_string(),
385 plist::Value::Dictionary(plist::Dictionary::from_iter([(
386 "MobileGestalt".to_string(),
387 plist::Value::Dictionary(plist::Dictionary::from_iter([
388 ("Status".to_string(), plist::Value::String("Success".into())),
389 (
390 "ProductVersion".to_string(),
391 plist::Value::String("26.0".into()),
392 ),
393 ])),
394 )])),
395 )])));
396
397 let value = query_mobile_gestalt(&mut stream, &["ProductVersion"])
398 .await
399 .unwrap();
400 let dict = value.into_dictionary().unwrap();
401 assert_eq!(dict["ProductVersion"].as_string(), Some("26.0"));
402 assert!(!dict.contains_key("Status"));
403 }
404
405 #[tokio::test]
406 async fn query_mobile_gestalt_returns_deprecated_error() {
407 let mut stream =
408 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
409 "Diagnostics".to_string(),
410 plist::Value::Dictionary(plist::Dictionary::from_iter([(
411 "MobileGestalt".to_string(),
412 plist::Value::Dictionary(plist::Dictionary::from_iter([(
413 "Status".to_string(),
414 plist::Value::String("MobileGestaltDeprecated".into()),
415 )])),
416 )])),
417 )])));
418
419 let err = query_mobile_gestalt(&mut stream, &["ProductVersion"])
420 .await
421 .unwrap_err();
422 assert!(matches!(err, DiagnosticsError::Deprecated(_)));
423 assert!(err.to_string().contains("deprecated"));
424 }
425
426 #[tokio::test]
427 async fn query_all_values_sends_all_request_and_returns_diagnostics_payload() {
428 let mut stream =
429 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
430 "Diagnostics".to_string(),
431 plist::Value::Dictionary(plist::Dictionary::from_iter([(
432 "GasGauge".to_string(),
433 plist::Value::Dictionary(plist::Dictionary::from_iter([(
434 "CycleCount".to_string(),
435 plist::Value::Integer(315.into()),
436 )])),
437 )])),
438 )])));
439
440 let value = query_all_values(&mut stream).await.unwrap();
441 let dict = value.into_dictionary().unwrap();
442 assert!(dict.contains_key("GasGauge"));
443
444 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
445 let payload = &stream.written[4..4 + len];
446 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
447 assert_eq!(dict["Request"].as_string(), Some("All"));
448 }
449
450 #[tokio::test]
451 async fn query_battery_sends_ioregistry_request_for_power_source() {
452 let mut stream =
453 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
454 "Diagnostics".to_string(),
455 plist::Value::Dictionary(plist::Dictionary::from_iter([(
456 "IORegistry".to_string(),
457 plist::Value::Dictionary(plist::Dictionary::new()),
458 )])),
459 )])));
460
461 let _ = query_battery(&mut stream).await.unwrap();
462
463 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
464 let payload = &stream.written[4..4 + len];
465 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
466 assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
467 assert_eq!(dict["EntryClass"].as_string(), Some("IOPMPowerSource"));
468 }
469
470 #[tokio::test]
471 async fn query_battery_extracts_ioregistry_payload() {
472 let mut stream =
473 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
474 "Diagnostics".to_string(),
475 plist::Value::Dictionary(plist::Dictionary::from_iter([(
476 "IORegistry".to_string(),
477 plist::Value::Dictionary(plist::Dictionary::from_iter([
478 (
479 "CurrentCapacity".to_string(),
480 plist::Value::Integer(82.into()),
481 ),
482 ("IsCharging".to_string(), plist::Value::Boolean(true)),
483 ("CycleCount".to_string(), plist::Value::Integer(315.into())),
484 ])),
485 )])),
486 )])));
487
488 let battery = query_battery(&mut stream).await.unwrap();
489 assert_eq!(battery.current_capacity, Some(82));
490 assert_eq!(battery.is_charging, Some(true));
491 assert_eq!(battery.cycle_count, Some(315));
492 }
493
494 #[tokio::test]
495 async fn query_ioregistry_returns_raw_ioregistry_payload() {
496 let mut stream =
497 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
498 "Diagnostics".to_string(),
499 plist::Value::Dictionary(plist::Dictionary::from_iter([(
500 "IORegistry".to_string(),
501 plist::Value::Dictionary(plist::Dictionary::from_iter([(
502 "ProductName".to_string(),
503 plist::Value::String("iPhone".into()),
504 )])),
505 )])),
506 )])));
507
508 let value = query_ioregistry(&mut stream, "IOPlatformExpertDevice")
509 .await
510 .unwrap();
511 let dict = value.into_dictionary().unwrap();
512 assert_eq!(dict["ProductName"].as_string(), Some("iPhone"));
513
514 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
515 let payload = &stream.written[4..4 + len];
516 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
517 assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
518 assert_eq!(
519 dict["EntryClass"].as_string(),
520 Some("IOPlatformExpertDevice")
521 );
522 assert!(!dict.contains_key("EntryName"));
523 assert!(!dict.contains_key("CurrentPlane"));
524 }
525
526 #[tokio::test]
527 async fn query_ioregistry_with_name_and_plane_encodes_optional_fields() {
528 let mut stream =
529 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
530 "Diagnostics".to_string(),
531 plist::Value::Dictionary(plist::Dictionary::from_iter([(
532 "IORegistry".to_string(),
533 plist::Value::Dictionary(plist::Dictionary::new()),
534 )])),
535 )])));
536
537 let _ = query_ioregistry_with(
538 &mut stream,
539 IoRegistryQuery {
540 entry_class: None,
541 entry_name: Some("device-tree"),
542 current_plane: Some("IODeviceTree"),
543 },
544 )
545 .await
546 .unwrap();
547
548 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
549 let payload = &stream.written[4..4 + len];
550 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
551 assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
552 assert_eq!(dict["EntryName"].as_string(), Some("device-tree"));
553 assert_eq!(dict["CurrentPlane"].as_string(), Some("IODeviceTree"));
554 assert!(!dict.contains_key("EntryClass"));
555 }
556
557 #[tokio::test]
558 async fn query_ioregistry_reports_status_when_diagnostics_are_missing() {
559 let mut stream =
560 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([
561 (
562 "Status".to_string(),
563 plist::Value::String("LookupFailed".into()),
564 ),
565 (
566 "Error".to_string(),
567 plist::Value::String("Entry not found".into()),
568 ),
569 ])));
570
571 let err = query_ioregistry_with(
572 &mut stream,
573 IoRegistryQuery {
574 entry_class: Some("IO80211Interface"),
575 entry_name: None,
576 current_plane: None,
577 },
578 )
579 .await
580 .unwrap_err();
581
582 assert!(matches!(err, DiagnosticsError::Protocol(_)));
583 let message = err.to_string();
584 assert!(message.contains("LookupFailed"));
585 assert!(message.contains("Entry not found"));
586 assert!(message.contains("Diagnostics"));
587 }
588}