@@ -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+
331415function isTransientSimulatorLaunchFailure ( error : unknown ) : boolean {
332416 if ( ! ( error instanceof AppError ) ) return false ;
333417 if ( error . code !== 'COMMAND_FAILED' ) return false ;
0 commit comments