1use std::time::Duration;
2
3use crate::proto::nskeyedarchiver_encode;
4use plist::{Dictionary, Value};
5use serde::Serialize;
6use serde_json::{Map, Value as JsonValue};
7use tokio::io::{AsyncRead, AsyncWrite};
8
9use crate::services::dtx::primitive_enc::archived_object;
10use crate::services::dtx::{DtxConnection, DtxError, DtxPayload, NSObject};
11
12pub const SERVICE_NAME: &str = "com.apple.accessibility.axAuditDaemon.remoteserver";
13pub const RSD_SERVICE_NAME: &str = "com.apple.accessibility.axAuditDaemon.remoteserver.shim.remote";
14
15const PUBLISH_CAPABILITIES_SELECTOR: &str = "_notifyOfPublishedCapabilities:";
16const EVENT_AUDIT_COMPLETE: &str = "hostDeviceDidCompleteAuditCategoriesWithAuditIssues:";
17const EVENT_FOCUS_CHANGED: &str = "hostInspectorCurrentElementChanged:";
18const EVENT_MONITORED_EVENT_TYPE_CHANGED: &str = "hostInspectorMonitoredEventTypeChanged:";
19
20#[derive(Debug, thiserror::Error)]
21pub enum AccessibilityAuditError {
22 #[error("DTX error: {0}")]
23 Dtx(#[from] DtxError),
24 #[error("protocol error: {0}")]
25 Protocol(String),
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum MoveDirection {
30 Previous = 3,
31 Next = 4,
32 First = 5,
33 Last = 6,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum AccessibilityAuditHandshake {
38 PublishCapabilities,
39 SkipInitialCapabilities,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize)]
43pub struct AuditEvent {
44 pub selector: String,
45 pub data: JsonValue,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct FocusElement {
50 pub platform_identifier: String,
51 pub estimated_uid: String,
52 pub caption: Option<String>,
53 pub spoken_description: Option<String>,
54}
55
56impl FocusElement {
57 fn try_from_event_payload(
58 payload: &JsonValue,
59 ) -> Result<Option<Self>, AccessibilityAuditError> {
60 let normalized = deserialize_ax_json(payload);
61 let object = find_first_object(&normalized).ok_or_else(|| {
62 AccessibilityAuditError::Protocol(format!(
63 "focus payload was not a JSON object or object list: {}",
64 json_debug_snippet(&normalized)
65 ))
66 })?;
67 let Some(platform_bytes) = extract_platform_element_bytes(object) else {
68 return Ok(None);
69 };
70 if platform_bytes.len() < 16 {
71 return Err(AccessibilityAuditError::Protocol(format!(
72 "platform element identifier too short: {} bytes; payload: {}",
73 platform_bytes.len(),
74 json_debug_snippet(&normalized)
75 )));
76 }
77
78 let platform_identifier = hex::encode_upper(&platform_bytes);
79 let estimated_uid = format!(
80 "{}-0000-0000-{}-000000000000",
81 hex::encode_upper(&platform_bytes[12..16]),
82 hex::encode_upper(&platform_bytes[0..2])
83 );
84
85 Ok(Some(Self {
86 platform_identifier,
87 estimated_uid,
88 caption: get_optional_string(object, "CaptionTextValue_v1"),
89 spoken_description: get_optional_string(object, "SpokenDescriptionValue_v1"),
90 }))
91 }
92
93 pub fn from_event_payload(payload: &JsonValue) -> Result<Self, AccessibilityAuditError> {
94 Self::try_from_event_payload(payload)?.ok_or_else(|| {
95 AccessibilityAuditError::Protocol(format!(
96 "focus payload did not contain PlatformElementValue_v1 bytes: {}",
97 json_debug_snippet(&deserialize_ax_json(payload))
98 ))
99 })
100 }
101}
102
103pub struct AccessibilityAuditClient<S> {
104 conn: DtxConnection<S>,
105 product_major_version: u64,
106 handshake: AccessibilityAuditHandshake,
107 published_capabilities: bool,
108 initial_messages_flushed: bool,
109 initial_messages_to_flush: usize,
110}
111
112impl<S> AccessibilityAuditClient<S>
113where
114 S: AsyncRead + AsyncWrite + Unpin + Send,
115{
116 pub fn new(stream: S, product_major_version: u64) -> Self {
117 Self::new_with_handshake(
118 stream,
119 product_major_version,
120 AccessibilityAuditHandshake::SkipInitialCapabilities,
121 )
122 }
123
124 pub fn new_rsd(stream: S, product_major_version: u64) -> Self {
125 Self::new_with_handshake(
126 stream,
127 product_major_version,
128 AccessibilityAuditHandshake::SkipInitialCapabilities,
129 )
130 }
131
132 pub fn new_with_handshake(
133 stream: S,
134 product_major_version: u64,
135 handshake: AccessibilityAuditHandshake,
136 ) -> Self {
137 Self {
138 conn: DtxConnection::new(stream),
139 product_major_version,
140 handshake,
141 published_capabilities: handshake
142 == AccessibilityAuditHandshake::SkipInitialCapabilities,
143 initial_messages_flushed: false,
144 initial_messages_to_flush: if product_major_version >= 15 { 2 } else { 1 },
145 }
146 }
147
148 async fn ensure_ready(&mut self) -> Result<(), AccessibilityAuditError> {
149 if !self.published_capabilities {
150 self.publish_capabilities().await?;
151 }
152 if self.initial_messages_flushed {
153 return Ok(());
154 }
155
156 for _ in 0..self.initial_messages_to_flush {
157 let message =
158 match tokio::time::timeout(Duration::from_millis(300), self.conn.recv()).await {
159 Ok(result) => result?,
160 Err(_) => continue, };
162 if message.expects_reply {
163 self.conn.send_ack(&message).await?;
164 }
165 }
166
167 self.initial_messages_flushed = true;
168 Ok(())
169 }
170
171 pub async fn publish_capabilities(&mut self) -> Result<(), AccessibilityAuditError> {
172 if self.handshake == AccessibilityAuditHandshake::SkipInitialCapabilities {
173 self.published_capabilities = true;
174 return Ok(());
175 }
176
177 let payload = nskeyedarchiver_encode::archive_dict(vec![
178 (
179 "com.apple.private.DTXBlockCompression".to_string(),
180 Value::Integer(2.into()),
181 ),
182 (
183 "com.apple.private.DTXConnection".to_string(),
184 Value::Integer(1.into()),
185 ),
186 ]);
187 self.conn
188 .method_call_async(
189 0,
190 PUBLISH_CAPABILITIES_SELECTOR,
191 &[archived_object(payload)],
192 )
193 .await?;
194 self.published_capabilities = true;
195 Ok(())
196 }
197
198 pub async fn capabilities(&mut self) -> Result<Vec<String>, AccessibilityAuditError> {
199 self.ensure_ready().await?;
200 let response = self.conn.method_call(0, "deviceCapabilities", &[]).await?;
201 extract_string_vec_response(response.payload)
202 }
203
204 pub async fn api_version(&mut self) -> Result<u64, AccessibilityAuditError> {
205 self.ensure_ready().await?;
206 let response = self.conn.method_call(0, "deviceApiVersion", &[]).await?;
207 extract_u64_response(response.payload)
208 }
209
210 pub async fn supported_audit_types(&mut self) -> Result<JsonValue, AccessibilityAuditError> {
211 self.ensure_ready().await?;
212 let selector = if self.product_major_version >= 15 {
213 "deviceAllSupportedAuditTypes"
214 } else {
215 "deviceAllAuditCaseIDs"
216 };
217 let response = self.conn.method_call(0, selector, &[]).await?;
218 extract_json_response(response.payload)
219 }
220
221 pub async fn settings(&mut self) -> Result<JsonValue, AccessibilityAuditError> {
222 self.ensure_ready().await?;
223 let response = self
224 .conn
225 .method_call(0, "deviceAccessibilitySettings", &[])
226 .await?;
227 extract_json_response(response.payload).map(|value| deserialize_ax_json(&value))
228 }
229
230 pub async fn set_app_monitoring_enabled(
231 &mut self,
232 enabled: bool,
233 ) -> Result<(), AccessibilityAuditError> {
234 self.ensure_ready().await?;
235 self.conn
236 .method_call_async(
237 0,
238 "deviceSetAppMonitoringEnabled:",
239 &[archived_object(nskeyedarchiver_encode::archive_bool(
240 enabled,
241 ))],
242 )
243 .await?;
244 Ok(())
245 }
246
247 pub async fn set_monitored_event_type(
248 &mut self,
249 event_type: u64,
250 ) -> Result<(), AccessibilityAuditError> {
251 self.ensure_ready().await?;
252 self.conn
253 .method_call_async(
254 0,
255 "deviceInspectorSetMonitoredEventType:",
256 &[archived_object(nskeyedarchiver_encode::archive_int(
257 event_type as i64,
258 ))],
259 )
260 .await?;
261 Ok(())
262 }
263
264 pub async fn set_show_ignored_elements(
265 &mut self,
266 enabled: bool,
267 ) -> Result<(), AccessibilityAuditError> {
268 self.ensure_ready().await?;
269 self.conn
270 .method_call_async(
271 0,
272 "deviceInspectorShowIgnoredElements:",
273 &[archived_object(nskeyedarchiver_encode::archive_bool(
274 enabled,
275 ))],
276 )
277 .await?;
278 Ok(())
279 }
280
281 pub async fn set_show_visuals(&mut self, enabled: bool) -> Result<(), AccessibilityAuditError> {
282 self.ensure_ready().await?;
283 self.conn
284 .method_call_async(
285 0,
286 "deviceInspectorShowVisuals:",
287 &[archived_object(nskeyedarchiver_encode::archive_bool(
288 enabled,
289 ))],
290 )
291 .await?;
292 Ok(())
293 }
294
295 pub async fn set_audit_target_pid(&mut self, pid: u64) -> Result<(), AccessibilityAuditError> {
296 self.ensure_ready().await?;
297 self.conn
298 .method_call_async(
299 0,
300 "deviceSetAuditTargetPid:",
301 &[archived_object(nskeyedarchiver_encode::archive_int(
302 pid as i64,
303 ))],
304 )
305 .await?;
306 Ok(())
307 }
308
309 pub async fn focus_on_element(&mut self) -> Result<(), AccessibilityAuditError> {
310 self.ensure_ready().await?;
311 self.conn
312 .method_call_async(
313 0,
314 "deviceInspectorFocusOnElement:",
315 &[archived_object(nskeyedarchiver_encode::archive_null())],
316 )
317 .await?;
318 Ok(())
319 }
320
321 pub async fn preview_on_element(&mut self) -> Result<(), AccessibilityAuditError> {
322 self.ensure_ready().await?;
323 self.conn
324 .method_call_async(
325 0,
326 "deviceInspectorPreviewOnElement:",
327 &[archived_object(nskeyedarchiver_encode::archive_null())],
328 )
329 .await?;
330 Ok(())
331 }
332
333 pub async fn highlight_issue(&mut self) -> Result<(), AccessibilityAuditError> {
334 self.ensure_ready().await?;
335 self.conn
336 .method_call_async(
337 0,
338 "deviceHighlightIssue:",
339 &[archived_object(nskeyedarchiver_encode::archive_dict(
340 vec![],
341 ))],
342 )
343 .await?;
344 Ok(())
345 }
346
347 pub async fn move_focus(
348 &mut self,
349 direction: MoveDirection,
350 ) -> Result<(), AccessibilityAuditError> {
351 self.ensure_ready().await?;
352 self.conn
353 .method_call_async(
354 0,
355 "deviceInspectorMoveWithOptions:",
356 &[archived_object(nskeyedarchiver_encode::archive_dict(vec![
357 (
358 "ObjectType".to_string(),
359 Value::String("passthrough".to_string()),
360 ),
361 (
362 "Value".to_string(),
363 Value::Dictionary(Dictionary::from_iter([
364 (
365 "allowNonAX".to_string(),
366 passthrough_value(Value::Boolean(false)),
367 ),
368 (
369 "direction".to_string(),
370 passthrough_value(Value::Integer((direction as i64).into())),
371 ),
372 (
373 "includeContainers".to_string(),
374 passthrough_value(Value::Boolean(true)),
375 ),
376 ])),
377 ),
378 ]))],
379 )
380 .await?;
381 Ok(())
382 }
383
384 pub async fn next_event(&mut self) -> Result<AuditEvent, AccessibilityAuditError> {
385 self.ensure_ready().await?;
386 loop {
387 let message = self.conn.recv().await?;
388 if message.expects_reply {
389 self.conn.send_ack(&message).await?;
390 }
391
392 if let DtxPayload::MethodInvocation { selector, args } = message.payload {
393 let data = if args.len() == 1 {
394 deserialize_ax_json(&nsobject_to_json(&args[0]))
395 } else {
396 deserialize_ax_json(&JsonValue::Array(
397 args.iter().map(nsobject_to_json).collect(),
398 ))
399 };
400 return Ok(AuditEvent { selector, data });
401 }
402 }
403 }
404
405 pub async fn wait_for_event(
406 &mut self,
407 selector: &str,
408 ) -> Result<AuditEvent, AccessibilityAuditError> {
409 loop {
410 let event = self.next_event().await?;
411 if event.selector == selector {
412 return Ok(event);
413 }
414 }
415 }
416
417 pub async fn wait_for_monitored_event_type_changed(
418 &mut self,
419 ) -> Result<(), AccessibilityAuditError> {
420 let _ = self
421 .wait_for_event(EVENT_MONITORED_EVENT_TYPE_CHANGED)
422 .await?;
423 Ok(())
424 }
425
426 pub async fn run_audit(
427 &mut self,
428 audit_types: &[String],
429 ) -> Result<JsonValue, AccessibilityAuditError> {
430 self.ensure_ready().await?;
431 let selector = if self.product_major_version >= 15 {
432 "deviceBeginAuditTypes:"
433 } else {
434 "deviceBeginAuditCaseIDs:"
435 };
436 let payload = nskeyedarchiver_encode::archive_array(
437 audit_types
438 .iter()
439 .cloned()
440 .map(Value::String)
441 .collect::<Vec<_>>(),
442 );
443 self.conn
444 .method_call_async(0, selector, &[archived_object(payload)])
445 .await?;
446
447 loop {
448 let event = self.next_event().await?;
449 if event.selector == EVENT_AUDIT_COMPLETE {
450 return Ok(extract_audit_complete_issues(&event.data));
451 }
452 }
453 }
454
455 pub async fn next_focus_change(&mut self) -> Result<FocusElement, AccessibilityAuditError> {
456 self.ensure_ready().await?;
457 loop {
458 let event = self.next_event().await?;
459 if event.selector != EVENT_FOCUS_CHANGED {
460 continue;
461 }
462 if let Some(focus) = FocusElement::try_from_event_payload(&event.data)? {
463 return Ok(focus);
464 }
465 }
466 }
467
468 pub async fn next_focus_change_with_idle_timeout(
469 &mut self,
470 idle_timeout: Duration,
471 ) -> Result<Option<FocusElement>, AccessibilityAuditError> {
472 self.ensure_ready().await?;
473 loop {
474 let event = match tokio::time::timeout(idle_timeout, self.next_event()).await {
475 Ok(event) => event?,
476 Err(_) => return Ok(None),
477 };
478 if event.selector != EVENT_FOCUS_CHANGED {
479 continue;
480 }
481 if let Some(focus) = FocusElement::try_from_event_payload(&event.data)? {
482 return Ok(Some(focus));
483 }
484 }
485 }
486
487 pub async fn navigate(
489 &mut self,
490 direction: MoveDirection,
491 timeout: Duration,
492 ) -> Result<Option<FocusElement>, AccessibilityAuditError> {
493 self.move_focus(direction).await?;
494 self.next_focus_change_with_idle_timeout(timeout).await
495 }
496
497 pub async fn perform_action_activate(
501 &mut self,
502 element_bytes: &[u8],
503 ) -> Result<(), AccessibilityAuditError> {
504 self.ensure_ready().await?;
505
506 let element_payload = nskeyedarchiver_encode::archive_dict(vec![
508 (
509 "ObjectType".to_string(),
510 Value::String("passthrough".to_string()),
511 ),
512 (
513 "Value".to_string(),
514 Value::Dictionary(Dictionary::from_iter([(
515 "AXAuditElement_v1".to_string(),
516 Value::Dictionary(Dictionary::from_iter([(
517 "PlatformElementValue_v1".to_string(),
518 passthrough_value(Value::Data(element_bytes.to_vec())),
519 )])),
520 )])),
521 ),
522 ]);
523
524 let action_payload = nskeyedarchiver_encode::archive_dict(vec![
526 (
527 "ObjectType".to_string(),
528 Value::String("cycler".to_string()),
529 ),
530 (
531 "Value".to_string(),
532 Value::Dictionary(Dictionary::from_iter([
533 (
534 "AttributeNameValue_v1".to_string(),
535 passthrough_value(Value::String("AXAction-2010".to_string())),
536 ),
537 (
538 "HumanReadableNameValue_v1".to_string(),
539 passthrough_value(Value::String("Activate".to_string())),
540 ),
541 (
542 "PerformsActionValue_v1".to_string(),
543 passthrough_value(Value::Boolean(true)),
544 ),
545 (
546 "SettableValue_v1".to_string(),
547 passthrough_value(Value::Boolean(false)),
548 ),
549 (
550 "ValueTypeValue_v1".to_string(),
551 passthrough_value(Value::Integer(1.into())),
552 ),
553 (
554 "DisplayAsTree_v1".to_string(),
555 passthrough_value(Value::Boolean(false)),
556 ),
557 (
558 "DisplayInlineValue_v1".to_string(),
559 passthrough_value(Value::Boolean(false)),
560 ),
561 (
562 "IsInternal_v1".to_string(),
563 passthrough_value(Value::Boolean(false)),
564 ),
565 ])),
566 ),
567 ]);
568
569 let value_payload = nskeyedarchiver_encode::archive_dict(vec![]);
571
572 self.conn
573 .method_call_async(
574 0,
575 "deviceElement:performAction:withValue:",
576 &[
577 archived_object(element_payload),
578 archived_object(action_payload),
579 archived_object(value_payload),
580 ],
581 )
582 .await?;
583 Ok(())
584 }
585}
586
587pub fn deserialize_ax_object(value: &Value) -> JsonValue {
588 deserialize_ax_json(&plist_to_json(value))
589}
590
591fn plist_to_json(value: &Value) -> JsonValue {
592 match value {
593 Value::Boolean(v) => JsonValue::Bool(*v),
594 Value::Data(bytes) => {
595 JsonValue::Array(bytes.iter().copied().map(JsonValue::from).collect())
596 }
597 Value::Date(date) => JsonValue::String(date.to_xml_format()),
598 Value::Integer(v) => v
599 .as_signed()
600 .map(JsonValue::from)
601 .or_else(|| v.as_unsigned().map(JsonValue::from))
602 .unwrap_or(JsonValue::Null),
603 Value::Real(v) => serde_json::Number::from_f64(*v)
604 .map(JsonValue::Number)
605 .unwrap_or(JsonValue::Null),
606 Value::String(v) => JsonValue::String(v.clone()),
607 Value::Uid(v) => JsonValue::from(v.get()),
608 Value::Array(values) => JsonValue::Array(values.iter().map(plist_to_json).collect()),
609 Value::Dictionary(dict) => JsonValue::Object(
610 dict.iter()
611 .map(|(key, value)| (key.clone(), plist_to_json(value)))
612 .collect(),
613 ),
614 _ => JsonValue::Null,
615 }
616}
617
618fn deserialize_ax_json(value: &JsonValue) -> JsonValue {
619 match value {
620 JsonValue::Array(items) => {
621 JsonValue::Array(items.iter().map(deserialize_ax_json).collect())
622 }
623 JsonValue::Object(object) => {
624 if let Some(object_type) = object.get("ObjectType").and_then(JsonValue::as_str) {
625 if object_type == "passthrough" {
626 return object
627 .get("Value")
628 .map(deserialize_ax_json)
629 .unwrap_or(JsonValue::Null);
630 }
631 if let Some(inner) = object.get("Value") {
632 return deserialize_ax_json(inner);
633 }
634 }
635
636 JsonValue::Object(
637 object
638 .iter()
639 .map(|(key, value)| (key.clone(), deserialize_ax_json(value)))
640 .collect(),
641 )
642 }
643 other => other.clone(),
644 }
645}
646
647fn extract_string_vec_response(
648 payload: DtxPayload,
649) -> Result<Vec<String>, AccessibilityAuditError> {
650 match payload {
651 DtxPayload::Response(NSObject::Array(items)) => items
652 .into_iter()
653 .map(|item| match item {
654 NSObject::String(value) => Ok(value),
655 other => Err(AccessibilityAuditError::Protocol(format!(
656 "expected string array item, got {other:?}"
657 ))),
658 })
659 .collect(),
660 other => Err(AccessibilityAuditError::Protocol(format!(
661 "expected array response, got {other:?}"
662 ))),
663 }
664}
665
666fn extract_u64_response(payload: DtxPayload) -> Result<u64, AccessibilityAuditError> {
667 match payload {
668 DtxPayload::Response(NSObject::Int(value)) if value >= 0 => Ok(value as u64),
669 DtxPayload::Response(NSObject::Uint(value)) => Ok(value),
670 other => Err(AccessibilityAuditError::Protocol(format!(
671 "expected integer response, got {other:?}"
672 ))),
673 }
674}
675
676fn extract_json_response(payload: DtxPayload) -> Result<JsonValue, AccessibilityAuditError> {
677 match payload {
678 DtxPayload::Response(value) => Ok(nsobject_to_json(&value)),
679 other => Err(AccessibilityAuditError::Protocol(format!(
680 "expected response payload, got {other:?}"
681 ))),
682 }
683}
684
685fn nsobject_to_json(value: &NSObject) -> JsonValue {
686 match value {
687 NSObject::Int(v) => JsonValue::from(*v),
688 NSObject::Uint(v) => JsonValue::from(*v),
689 NSObject::Double(v) => serde_json::Number::from_f64(*v)
690 .map(JsonValue::Number)
691 .unwrap_or(JsonValue::Null),
692 NSObject::Bool(v) => JsonValue::Bool(*v),
693 NSObject::String(v) => JsonValue::String(v.clone()),
694 NSObject::Data(bytes) => {
695 JsonValue::Array(bytes.iter().copied().map(JsonValue::from).collect())
696 }
697 NSObject::Array(items) => JsonValue::Array(items.iter().map(nsobject_to_json).collect()),
698 NSObject::Dict(dict) => JsonValue::Object(
699 dict.iter()
700 .map(|(key, value)| (key.clone(), nsobject_to_json(value)))
701 .collect::<Map<String, JsonValue>>(),
702 ),
703 NSObject::Null => JsonValue::Null,
704 }
705}
706
707fn get_optional_string(object: &Map<String, JsonValue>, field: &str) -> Option<String> {
708 object.get(field).and_then(json_string_to_owned)
709}
710
711fn find_first_object(value: &JsonValue) -> Option<&Map<String, JsonValue>> {
712 match value {
713 JsonValue::Object(object) => Some(object),
714 JsonValue::Array(items) => items.iter().find_map(find_first_object),
715 _ => None,
716 }
717}
718
719fn extract_platform_element_bytes(object: &Map<String, JsonValue>) -> Option<Vec<u8>> {
720 if let Some(bytes) = object
721 .get("PlatformElementValue_v1")
722 .and_then(json_bytes_to_vec)
723 {
724 return Some(bytes);
725 }
726
727 if let Some(bytes) = object
728 .get("ElementValue_v1")
729 .and_then(JsonValue::as_object)
730 .and_then(|element| element.get("PlatformElementValue_v1"))
731 .and_then(json_bytes_to_vec)
732 {
733 return Some(bytes);
734 }
735
736 object
737 .values()
738 .filter_map(JsonValue::as_object)
739 .find_map(extract_platform_element_bytes)
740}
741
742fn extract_audit_complete_issues(payload: &JsonValue) -> JsonValue {
743 match payload {
744 JsonValue::Array(items) if items.len() == 1 => {
745 if let Some(value) = items[0]
746 .as_object()
747 .and_then(|item| item.get("value").or_else(|| item.get("Value")))
748 {
749 return value.clone();
750 }
751 if let JsonValue::Array(inner) = &items[0] {
752 return JsonValue::Array(inner.clone());
753 }
754 payload.clone()
755 }
756 _ => payload.clone(),
757 }
758}
759
760fn json_bytes_to_vec(value: &JsonValue) -> Option<Vec<u8>> {
761 match value {
762 JsonValue::Array(items) => items
763 .iter()
764 .map(|item| item.as_u64().map(|v| v as u8))
765 .collect(),
766 JsonValue::Object(object) => object
767 .get("Value")
768 .and_then(json_bytes_to_vec)
769 .or_else(|| object.values().find_map(json_bytes_to_vec)),
770 _ => None,
771 }
772}
773
774fn json_string_to_owned(value: &JsonValue) -> Option<String> {
775 match value {
776 JsonValue::String(value) => Some(value.clone()),
777 JsonValue::Object(object) => object
778 .get("Value")
779 .and_then(json_string_to_owned)
780 .or_else(|| object.values().find_map(json_string_to_owned)),
781 _ => None,
782 }
783}
784
785fn json_debug_snippet(value: &JsonValue) -> String {
786 let text = serde_json::to_string(value).unwrap_or_else(|_| "<unserializable>".to_string());
787 debug_snippet(&text)
788}
789
790fn debug_snippet(text: &str) -> String {
791 const LIMIT: usize = 1200;
792 if text.len() <= LIMIT {
793 text.to_string()
794 } else {
795 format!("{}...", &text[..LIMIT])
796 }
797}
798
799fn passthrough_value(value: Value) -> Value {
800 Value::Dictionary(Dictionary::from_iter([
801 (
802 "ObjectType".to_string(),
803 Value::String("passthrough".to_string()),
804 ),
805 ("Value".to_string(), value),
806 ]))
807}
808
809#[cfg(test)]
810mod tests {
811 use serde_json::json;
812
813 use super::*;
814 use crate::services::dtx::{DtxPayload, NSObject};
815
816 #[test]
817 fn extract_string_vec_response_rejects_non_string_entries() {
818 let err = extract_string_vec_response(DtxPayload::Response(NSObject::Array(vec![
819 NSObject::String("ok".into()),
820 NSObject::Bool(true),
821 ])))
822 .expect_err("mixed response types must fail");
823
824 assert!(err
825 .to_string()
826 .contains("expected string array item, got Bool(true)"));
827 }
828
829 #[test]
830 fn extract_u64_response_accepts_signed_and_unsigned_values() {
831 assert_eq!(
832 extract_u64_response(DtxPayload::Response(NSObject::Int(17))).unwrap(),
833 17
834 );
835 assert_eq!(
836 extract_u64_response(DtxPayload::Response(NSObject::Uint(42))).unwrap(),
837 42
838 );
839 }
840
841 #[test]
842 fn focus_element_rejects_short_platform_identifier() {
843 let err = FocusElement::from_event_payload(&json!({
844 "PlatformElementValue_v1": [1, 2, 3],
845 }))
846 .expect_err("short platform identifiers must fail");
847
848 assert!(err
849 .to_string()
850 .contains("platform element identifier too short: 3 bytes"));
851 }
852
853 #[test]
854 fn focus_element_try_from_event_payload_skips_metadata_only_events() {
855 let focus = FocusElement::try_from_event_payload(&json!({
856 "CaptionTextValue_v1": "",
857 "InspectorSectionsValue_v1": [],
858 "SpokenDescriptionValue_v1": ""
859 }))
860 .expect("metadata-only focus event should parse");
861
862 assert!(focus.is_none());
863 }
864
865 #[test]
866 fn extract_platform_element_bytes_finds_nested_value_recursively() {
867 let value = json!({
868 "Outer": {
869 "Inner": {
870 "PlatformElementValue_v1": [1, 2, 3, 4]
871 }
872 }
873 });
874
875 let object = value.as_object().expect("json object");
876 assert_eq!(
877 extract_platform_element_bytes(object),
878 Some(vec![1, 2, 3, 4])
879 );
880 }
881
882 #[test]
883 fn extract_platform_element_bytes_unwraps_nested_value_wrappers() {
884 let value = json!({
885 "ElementValue_v1": {
886 "Value": {
887 "Value": {
888 "PlatformElementValue_v1": {
889 "Value": [1, 2, 3, 4]
890 }
891 }
892 }
893 }
894 });
895
896 let object = value.as_object().expect("json object");
897 assert_eq!(
898 extract_platform_element_bytes(object),
899 Some(vec![1, 2, 3, 4])
900 );
901 }
902
903 #[test]
904 fn get_optional_string_unwraps_nested_value_wrappers() {
905 let value = json!({
906 "SpokenDescriptionValue_v1": {
907 "Value": "Play button"
908 }
909 });
910
911 let object = value.as_object().expect("json object");
912 assert_eq!(
913 get_optional_string(object, "SpokenDescriptionValue_v1"),
914 Some("Play button".to_string())
915 );
916 }
917
918 #[test]
919 fn passthrough_value_wraps_value_with_expected_marker() {
920 let wrapped = passthrough_value(Value::Boolean(true));
921 let dict = wrapped.as_dictionary().expect("wrapped dictionary");
922 assert_eq!(
923 dict.get("ObjectType").and_then(Value::as_string),
924 Some("passthrough")
925 );
926 assert_eq!(dict.get("Value").and_then(Value::as_boolean), Some(true));
927 }
928
929 #[test]
930 fn extract_audit_complete_issues_unwraps_single_value_wrapper() {
931 let payload = json!([
932 {
933 "value": [
934 {
935 "IssueClassificationValue_v1": 12
936 }
937 ]
938 }
939 ]);
940
941 assert_eq!(
942 extract_audit_complete_issues(&payload),
943 json!([
944 {
945 "IssueClassificationValue_v1": 12
946 }
947 ])
948 );
949 }
950}