@@ -54,6 +54,7 @@ interface SimDevice {
5454 udid : string ;
5555 name : string ;
5656 state : string ;
57+ isAvailable : boolean ;
5758 deviceTypeIdentifier : string ;
5859}
5960
@@ -71,6 +72,7 @@ interface SimctlRuntimesOutput {
7172 runtimes : SimctlRuntime [ ] ;
7273}
7374
75+
7476const defaultConfig : Partial < IosSimulatorConfig > & { os : any } = {
7577 simulators : [ ] ,
7678 os : [ 'macOS' ] ,
@@ -122,6 +124,20 @@ function runtimeToXcodebuildPlatform(runtimeId: string): string {
122124 return match ? match [ 1 ] : runtimeId ;
123125}
124126
127+ // com.apple.CoreSimulator.SimRuntime.iOS-26-0 → "com.apple.CoreSimulator.SimRuntime.iOS-26"
128+ function runtimeMajorPrefix ( runtimeId : string ) : string {
129+ // Strip the patch version component (last -N segment) to get a major-match prefix.
130+ // iOS-26-0 and iOS-26-3 both share prefix "...iOS-26".
131+ return runtimeId . replace ( / - \d + $ / , '' ) ;
132+ }
133+
134+ // Two runtime IDs match if they are equal or share the same major-version prefix.
135+ // Allows iOS-26-0 (declared) to match iOS-26-3 (installed).
136+ function runtimesMatch ( a : string , b : string ) : boolean {
137+ if ( a === b ) return true ;
138+ return runtimeMajorPrefix ( a ) === runtimeMajorPrefix ( b ) ;
139+ }
140+
125141export class IosSimulatorResource extends Resource < IosSimulatorConfig > {
126142 getSettings ( ) : ResourceSettings < IosSimulatorConfig > {
127143 return {
@@ -141,7 +157,7 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
141157 isElementEqual : ( a , b ) =>
142158 a . name === b . name &&
143159 a . deviceType === b . deviceType &&
144- a . runtime === b . runtime ,
160+ runtimesMatch ( a . runtime , b . runtime ) ,
145161 filterInStatelessMode : ( desired , current ) =>
146162 current . filter ( ( c ) => desired . some ( ( d ) => d . name === c . name ) ) ,
147163 canModify : true ,
@@ -154,21 +170,7 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
154170 }
155171
156172 async refresh ( ) : Promise < Partial < IosSimulatorConfig > | null > {
157- const allDevices = await this . listAllDevices ( ) ;
158- if ( ! allDevices ) return null ;
159-
160- const simulators : SimulatorDeclaration [ ] = [ ] ;
161- for ( const [ runtimeId , devices ] of Object . entries ( allDevices ) ) {
162- for ( const device of devices ) {
163- simulators . push ( {
164- name : device . name ,
165- deviceType : device . deviceTypeIdentifier ,
166- runtime : runtimeId ,
167- } ) ;
168- }
169- }
170-
171- return simulators . length > 0 ? { simulators } : null ;
173+ return null ;
172174 }
173175
174176 async create ( plan : CreatePlan < IosSimulatorConfig > ) : Promise < void > {
@@ -182,10 +184,17 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
182184 } else {
183185 await this . assertRuntimesAvailable ( simulators ) ;
184186 }
187+ const available = await this . listAvailableRuntimes ( ) ;
188+ const existingDevices = await this . listAllDevices ( ) ?? { } ;
189+ const existingNames = new Set (
190+ Object . values ( existingDevices ) . flat ( ) . map ( ( d ) => d . name ) ,
191+ ) ;
185192 const $ = getPty ( ) ;
186193 for ( const sim of simulators ) {
194+ if ( existingNames . has ( sim . name ) ) continue ;
195+ const runtimeId = this . resolveRuntimeId ( sim . runtime , available ) ;
187196 const { status, data } = await $ . spawnSafe (
188- `xcrun simctl create "${ sim . name } " "${ sim . deviceType } " "${ sim . runtime } "` ,
197+ `xcrun simctl create "${ sim . name } " "${ sim . deviceType } " "${ runtimeId } "` ,
189198 { interactive : true } ,
190199 ) ;
191200 if ( status !== SpawnStatus . SUCCESS ) {
@@ -230,9 +239,11 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
230239 } else if ( toAdd . length > 0 ) {
231240 await this . assertRuntimesAvailable ( toAdd ) ;
232241 }
242+ const available = await this . listAvailableRuntimes ( ) ;
233243 for ( const sim of toAdd ) {
244+ const runtimeId = this . resolveRuntimeId ( sim . runtime , available ) ;
234245 await $ . spawn (
235- `xcrun simctl create "${ sim . name } " "${ sim . deviceType } " "${ sim . runtime } "` ,
246+ `xcrun simctl create "${ sim . name } " "${ sim . deviceType } " "${ runtimeId } "` ,
236247 { interactive : true } ,
237248 ) ;
238249 }
@@ -273,41 +284,44 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
273284 }
274285 }
275286
276- private async deleteOrphanedRuntimes ( candidateRuntimes : Set < string > ) : Promise < void > {
277- if ( candidateRuntimes . size === 0 ) return ;
278-
279- const allDevices = await this . listAllDevices ( ) ;
280- const stillInUse = new Set < string > ( ) ;
281- if ( allDevices ) {
282- for ( const [ runtimeId , devices ] of Object . entries ( allDevices ) ) {
283- if ( devices . length > 0 ) stillInUse . add ( runtimeId ) ;
284- }
285- }
286-
287- const $ = getPty ( ) ;
288- for ( const runtimeId of candidateRuntimes ) {
289- if ( ! stillInUse . has ( runtimeId ) ) {
290- await $ . spawnSafe ( `xcrun simctl runtime delete "${ runtimeId } "` ) ;
291- }
292- }
293- }
294-
295- private async getMissingRuntimes ( simulators : SimulatorDeclaration [ ] ) : Promise < string [ ] > {
287+ private async listAvailableRuntimes ( ) : Promise < SimctlRuntime [ ] > {
296288 const $ = getPty ( ) ;
297289 const { status, data } = await $ . spawnSafe ( 'xcrun simctl list runtimes --json' ) ;
298290 if ( status !== SpawnStatus . SUCCESS ) return [ ] ;
299-
300- let allRuntimes : SimctlRuntime [ ] ;
301291 try {
302292 const parsed : SimctlRuntimesOutput = JSON . parse ( data ) ;
303- allRuntimes = parsed . runtimes ;
293+ return parsed . runtimes . filter ( ( r ) => r . isAvailable ) ;
304294 } catch {
305295 return [ ] ;
306296 }
297+ }
298+
299+ // Resolve a declared runtime ID to the actual available one.
300+ // If the exact ID is available, return it unchanged.
301+ // Otherwise fall back to the highest-versioned available runtime sharing the same major prefix
302+ // (e.g. iOS-26-0 → iOS-26-3 when only iOS 26.3 is installed).
303+ private resolveRuntimeId ( declared : string , available : SimctlRuntime [ ] ) : string {
304+ if ( available . some ( ( r ) => r . identifier === declared ) ) return declared ;
305+
306+ const prefix = runtimeMajorPrefix ( declared ) ;
307+ const candidates = available . filter ( ( r ) => r . identifier . startsWith ( prefix ) ) ;
308+ if ( candidates . length === 0 ) return declared ;
309+
310+ // Pick the lexicographically highest patch version
311+ candidates . sort ( ( a , b ) => b . identifier . localeCompare ( a . identifier ) ) ;
312+ return candidates [ 0 ] . identifier ;
313+ }
307314
308- const availableIds = new Set ( allRuntimes . filter ( ( r ) => r . isAvailable ) . map ( ( r ) => r . identifier ) ) ;
315+ private async getMissingRuntimes ( simulators : SimulatorDeclaration [ ] ) : Promise < string [ ] > {
316+ const available = await this . listAvailableRuntimes ( ) ;
317+ const availableIds = new Set ( available . map ( ( r ) => r . identifier ) ) ;
309318 const requiredRuntimes = [ ...new Set ( simulators . map ( ( s ) => s . runtime ) ) ] ;
310- return requiredRuntimes . filter ( ( r ) => ! availableIds . has ( r ) ) ;
319+ return requiredRuntimes . filter ( ( declared ) => {
320+ if ( availableIds . has ( declared ) ) return false ;
321+ // Also consider it present if a same-major-version runtime is available
322+ const prefix = runtimeMajorPrefix ( declared ) ;
323+ return ! available . some ( ( r ) => r . identifier . startsWith ( prefix ) ) ;
324+ } ) ;
311325 }
312326
313327 private async downloadMissingRuntimes ( simulators : SimulatorDeclaration [ ] ) : Promise < void > {
@@ -334,6 +348,25 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
334348 throw new Error ( lines . join ( '\n' ) ) ;
335349 }
336350
351+ private async deleteOrphanedRuntimes ( candidateRuntimes : Set < string > ) : Promise < void > {
352+ if ( candidateRuntimes . size === 0 ) return ;
353+
354+ const allDevices = await this . listAllDevices ( ) ;
355+ const stillInUse = new Set < string > ( ) ;
356+ if ( allDevices ) {
357+ for ( const [ runtimeId , devices ] of Object . entries ( allDevices ) ) {
358+ if ( devices . length > 0 ) stillInUse . add ( runtimeId ) ;
359+ }
360+ }
361+
362+ const $ = getPty ( ) ;
363+ for ( const runtimeId of candidateRuntimes ) {
364+ if ( ! stillInUse . has ( runtimeId ) ) {
365+ await $ . spawnSafe ( `xcrun simctl runtime delete "${ runtimeId } "` ) ;
366+ }
367+ }
368+ }
369+
337370 private async acceptLicenseIfNeeded ( ) : Promise < void > {
338371 const $ = getPty ( ) ;
339372 const { status } = await $ . spawnSafe ( 'xcodebuild -license status' ) ;
0 commit comments