@@ -32,6 +32,20 @@ const schema = z.object({
3232 'Automatically accept the Xcode license agreement if it has not been accepted yet. ' +
3333 'Runs `sudo xcodebuild -license accept`. Defaults to true.'
3434 ) ,
35+ downloadRuntimes : z
36+ . boolean ( )
37+ . optional ( )
38+ . describe (
39+ 'Automatically download missing simulator runtimes via `xcodebuild -downloadPlatform`. ' +
40+ 'Defaults to true. Set to false if you manage runtimes manually through Xcode.'
41+ ) ,
42+ destroyRuntimes : z
43+ . boolean ( )
44+ . optional ( )
45+ . describe (
46+ 'Delete simulator runtimes that are no longer used by any simulator when this resource is destroyed. ' +
47+ 'Defaults to false. Enable with caution — runtimes are several GB and take time to re-download.'
48+ ) ,
3549} ) ;
3650
3751export type IosSimulatorConfig = z . infer < typeof schema > ;
@@ -133,6 +147,8 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
133147 canModify : true ,
134148 } ,
135149 acceptLicense : { type : 'boolean' , setting : true , default : true } ,
150+ downloadRuntimes : { type : 'boolean' , setting : true , default : true } ,
151+ destroyRuntimes : { type : 'boolean' , setting : true , default : false } ,
136152 } ,
137153 } ;
138154 }
@@ -160,17 +176,32 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
160176 await this . acceptLicenseIfNeeded ( ) ;
161177 }
162178 await this . assertSimctlAvailable ( ) ;
163- await this . assertRuntimesAvailable ( plan . desiredConfig . simulators ?? [ ] ) ;
179+ const simulators = plan . desiredConfig . simulators ?? [ ] ;
180+ if ( plan . desiredConfig . downloadRuntimes !== false ) {
181+ await this . downloadMissingRuntimes ( simulators ) ;
182+ } else {
183+ await this . assertRuntimesAvailable ( simulators ) ;
184+ }
164185 const $ = getPty ( ) ;
165- for ( const sim of plan . desiredConfig . simulators ?? [ ] ) {
166- await $ . spawn (
186+ for ( const sim of simulators ) {
187+ const { status , data } = await $ . spawnSafe (
167188 `xcrun simctl create "${ sim . name } " "${ sim . deviceType } " "${ sim . runtime } "` ,
168189 { interactive : true } ,
169190 ) ;
191+ if ( status !== SpawnStatus . SUCCESS ) {
192+ if ( data . includes ( 'Invalid runtime' ) ) {
193+ throw new Error (
194+ `Runtime "${ sim . runtime } " is not installed or not available.\n` +
195+ 'Download it in Xcode → Settings → Platforms, or via:\n' +
196+ ` xcodebuild -downloadPlatform ${ runtimeToXcodebuildPlatform ( sim . runtime ) } ` ,
197+ ) ;
198+ }
199+ throw new Error ( `Failed to create simulator "${ sim . name } ": ${ data } ` ) ;
200+ }
170201 }
171202 }
172203
173- async modify ( pc : ParameterChange < IosSimulatorConfig > , _plan : ModifyPlan < IosSimulatorConfig > ) : Promise < void > {
204+ async modify ( pc : ParameterChange < IosSimulatorConfig > , plan : ModifyPlan < IosSimulatorConfig > ) : Promise < void > {
174205 if ( pc . name !== 'simulators' ) return ;
175206
176207 const $ = getPty ( ) ;
@@ -194,6 +225,11 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
194225 }
195226
196227 const toAdd = desired . filter ( ( d ) => ! previous . some ( ( p ) => p . name === d . name ) ) ;
228+ if ( toAdd . length > 0 && plan . desiredConfig . downloadRuntimes !== false ) {
229+ await this . downloadMissingRuntimes ( toAdd ) ;
230+ } else if ( toAdd . length > 0 ) {
231+ await this . assertRuntimesAvailable ( toAdd ) ;
232+ }
197233 for ( const sim of toAdd ) {
198234 await $ . spawn (
199235 `xcrun simctl create "${ sim . name } " "${ sim . deviceType } " "${ sim . runtime } "` ,
@@ -207,7 +243,10 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
207243 const allDevices = await this . listAllDevices ( ) ;
208244 if ( ! allDevices ) return ;
209245
210- for ( const sim of plan . currentConfig . simulators ?? [ ] ) {
246+ const simulatorsToDestroy = plan . currentConfig . simulators ?? [ ] ;
247+ const runtimesInUse = new Set ( simulatorsToDestroy . map ( ( s ) => s . runtime ) ) ;
248+
249+ for ( const sim of simulatorsToDestroy ) {
211250 for ( const devices of Object . values ( allDevices ) ) {
212251 const match = devices . find ( ( d ) => d . name === sim . name ) ;
213252 if ( match ) {
@@ -216,6 +255,10 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
216255 }
217256 }
218257 }
258+
259+ if ( plan . currentConfig . destroyRuntimes ) {
260+ await this . deleteOrphanedRuntimes ( runtimesInUse ) ;
261+ }
219262 }
220263
221264 private async assertSimctlAvailable ( ) : Promise < void > {
@@ -230,35 +273,67 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
230273 }
231274 }
232275
233- private async assertRuntimesAvailable ( simulators : SimulatorDeclaration [ ] ) : Promise < void > {
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 [ ] > {
234296 const $ = getPty ( ) ;
235297 const { status, data } = await $ . spawnSafe ( 'xcrun simctl list runtimes --json' ) ;
236- if ( status !== SpawnStatus . SUCCESS ) return ; // can't verify, let simctl fail with its own message
298+ if ( status !== SpawnStatus . SUCCESS ) return [ ] ;
237299
238- let availableRuntimes : Set < string > ;
300+ let allRuntimes : SimctlRuntime [ ] ;
239301 try {
240302 const parsed : SimctlRuntimesOutput = JSON . parse ( data ) ;
241- availableRuntimes = new Set (
242- parsed . runtimes . filter ( ( r ) => r . isAvailable ) . map ( ( r ) => r . identifier ) ,
243- ) ;
303+ allRuntimes = parsed . runtimes ;
244304 } catch {
245- return ;
305+ return [ ] ;
246306 }
247307
248- const missing = [ ...new Set ( simulators . map ( ( s ) => s . runtime ) ) ] . filter (
249- ( r ) => ! availableRuntimes . has ( r ) ,
250- ) ;
308+ const availableIds = new Set ( allRuntimes . filter ( ( r ) => r . isAvailable ) . map ( ( r ) => r . identifier ) ) ;
309+ const requiredRuntimes = [ ...new Set ( simulators . map ( ( s ) => s . runtime ) ) ] ;
310+ return requiredRuntimes . filter ( ( r ) => ! availableIds . has ( r ) ) ;
311+ }
251312
252- if ( missing . length > 0 ) {
253- throw new Error (
254- `The following simulator runtime${ missing . length > 1 ? 's are' : ' is' } not installed:\n` +
255- missing . map ( ( r ) => ` ${ r } ` ) . join ( '\n' ) + '\n' +
256- 'Download runtimes in Xcode → Settings → Platforms, or via:\n' +
257- missing . map ( ( r ) => ` xcodebuild -downloadPlatform ${ runtimeToXcodebuildPlatform ( r ) } ` ) . join ( '\n' ) ,
258- ) ;
313+ private async downloadMissingRuntimes ( simulators : SimulatorDeclaration [ ] ) : Promise < void > {
314+ const missing = await this . getMissingRuntimes ( simulators ) ;
315+ if ( missing . length === 0 ) return ;
316+
317+ const $ = getPty ( ) ;
318+ const platforms = [ ...new Set ( missing . map ( runtimeToXcodebuildPlatform ) ) ] ;
319+ for ( const platform of platforms ) {
320+ await $ . spawn ( `xcodebuild -downloadPlatform ${ platform } ` , { stdin : true } ) ;
259321 }
260322 }
261323
324+ private async assertRuntimesAvailable ( simulators : SimulatorDeclaration [ ] ) : Promise < void > {
325+ const missing = await this . getMissingRuntimes ( simulators ) ;
326+ if ( missing . length === 0 ) return ;
327+
328+ const lines : string [ ] = [
329+ `The following simulator runtime${ missing . length > 1 ? 's are' : ' is' } not installed or not available:` ,
330+ ...missing . map ( ( r ) => ` ${ r } ` ) ,
331+ 'Download runtimes in Xcode → Settings → Platforms, or via:' ,
332+ ...missing . map ( ( r ) => ` xcodebuild -downloadPlatform ${ runtimeToXcodebuildPlatform ( r ) } ` ) ,
333+ ] ;
334+ throw new Error ( lines . join ( '\n' ) ) ;
335+ }
336+
262337 private async acceptLicenseIfNeeded ( ) : Promise < void > {
263338 const $ = getPty ( ) ;
264339 const { status } = await $ . spawnSafe ( 'xcodebuild -license status' ) ;
0 commit comments