Skip to content

Commit 2617217

Browse files
committed
feat: add iOS simulator Face ID settings command support
1 parent 4c83f5a commit 2617217

9 files changed

Lines changed: 217 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ agent-device swipe 540 1500 540 500 120 --count 8 --pause-ms 30 --pattern ping-p
140140
- `alert`, `wait`, `screenshot`
141141
- `trace start`, `trace stop`
142142
- `settings wifi|airplane|location on|off`
143+
- `settings faceid match|nonmatch|enroll|unenroll` (aliases: `validate|unvalidate`, iOS simulator only)
143144
- `appstate`, `apps`, `devices`, `session list`
144145

145146
## iOS Snapshots

skills/agent-device/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,16 @@ agent-device settings airplane on
9090
agent-device settings airplane off
9191
agent-device settings location on
9292
agent-device settings location off
93+
agent-device settings faceid match
94+
agent-device settings faceid nonmatch
95+
agent-device settings faceid enroll
96+
agent-device settings faceid unenroll
9397
```
9498

9599
Note: iOS wifi/airplane toggles status bar indicators, not actual network state.
96100
Airplane off clears status bar overrides.
97101
iOS settings helpers are simulator-only.
102+
Face ID aliases: `validate` = `match`, `unvalidate` = `nonmatch`.
98103

99104
### App state
100105

src/daemon/handlers/__tests__/snapshot-handler.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,26 @@ test('settings rejects unsupported iOS physical devices', async () => {
9090
assert.match(response.error.message, /settings is not supported/i);
9191
}
9292
});
93+
94+
test('settings validates faceid aliases in usage hint', async () => {
95+
const sessionStore = makeSessionStore();
96+
const response = await handleSnapshotCommands({
97+
req: {
98+
token: 't',
99+
session: 'default',
100+
command: 'settings',
101+
positionals: [],
102+
flags: {},
103+
},
104+
sessionName: 'default',
105+
logPath: '/tmp/daemon.log',
106+
sessionStore,
107+
});
108+
109+
assert.ok(response);
110+
assert.equal(response?.ok, false);
111+
if (response && !response.ok) {
112+
assert.equal(response.error.code, 'INVALID_ARGS');
113+
assert.match(response.error.message, /validate\|unvalidate/);
114+
}
115+
});

src/daemon/handlers/snapshot.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,8 @@ export async function handleSnapshotCommands(params: {
311311
ok: false,
312312
error: {
313313
code: 'INVALID_ARGS',
314-
message: 'settings requires <wifi|airplane|location> <on|off>',
314+
message:
315+
'settings requires <wifi|airplane|location> <on|off> or faceid <match|nonmatch|enroll|unenroll|validate|unvalidate>',
315316
},
316317
};
317318
}

src/platforms/ios/__tests__/index.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import assert from 'node:assert/strict';
33
import { promises as fs } from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6-
import { listIosApps, openIosApp, parseIosDeviceAppsPayload, reinstallIosApp, resolveIosApp } from '../index.ts';
6+
import {
7+
listIosApps,
8+
openIosApp,
9+
parseIosDeviceAppsPayload,
10+
reinstallIosApp,
11+
resolveIosApp,
12+
setIosSetting,
13+
} from '../index.ts';
714
import type { DeviceInfo } from '../../../utils/device.ts';
815
import { AppError } from '../../../utils/errors.ts';
916

@@ -452,3 +459,81 @@ test('listIosApps applies user-installed filter on simulator', async () => {
452459
await fs.rm(tmpDir, { recursive: true, force: true });
453460
}
454461
});
462+
463+
test('setIosSetting faceid validate uses simctl biometric match', async () => {
464+
await withMockedXcrun(
465+
'agent-device-ios-faceid-match-test-',
466+
`#!/bin/sh
467+
printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
468+
printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
469+
if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then
470+
cat <<'JSON'
471+
{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}}
472+
JSON
473+
exit 0
474+
fi
475+
if [ "$1" = "simctl" ] && [ "$2" = "biometric" ] && [ "$3" = "sim-1" ] && [ "$4" = "match" ] && [ "$5" = "face" ]; then
476+
exit 0
477+
fi
478+
echo "unexpected xcrun args: $@" >&2
479+
exit 1
480+
`,
481+
async ({ argsLogPath }) => {
482+
const device: DeviceInfo = {
483+
platform: 'ios',
484+
id: 'sim-1',
485+
name: 'iPhone Sim',
486+
kind: 'simulator',
487+
booted: true,
488+
};
489+
await setIosSetting(device, 'faceid', 'validate');
490+
const lines = (await fs.readFile(argsLogPath, 'utf8'))
491+
.trim()
492+
.split('\n')
493+
.filter(Boolean);
494+
const logged = lines.join(' ');
495+
assert.match(logged, /simctl biometric sim-1 match face/);
496+
},
497+
);
498+
});
499+
500+
test('setIosSetting faceid retries alternate biometric argument order', async () => {
501+
await withMockedXcrun(
502+
'agent-device-ios-faceid-fallback-test-',
503+
`#!/bin/sh
504+
printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
505+
printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
506+
if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then
507+
cat <<'JSON'
508+
{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}}
509+
JSON
510+
exit 0
511+
fi
512+
if [ "$1" = "simctl" ] && [ "$2" = "biometric" ] && [ "$3" = "sim-1" ] && [ "$4" = "match" ] && [ "$5" = "face" ]; then
513+
exit 2
514+
fi
515+
if [ "$1" = "simctl" ] && [ "$2" = "biometric" ] && [ "$3" = "match" ] && [ "$4" = "sim-1" ] && [ "$5" = "face" ]; then
516+
exit 0
517+
fi
518+
echo "unexpected xcrun args: $@" >&2
519+
exit 1
520+
`,
521+
async ({ argsLogPath }) => {
522+
const device: DeviceInfo = {
523+
platform: 'ios',
524+
id: 'sim-1',
525+
name: 'iPhone Sim',
526+
kind: 'simulator',
527+
booted: true,
528+
};
529+
await setIosSetting(device, 'faceid', 'match');
530+
const lines = (await fs.readFile(argsLogPath, 'utf8'))
531+
.trim()
532+
.split('\n')
533+
.filter(Boolean);
534+
const logged = lines.join(' ');
535+
assert.match(logged, /simctl biometric sim-1 match face/);
536+
assert.match(logged, /simctl biometric match sim-1 face/);
537+
},
538+
);
539+
});

src/platforms/ios/apps.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,15 +225,16 @@ export async function setIosSetting(
225225
ensureSimulator(device, 'settings');
226226
await ensureBootedSimulator(device);
227227
const normalized = setting.toLowerCase();
228-
const enabled = parseSettingState(state);
229228

230229
switch (normalized) {
231230
case 'wifi': {
231+
const enabled = parseSettingState(state);
232232
const mode = enabled ? 'active' : 'failed';
233233
await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'override', '--wifiMode', mode]);
234234
return;
235235
}
236236
case 'airplane': {
237+
const enabled = parseSettingState(state);
237238
if (enabled) {
238239
await runCmd('xcrun', [
239240
'simctl',
@@ -259,13 +260,19 @@ export async function setIosSetting(
259260
return;
260261
}
261262
case 'location': {
263+
const enabled = parseSettingState(state);
262264
if (!appBundleId) {
263265
throw new AppError('INVALID_ARGS', 'location setting requires an active app in session');
264266
}
265267
const action = enabled ? 'grant' : 'revoke';
266268
await runCmd('xcrun', ['simctl', 'privacy', device.id, action, 'location', appBundleId]);
267269
return;
268270
}
271+
case 'faceid': {
272+
const action = parseFaceIdAction(state);
273+
await runFaceIdSimctlCommand(device.id, action);
274+
return;
275+
}
269276
default:
270277
throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`);
271278
}
@@ -328,6 +335,83 @@ function parseSettingState(state: string): boolean {
328335
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
329336
}
330337

338+
type FaceIdAction = 'match' | 'nonmatch' | 'enroll' | 'unenroll';
339+
340+
function parseFaceIdAction(state: string): FaceIdAction {
341+
const normalized = state.trim().toLowerCase();
342+
if (normalized === 'match' || normalized === 'validate') return 'match';
343+
if (normalized === 'nonmatch' || normalized === 'nomatch' || normalized === 'unvalidate') {
344+
return 'nonmatch';
345+
}
346+
if (normalized === 'enroll') return 'enroll';
347+
if (normalized === 'unenroll') return 'unenroll';
348+
throw new AppError(
349+
'INVALID_ARGS',
350+
`Invalid faceid state: ${state}. Use match|nonmatch|enroll|unenroll (aliases: validate|unvalidate).`,
351+
);
352+
}
353+
354+
async function runFaceIdSimctlCommand(deviceId: string, action: FaceIdAction): Promise<void> {
355+
const attempts = biometricCommandAttempts(deviceId, action);
356+
const failures: Array<{ args: string[]; stderr: string; stdout: string; exitCode: number }> = [];
357+
358+
for (const args of attempts) {
359+
const result = await runCmd('xcrun', args, { allowFailure: true });
360+
if (result.exitCode === 0) return;
361+
failures.push({
362+
args,
363+
stderr: result.stderr,
364+
stdout: result.stdout,
365+
exitCode: result.exitCode,
366+
});
367+
}
368+
369+
throw new AppError(
370+
'COMMAND_FAILED',
371+
'simctl biometric command failed. Ensure your Xcode Simulator runtime supports Face ID control.',
372+
{
373+
deviceId,
374+
action,
375+
attempts: failures.map((failure) => ({
376+
args: failure.args.join(' '),
377+
exitCode: failure.exitCode,
378+
stderr: failure.stderr.slice(0, 400),
379+
})),
380+
},
381+
);
382+
}
383+
384+
function biometricCommandAttempts(deviceId: string, action: FaceIdAction): string[][] {
385+
switch (action) {
386+
case 'match':
387+
return [
388+
['simctl', 'biometric', deviceId, 'match', 'face'],
389+
['simctl', 'biometric', 'match', deviceId, 'face'],
390+
];
391+
case 'nonmatch':
392+
return [
393+
['simctl', 'biometric', deviceId, 'nonmatch', 'face'],
394+
['simctl', 'biometric', deviceId, 'nomatch', 'face'],
395+
['simctl', 'biometric', 'nonmatch', deviceId, 'face'],
396+
['simctl', 'biometric', 'nomatch', deviceId, 'face'],
397+
];
398+
case 'enroll':
399+
return [
400+
['simctl', 'biometric', deviceId, 'enroll', 'yes'],
401+
['simctl', 'biometric', deviceId, 'enroll', '1'],
402+
['simctl', 'biometric', 'enroll', deviceId, 'yes'],
403+
['simctl', 'biometric', 'enroll', deviceId, '1'],
404+
];
405+
case 'unenroll':
406+
return [
407+
['simctl', 'biometric', deviceId, 'enroll', 'no'],
408+
['simctl', 'biometric', deviceId, 'enroll', '0'],
409+
['simctl', 'biometric', 'enroll', deviceId, 'no'],
410+
['simctl', 'biometric', 'enroll', deviceId, '0'],
411+
];
412+
}
413+
}
414+
331415
function isTransientSimulatorLaunchFailure(error: unknown): boolean {
332416
if (!(error instanceof AppError)) return false;
333417
if (error.code !== 'COMMAND_FAILED') return false;

src/utils/__tests__/args.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ test('usage includes swipe and press series options', () => {
261261
assert.match(help, /swipe <x1> <y1> <x2> <y2>/);
262262
assert.match(help, /--pattern one-way\|ping-pong/);
263263
assert.match(help, /--interval-ms/);
264+
assert.match(help, /settings <wifi\|airplane\|location\|faceid>/);
264265
});
265266

266267
test('command usage shows command and global flags separately', () => {
@@ -280,3 +281,9 @@ test('command usage shows no command flags when unsupported', () => {
280281
assert.doesNotMatch(help, /Command flags:/);
281282
assert.match(help, /Global flags:/);
282283
});
284+
285+
test('settings usage documents faceid aliases', () => {
286+
const help = usageForCommand('settings');
287+
if (help === null) throw new Error('Expected command help text');
288+
assert.match(help, /match\|nonmatch\|enroll\|unenroll\|validate\|unvalidate/);
289+
});

src/utils/command-schema.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,9 @@ export const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
524524
allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS],
525525
},
526526
settings: {
527-
description: 'Toggle OS settings (simulators)',
527+
usageOverride:
528+
'settings <wifi|airplane|location|faceid> <on|off|match|nonmatch|enroll|unenroll|validate|unvalidate>',
529+
description: 'Toggle OS settings (simulators), including Face ID on iOS simulators',
528530
positionalArgs: ['setting', 'state'],
529531
allowedFlags: [],
530532
},

website/docs/docs/commands.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,14 @@ agent-device settings airplane on
122122
agent-device settings airplane off
123123
agent-device settings location on
124124
agent-device settings location off
125+
agent-device settings faceid match
126+
agent-device settings faceid nonmatch
127+
agent-device settings faceid enroll
128+
agent-device settings faceid unenroll
125129
```
126130

127131
- iOS `settings` support is simulator-only.
132+
- Face ID controls are iOS simulator-only and support aliases: `validate` = `match`, `unvalidate` = `nonmatch`.
128133

129134
## App state and app lists
130135

0 commit comments

Comments
 (0)