Skip to content

Commit 0418b0c

Browse files
authored
feat: add Face ID simulator controls to settings command (#75)
* feat: add iOS simulator Face ID settings command support * fix: remove faceid aliases and standardize docs
1 parent 4c83f5a commit 0418b0c

9 files changed

Lines changed: 219 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` (iOS simulator only)
143144
- `appstate`, `apps`, `devices`, `session list`
144145

145146
## iOS Snapshots

skills/agent-device/SKILL.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,17 @@ 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+
Use `match`/`nonmatch` as the canonical command values.
103+
Think of them as validate/invalidate outcomes when describing intent.
98104

99105
### App state
100106

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,27 @@ 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 usage hint documents canonical faceid states', 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, /match\|nonmatch\|enroll\|unenroll/);
114+
assert.doesNotMatch(response.error.message, /validate\|unvalidate/);
115+
}
116+
});

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

src/utils/__tests__/args.test.ts

Lines changed: 8 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,10 @@ 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 canonical faceid states', () => {
286+
const help = usageForCommand('settings');
287+
if (help === null) throw new Error('Expected command help text');
288+
assert.match(help, /match\|nonmatch\|enroll\|unenroll/);
289+
assert.doesNotMatch(help, /validate\|unvalidate/);
290+
});

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>',
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,15 @@ 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.
133+
- Use `match`/`nonmatch` to simulate valid/invalid Face ID outcomes.
128134

129135
## App state and app lists
130136

0 commit comments

Comments
 (0)