Skip to content

Commit 43ea5a2

Browse files
authored
Merge branch 'main' into fix/usbmux-decoder-byte-loss
2 parents 8d74136 + 8d6a87b commit 43ea5a2

5 files changed

Lines changed: 364 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"test:afc": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/afc.spec.js\"",
4747
"test:all": "node --enable-source-maps --experimental-test-module-mocks --test --test-timeout=60000 \"build/test/unit/**/*.spec.js\" \"build/test/integration/**/*.spec.js\"",
4848
"test:app-service": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/app-service.spec.js\"",
49-
"test:accessibility-audit": "node --enable-source-maps --test --test-timeout=120000 \"build/test/integration/accessibility-audit.spec.js\"",
49+
"test:accessibility-audit": "node --enable-source-maps --test --test-timeout=300000 \"build/test/integration/accessibility-audit.spec.js\"",
5050
"test:coredevice-device-info": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/device-info-coredevice.spec.js\"",
5151
"test:device-control": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/device-control.spec.js\"",
5252
"test:configuration": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/configuration.spec.js\"",

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export type {
7777
AxInspectedElement,
7878
AxInspectorSection,
7979
} from './services/ios/accessibility-audit/ax-element.js';
80+
export {serializeAxSetting} from './services/ios/accessibility-audit/index.js';
8081
export type {InspectOptions, RunAuditOptions, AxAuditIssue} from './services/ios/accessibility-audit/index.js';
8182
export {CoreDeviceInfoService} from './services/ios/device-info/index.js';
8283
export type {

src/services/ios/accessibility-audit/index.ts

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,43 @@ import {AxAuditDtxTransport, type InvokeOptions} from './dtx-transport.js';
1616

1717
const 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. */
2057
const 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. */
380504
function 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

Comments
 (0)