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