@@ -16,6 +16,43 @@ import {AxAuditDtxTransport, type InvokeOptions} from './dtx-transport.js';
1616
1717const log = getLogger ( 'AccessibilityAudit' ) ;
1818
19+ /** `SettingTypeValue_v1` for a slider setting, e.g. `DYNAMIC_TYPE`. */
20+ const SETTING_TYPE_SLIDER = 2 ;
21+ /** `SettingTypeValue_v1` for an on/off toggle — every setting bar the slider. */
22+ const SETTING_TYPE_TOGGLE = 3 ;
23+
24+ /**
25+ * What each setting type accepts, keyed by `SettingTypeValue_v1`.
26+ *
27+ * The device discovers its own catalogue at runtime, so the rules are keyed by
28+ * type rather than by setting: supporting a new type is one entry here, and an
29+ * unrecognised type has no entry and is refused.
30+ */
31+ const SETTING_VALIDATORS = new Map < number , ( value : boolean | number , identifier : string ) => void > ( [
32+ [
33+ SETTING_TYPE_SLIDER ,
34+ ( value , identifier ) => {
35+ // The device clamps out-of-range input to 1 — including negatives — so a
36+ // typo would silently max the setting out rather than fail.
37+ if ( typeof value !== 'number' || ! Number . isFinite ( value ) || value < 0 || value > 1 ) {
38+ throw new Error (
39+ `Setting "${ identifier } " is a slider and expects a number between 0 and 1, got ${ JSON . stringify ( value ) } ` ,
40+ ) ;
41+ }
42+ } ,
43+ ] ,
44+ [
45+ SETTING_TYPE_TOGGLE ,
46+ ( value , identifier ) => {
47+ // A toggle takes any truthy value on the wire (0.5 reads back as true),
48+ // which is too loose to be useful in a typed API.
49+ if ( typeof value !== 'boolean' ) {
50+ throw new Error ( `Setting "${ identifier } " is a toggle and expects a boolean, got ${ JSON . stringify ( value ) } ` ) ;
51+ }
52+ } ,
53+ ] ,
54+ ] ) ;
55+
1956/** `deviceInspectorSetMonitoredEventType:` value that reports focus changes. */
2057const MONITORED_EVENT_FOCUS = 2 ;
2158/** Value that disarms monitoring. */
@@ -72,6 +109,14 @@ export class AccessibilityAuditService {
72109 /** Guards {@link runAudit} against overlapping calls on one instance. */
73110 private auditInFlight = false ;
74111
112+ /**
113+ * Identifier to `SettingTypeValue_v1`, read once per connection.
114+ *
115+ * Only a setting's *value* changes; its identity, type and tick marks are
116+ * fixed for the device, so repeated writes need not re-read the catalogue.
117+ */
118+ private settingTypes : Map < string , number | undefined > | undefined ;
119+
75120 private constructor ( private readonly transport : AxAuditDtxTransport ) { }
76121
77122 /**
@@ -83,7 +128,7 @@ export class AccessibilityAuditService {
83128 return new AccessibilityAuditService ( await AxAuditDtxTransport . connect ( udid ) ) ;
84129 }
85130
86- /** The daemon's API version (26 on iOS 27.0). */
131+ /** The daemon's API version (26 on iOS 26.6 and 27.0). */
87132 async getApiVersion ( options ?: InvokeOptions ) : Promise < number > {
88133 const value = await this . transport . invoke ( 'deviceApiVersion' , null , options ) ;
89134 if ( typeof value !== 'number' ) {
@@ -116,6 +161,68 @@ export class AccessibilityAuditService {
116161 return raw . map ( toDeviceSetting ) ;
117162 }
118163
164+ /**
165+ * Writes one accessibility setting. Applies system-wide, but only while this
166+ * service is open — closing it reverts the setting.
167+ *
168+ * Sliders snap to the device's tick marks (`DYNAMIC_TYPE` has 12, so `0.5`
169+ * reads back as `0.545`). The device acknowledges a write in a few
170+ * milliseconds but commits it asynchronously, so back-to-back writes to the
171+ * same setting are dropped — ~1.5s apart was reliable in testing, and the
172+ * exact minimum is not published.
173+ *
174+ * @param identifier A setting identifier, e.g. `INVERT_COLORS`, `DYNAMIC_TYPE`.
175+ * @param value `boolean` for a toggle, or a number in 0..1 for a slider.
176+ * @param options Reply timeout.
177+ * @throws If the identifier is unknown, or the value is wrong for its type.
178+ */
179+ async setAccessibilitySetting ( identifier : string , value : boolean | number , options ?: InvokeOptions ) : Promise < void > {
180+ const schema = await this . loadSettingTypes ( options ) ;
181+ if ( ! schema . has ( identifier ) ) {
182+ throw new Error (
183+ `Unknown accessibility setting "${ identifier } "; the device supports: ${ [ ...schema . keys ( ) ] . join ( ', ' ) } ` ,
184+ ) ;
185+ }
186+ const settingType = schema . get ( identifier ) ;
187+ const validate = settingType === undefined ? undefined : SETTING_VALIDATORS . get ( settingType ) ;
188+ if ( ! validate ) {
189+ // Only slider and toggle have been observed (iOS 26.6 and 27.0); refuse
190+ // rather than guess at the value another type expects.
191+ throw new Error ( `Setting "${ identifier } " has unsupported type ${ JSON . stringify ( settingType ) } ` ) ;
192+ }
193+ validate ( value , identifier ) ;
194+
195+ const aux = new MessageAux ( ) ;
196+ aux . appendObj ( serializeAxSetting ( identifier ) ) ;
197+ aux . appendObj ( { ObjectType : 'passthrough' , Value : value } ) ;
198+ // The daemon answers (with null), so awaiting it confirms the write landed
199+ // before a caller screenshots or re-audits.
200+ await this . transport . invoke ( 'deviceUpdateAccessibilitySetting:withValue:' , aux , options ) ;
201+ }
202+
203+ /** Reads the setting catalogue once and remembers each identifier's type. */
204+ private async loadSettingTypes ( options ?: InvokeOptions ) : Promise < Map < string , number | undefined > > {
205+ if ( ! this . settingTypes ) {
206+ const settings = await this . getAccessibilitySettings ( options ) ;
207+ this . settingTypes = new Map ( settings . map ( ( entry ) => [ entry . identifier , entry . settingType ] ) ) ;
208+ }
209+ return this . settingTypes ;
210+ }
211+
212+ /**
213+ * Resets the device's stored accessibility settings to their defaults.
214+ *
215+ * **Persistent and destructive** — unlike {@link setAccessibilitySetting} this
216+ * survives disconnect and discards the user's own choices. Session overrides
217+ * revert on close by themselves, so this is rarely the right cleanup. It also
218+ * drops overrides held by other connections.
219+ *
220+ * @param options Reply timeout.
221+ */
222+ async resetAccessibilitySettings ( options ?: InvokeOptions ) : Promise < void > {
223+ await this . transport . invoke ( 'deviceResetToDefaultAccessibilitySettings' , null , options ) ;
224+ }
225+
119226 /**
120227 * Runs the given accessibility audits on whatever the device is currently
121228 * showing and resolves with the issues found (empty when everything passes).
@@ -188,7 +295,7 @@ export class AccessibilityAuditService {
188295 * `hostInspectorCurrentElementChanged:` call, so that is what this reproduces:
189296 * arm, ask focus to report, wait for the push, disarm. Captured from a live
190297 * Inspector session — `deviceFetchElementAtNormalizedDeviceCoordinate:`
191- * returns `null` on iOS 27 no matter how it is called.
298+ * returns `null` on iOS 26.6 and 27.0 no matter how it is called.
192299 *
193300 * @param options Timeout, and whether to draw the on-device highlight.
194301 */
@@ -286,7 +393,7 @@ export class AccessibilityAuditService {
286393 /**
287394 * Returns one of the daemon's well-known elements.
288395 *
289- * Index `0` and `1` resolve on iOS 27 ; higher indices return `undefined`.
396+ * Index `0` and `1` resolve on iOS 26.6 and 27.0 ; higher return `undefined`.
290397 *
291398 * @param index Which special element to fetch.
292399 * @param options Reply timeout.
@@ -376,9 +483,26 @@ function asStringArray(value: unknown, selector: string): string[] {
376483 return value as string [ ] ;
377484}
378485
486+ /**
487+ * Builds the setting descriptor the daemon expects.
488+ *
489+ * Only the identifier is read — the device ignores the type, tick-mark and
490+ * enabled fields, verified by sending deliberately wrong ones — so this sends
491+ * the identifier alone rather than echoing a descriptor back.
492+ */
493+ export function serializeAxSetting ( identifier : string ) : Record < string , unknown > {
494+ return {
495+ ObjectType : 'AXAuditDeviceSetting_v1' ,
496+ Value : {
497+ ObjectType : 'passthrough' ,
498+ Value : { IdentiifierValue_v1 : { ObjectType : 'passthrough' , Value : identifier } } ,
499+ } ,
500+ } ;
501+ }
502+
379503/** Maps one deserialized `AXAuditDeviceSetting_v1` to the cleaned shape. */
380504function toDeviceSetting ( raw : unknown ) : AxDeviceSetting {
381- if ( typeof raw !== 'object' || raw === null ) {
505+ if ( ! util . isPlainObject ( raw ) ) {
382506 throw new Error ( `Malformed accessibility setting: ${ JSON . stringify ( raw ) ?. slice ( 0 , 120 ) } ` ) ;
383507 }
384508 const fields = raw as Record < string , unknown > ;
0 commit comments