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 plist::from_value(&io_registry).map_err(|e| DiagnosticsError::Plist(e.to_string()))
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).map_err(|e| DiagnosticsError::Plist(e.to_string()))?;
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 =
240 plist::from_bytes(&data).map_err(|e| DiagnosticsError::Plist(e.to_string()))?;
241 value.into_dictionary().ok_or_else(|| {
242 DiagnosticsError::Protocol("diagnostics response payload was not a dictionary".into())
243 })
244}
245
246fn extract_diagnostics_payload(
247 response: &plist::Dictionary,
248) -> Result<plist::Value, DiagnosticsError> {
249 response
250 .get("Diagnostics")
251 .cloned()
252 .ok_or_else(|| missing_diagnostics_error(response))
253}
254
255fn missing_diagnostics_error(response: &plist::Dictionary) -> DiagnosticsError {
256 let status = response
257 .get("Status")
258 .and_then(plist::Value::as_string)
259 .map(|value| format!(" (Status={value})"))
260 .unwrap_or_default();
261 let rendered = render_plist_value(&plist::Value::Dictionary(response.clone()));
262 DiagnosticsError::Protocol(format!(
263 "diagnostics response missing Diagnostics{status}: {rendered}"
264 ))
265}
266
267fn render_plist_value(value: &plist::Value) -> String {
268 serde_json::to_string(&plist_to_json(value))
269 .unwrap_or_else(|_| "<failed to render plist value>".to_string())
270}
271
272fn plist_to_json(value: &plist::Value) -> serde_json::Value {
273 match value {
274 plist::Value::Array(items) => {
275 serde_json::Value::Array(items.iter().map(plist_to_json).collect())
276 }
277 plist::Value::Boolean(value) => serde_json::Value::Bool(*value),
278 plist::Value::Data(bytes) => {
279 serde_json::Value::Array(bytes.iter().copied().map(serde_json::Value::from).collect())
280 }
281 plist::Value::Date(value) => serde_json::Value::String(value.to_xml_format()),
282 plist::Value::Dictionary(dict) => serde_json::Value::Object(
283 dict.iter()
284 .map(|(key, value)| (key.clone(), plist_to_json(value)))
285 .collect(),
286 ),
287 plist::Value::Integer(value) => value
288 .as_signed()
289 .map(serde_json::Value::from)
290 .or_else(|| value.as_unsigned().map(serde_json::Value::from))
291 .unwrap_or(serde_json::Value::Null),
292 plist::Value::Real(value) => serde_json::Value::from(*value),
293 plist::Value::String(value) => serde_json::Value::String(value.clone()),
294 plist::Value::Uid(value) => serde_json::Value::from(value.get()),
295 _ => serde_json::Value::Null,
296 }
297}
298
299fn extract_mobile_gestalt_payload(
300 diagnostics: plist::Value,
301) -> Result<plist::Value, DiagnosticsError> {
302 let Some(dict) = diagnostics.as_dictionary() else {
303 return Ok(diagnostics);
304 };
305
306 let Some(mobile_gestalt) = dict.get("MobileGestalt") else {
307 return Ok(diagnostics);
308 };
309
310 let mut inner = mobile_gestalt.as_dictionary().cloned().ok_or_else(|| {
311 DiagnosticsError::Protocol("MobileGestalt payload was not a dictionary".into())
312 })?;
313
314 if let Some(status) = inner.get("Status").and_then(|value| value.as_string()) {
315 match status {
316 "Success" => {
317 inner.remove("Status");
318 }
319 "MobileGestaltDeprecated" => {
320 return Err(DiagnosticsError::Deprecated(
321 "diagnostics relay reports MobileGestaltDeprecated on this OS".into(),
322 ));
323 }
324 other => {
325 return Err(DiagnosticsError::Protocol(format!(
326 "unexpected MobileGestalt status: {other}"
327 )));
328 }
329 }
330 }
331
332 Ok(plist::Value::Dictionary(inner))
333}
334
335#[cfg(test)]
336mod tests {
337 use crate::test_util::MockStream;
338
339 use super::*;
340
341 #[tokio::test]
342 async fn query_mobile_gestalt_uses_mobile_gestalt_keys_field() {
343 let mut stream =
344 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
345 "Diagnostics".to_string(),
346 plist::Value::Dictionary(plist::Dictionary::new()),
347 )])));
348
349 let _ = query_mobile_gestalt(&mut stream, &["ProductVersion"])
350 .await
351 .unwrap();
352
353 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
354 let payload = &stream.written[4..4 + len];
355 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
356 assert_eq!(dict["Request"].as_string(), Some("MobileGestalt"));
357 let keys = dict["MobileGestaltKeys"].as_array().unwrap();
358 assert_eq!(keys.len(), 1);
359 assert_eq!(keys[0].as_string(), Some("ProductVersion"));
360 assert!(!dict.contains_key("Keys"));
361 }
362
363 #[tokio::test]
364 async fn query_mobile_gestalt_returns_diagnostics_payload() {
365 let mut stream =
366 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
367 "Diagnostics".to_string(),
368 plist::Value::Dictionary(plist::Dictionary::from_iter([(
369 "ProductVersion".to_string(),
370 plist::Value::String("26.0".into()),
371 )])),
372 )])));
373
374 let value = query_mobile_gestalt(&mut stream, &["ProductVersion"])
375 .await
376 .unwrap();
377 let dict = value.into_dictionary().unwrap();
378 assert_eq!(dict["ProductVersion"].as_string(), Some("26.0"));
379 }
380
381 #[tokio::test]
382 async fn query_mobile_gestalt_strips_nested_success_status() {
383 let mut stream =
384 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
385 "Diagnostics".to_string(),
386 plist::Value::Dictionary(plist::Dictionary::from_iter([(
387 "MobileGestalt".to_string(),
388 plist::Value::Dictionary(plist::Dictionary::from_iter([
389 ("Status".to_string(), plist::Value::String("Success".into())),
390 (
391 "ProductVersion".to_string(),
392 plist::Value::String("26.0".into()),
393 ),
394 ])),
395 )])),
396 )])));
397
398 let value = query_mobile_gestalt(&mut stream, &["ProductVersion"])
399 .await
400 .unwrap();
401 let dict = value.into_dictionary().unwrap();
402 assert_eq!(dict["ProductVersion"].as_string(), Some("26.0"));
403 assert!(!dict.contains_key("Status"));
404 }
405
406 #[tokio::test]
407 async fn query_mobile_gestalt_returns_deprecated_error() {
408 let mut stream =
409 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
410 "Diagnostics".to_string(),
411 plist::Value::Dictionary(plist::Dictionary::from_iter([(
412 "MobileGestalt".to_string(),
413 plist::Value::Dictionary(plist::Dictionary::from_iter([(
414 "Status".to_string(),
415 plist::Value::String("MobileGestaltDeprecated".into()),
416 )])),
417 )])),
418 )])));
419
420 let err = query_mobile_gestalt(&mut stream, &["ProductVersion"])
421 .await
422 .unwrap_err();
423 assert!(matches!(err, DiagnosticsError::Deprecated(_)));
424 assert!(err.to_string().contains("deprecated"));
425 }
426
427 #[tokio::test]
428 async fn query_all_values_sends_all_request_and_returns_diagnostics_payload() {
429 let mut stream =
430 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
431 "Diagnostics".to_string(),
432 plist::Value::Dictionary(plist::Dictionary::from_iter([(
433 "GasGauge".to_string(),
434 plist::Value::Dictionary(plist::Dictionary::from_iter([(
435 "CycleCount".to_string(),
436 plist::Value::Integer(315.into()),
437 )])),
438 )])),
439 )])));
440
441 let value = query_all_values(&mut stream).await.unwrap();
442 let dict = value.into_dictionary().unwrap();
443 assert!(dict.contains_key("GasGauge"));
444
445 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
446 let payload = &stream.written[4..4 + len];
447 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
448 assert_eq!(dict["Request"].as_string(), Some("All"));
449 }
450
451 #[tokio::test]
452 async fn query_battery_sends_ioregistry_request_for_power_source() {
453 let mut stream =
454 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
455 "Diagnostics".to_string(),
456 plist::Value::Dictionary(plist::Dictionary::from_iter([(
457 "IORegistry".to_string(),
458 plist::Value::Dictionary(plist::Dictionary::new()),
459 )])),
460 )])));
461
462 let _ = query_battery(&mut stream).await.unwrap();
463
464 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
465 let payload = &stream.written[4..4 + len];
466 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
467 assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
468 assert_eq!(dict["EntryClass"].as_string(), Some("IOPMPowerSource"));
469 }
470
471 #[tokio::test]
472 async fn query_battery_extracts_ioregistry_payload() {
473 let mut stream =
474 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
475 "Diagnostics".to_string(),
476 plist::Value::Dictionary(plist::Dictionary::from_iter([(
477 "IORegistry".to_string(),
478 plist::Value::Dictionary(plist::Dictionary::from_iter([
479 (
480 "CurrentCapacity".to_string(),
481 plist::Value::Integer(82.into()),
482 ),
483 ("IsCharging".to_string(), plist::Value::Boolean(true)),
484 ("CycleCount".to_string(), plist::Value::Integer(315.into())),
485 ])),
486 )])),
487 )])));
488
489 let battery = query_battery(&mut stream).await.unwrap();
490 assert_eq!(battery.current_capacity, Some(82));
491 assert_eq!(battery.is_charging, Some(true));
492 assert_eq!(battery.cycle_count, Some(315));
493 }
494
495 #[tokio::test]
496 async fn query_ioregistry_returns_raw_ioregistry_payload() {
497 let mut stream =
498 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
499 "Diagnostics".to_string(),
500 plist::Value::Dictionary(plist::Dictionary::from_iter([(
501 "IORegistry".to_string(),
502 plist::Value::Dictionary(plist::Dictionary::from_iter([(
503 "ProductName".to_string(),
504 plist::Value::String("iPhone".into()),
505 )])),
506 )])),
507 )])));
508
509 let value = query_ioregistry(&mut stream, "IOPlatformExpertDevice")
510 .await
511 .unwrap();
512 let dict = value.into_dictionary().unwrap();
513 assert_eq!(dict["ProductName"].as_string(), Some("iPhone"));
514
515 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
516 let payload = &stream.written[4..4 + len];
517 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
518 assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
519 assert_eq!(
520 dict["EntryClass"].as_string(),
521 Some("IOPlatformExpertDevice")
522 );
523 assert!(!dict.contains_key("EntryName"));
524 assert!(!dict.contains_key("CurrentPlane"));
525 }
526
527 #[tokio::test]
528 async fn query_ioregistry_with_name_and_plane_encodes_optional_fields() {
529 let mut stream =
530 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
531 "Diagnostics".to_string(),
532 plist::Value::Dictionary(plist::Dictionary::from_iter([(
533 "IORegistry".to_string(),
534 plist::Value::Dictionary(plist::Dictionary::new()),
535 )])),
536 )])));
537
538 let _ = query_ioregistry_with(
539 &mut stream,
540 IoRegistryQuery {
541 entry_class: None,
542 entry_name: Some("device-tree"),
543 current_plane: Some("IODeviceTree"),
544 },
545 )
546 .await
547 .unwrap();
548
549 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
550 let payload = &stream.written[4..4 + len];
551 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
552 assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
553 assert_eq!(dict["EntryName"].as_string(), Some("device-tree"));
554 assert_eq!(dict["CurrentPlane"].as_string(), Some("IODeviceTree"));
555 assert!(!dict.contains_key("EntryClass"));
556 }
557
558 #[tokio::test]
559 async fn query_ioregistry_reports_status_when_diagnostics_are_missing() {
560 let mut stream =
561 MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([
562 (
563 "Status".to_string(),
564 plist::Value::String("LookupFailed".into()),
565 ),
566 (
567 "Error".to_string(),
568 plist::Value::String("Entry not found".into()),
569 ),
570 ])));
571
572 let err = query_ioregistry_with(
573 &mut stream,
574 IoRegistryQuery {
575 entry_class: Some("IO80211Interface"),
576 entry_name: None,
577 current_plane: None,
578 },
579 )
580 .await
581 .unwrap_err();
582
583 assert!(matches!(err, DiagnosticsError::Protocol(_)));
584 let message = err.to_string();
585 assert!(message.contains("LookupFailed"));
586 assert!(message.contains("Entry not found"));
587 assert!(message.contains("Diagnostics"));
588 }
589}