Skip to main content

ios_core/services/accessibility_audit/
mod.rs

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, // slot timed out; keep iterating remaining slots
161                };
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    /// Navigate focus in the given direction and return the newly focused element.
488    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    /// Perform the Activate action on the currently focused element.
498    ///
499    /// `element_bytes` should be the raw platform identifier bytes from `FocusElement`.
500    pub async fn perform_action_activate(
501        &mut self,
502        element_bytes: &[u8],
503    ) -> Result<(), AccessibilityAuditError> {
504        self.ensure_ready().await?;
505
506        // Build AXAuditElement_v1 wrapper for the element
507        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        // Build AXAuditElementAttribute_v1 for Activate action
525        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        // Build empty value dict
570        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}