@@ -38,9 +38,6 @@ public class RuntimeInstaller {
38
38
}
39
39
}
40
40
41
-
42
-
43
-
44
41
installed. forEach { runtime in
45
42
let resolvedBetaNumber = downloadablesResponse. sdkToSeedMappings. first {
46
43
$0. buildUpdate == runtime. build
@@ -102,22 +99,27 @@ public class RuntimeInstaller {
102
99
public func downloadAndInstallRuntime( identifier: String , to destinationDirectory: Path , with downloader: Downloader , shouldDelete: Bool ) async throws {
103
100
let matchedRuntime = try await getMatchingRuntime ( identifier: identifier)
104
101
105
- if matchedRuntime. contentType == . package && !Current. shell. isRoot ( ) {
106
- throw Error . rootNeeded
102
+ let deleteIfNeeded : ( URL ) -> Void = { dmgUrl in
103
+ if shouldDelete {
104
+ Current . logging. log ( " Deleting Archive " )
105
+ try ? Current . files. removeItem ( at: dmgUrl)
106
+ }
107
107
}
108
108
109
- let dmgUrl = try await downloadOrUseExistingArchive ( runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
110
109
switch matchedRuntime. contentType {
111
- case . package :
112
- try await installFromPackage ( dmgUrl: dmgUrl, runtime: matchedRuntime)
113
- case . diskImage:
114
- try await installFromImage ( dmgUrl: dmgUrl)
115
- case . cryptexDiskImage:
116
- throw Error . unsupportedCryptexDiskImage
117
- }
118
- if shouldDelete {
119
- Current . logging. log ( " Deleting Archive " )
120
- try ? Current . files. removeItem ( at: dmgUrl)
110
+ case . package :
111
+ guard Current . shell. isRoot ( ) else {
112
+ throw Error . rootNeeded
113
+ }
114
+ let dmgUrl = try await downloadOrUseExistingArchive ( runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
115
+ try await installFromPackage ( dmgUrl: dmgUrl, runtime: matchedRuntime)
116
+ deleteIfNeeded ( dmgUrl)
117
+ case . diskImage:
118
+ let dmgUrl = try await downloadOrUseExistingArchive ( runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
119
+ try await installFromImage ( dmgUrl: dmgUrl)
120
+ deleteIfNeeded ( dmgUrl)
121
+ case . cryptexDiskImage:
122
+ try await downloadAndInstallUsingXcodeBuild ( runtime: matchedRuntime)
121
123
}
122
124
}
123
125
@@ -186,7 +188,7 @@ public class RuntimeInstaller {
186
188
@MainActor
187
189
public func downloadOrUseExistingArchive( runtime: DownloadableRuntime , to destinationDirectory: Path , downloader: Downloader ) async throws -> URL {
188
190
guard let source = runtime. source else {
189
- throw Error . missingRuntimeSource ( runtime. identifier )
191
+ throw Error . missingRuntimeSource ( runtime. visibleIdentifier )
190
192
}
191
193
let url = URL ( string: source) !
192
194
let destination = destinationDirectory/ url. lastPathComponent
@@ -224,6 +226,124 @@ public class RuntimeInstaller {
224
226
destination. setCurrentUserAsOwner ( )
225
227
return result
226
228
}
229
+
230
+ // MARK: Xcode 16.1 Runtime installation helpers
231
+ /// Downloads and installs the runtime using xcodebuild, requires Xcode 16.1+ to download a runtime using a given directory
232
+ /// - Parameters:
233
+ /// - runtime: The runtime to download and install to identify the platform and version numbers
234
+ private func downloadAndInstallUsingXcodeBuild( runtime: DownloadableRuntime ) async throws {
235
+
236
+ // Make sure that we are using a version of xcode that supports this
237
+ try await ensureSelectedXcodeVersionForDownload ( )
238
+
239
+ // Kick off the download/install process and get an async stream of the progress
240
+ let downloadStream = createXcodebuildDownloadStream ( runtime: runtime)
241
+
242
+ // Observe the progress and update the console from it
243
+ for try await progress in downloadStream {
244
+ let formatter = NumberFormatter ( numberStyle: . percent)
245
+ guard Current . shell. isatty ( ) else { return }
246
+ // These escape codes move up a line and then clear to the end
247
+ Current . logging. log ( " \u{1B} [1A \u{1B} [KDownloading Runtime \( runtime. visibleIdentifier) : \( formatter. string ( from: progress. fractionCompleted) !) " )
248
+ }
249
+ }
250
+
251
+ /// Checks the existing `xcodebuild -version` to ensure that the version is appropriate to use for downloading the cryptex style 16.1+ downloads
252
+ /// otherwise will throw an error
253
+ private func ensureSelectedXcodeVersionForDownload( ) async throws {
254
+ let xcodeBuildPath = Path . root. usr. bin. join ( " xcodebuild " )
255
+ let versionString = try await Process . run ( xcodeBuildPath, " -version " ) . async ( )
256
+ let versionPattern = #"Xcode (\d+\.\d+)"#
257
+ let versionRegex = try NSRegularExpression ( pattern: versionPattern)
258
+
259
+ // parse out the version string (e.g. 16.1) from the xcodebuild version command and convert it to a `Version`
260
+ guard let match = versionRegex. firstMatch ( in: versionString. out, range: NSRange ( versionString. out. startIndex... , in: versionString. out) ) ,
261
+ let versionRange = Range ( match. range ( at: 1 ) , in: versionString. out) ,
262
+ let version = Version ( tolerant: String ( versionString. out [ versionRange] ) ) else {
263
+ throw Error . noXcodeSelectedFound
264
+ }
265
+
266
+ // actually compare the version against version 16.1 to ensure it's equal or greater
267
+ guard version >= Version ( 16 , 1 , 0 ) else {
268
+ throw Error . xcode16_1OrGreaterRequired ( version)
269
+ }
270
+
271
+ // If we made it here, we're gucci and 16.1 or greater is selected
272
+ }
273
+
274
+ // Creates and invokes the xcodebuild install command and converts it to a stream of Progress
275
+ private func createXcodebuildDownloadStream( runtime: DownloadableRuntime ) -> AsyncThrowingStream < Progress , Swift . Error > {
276
+ let platform = runtime. platform. shortName
277
+ let version = runtime. simulatorVersion. buildUpdate
278
+
279
+ return AsyncThrowingStream < Progress , Swift . Error > { continuation in
280
+ Task {
281
+ // Assume progress will not have data races, so we manually opt-out isolation checks.
282
+ let progress = Progress ( )
283
+ progress. kind = . file
284
+ progress. fileOperationKind = . downloading
285
+
286
+ let process = Process ( )
287
+ let xcodeBuildPath = Path . root. usr. bin. join ( " xcodebuild " ) . url
288
+
289
+ process. executableURL = xcodeBuildPath
290
+ process. arguments = [
291
+ " -downloadPlatform " ,
292
+ " \( platform) " ,
293
+ " -buildVersion " ,
294
+ " \( version) "
295
+ ]
296
+
297
+ let stdOutPipe = Pipe ( )
298
+ process. standardOutput = stdOutPipe
299
+ let stdErrPipe = Pipe ( )
300
+ process. standardError = stdErrPipe
301
+
302
+ let observer = NotificationCenter . default. addObserver (
303
+ forName: . NSFileHandleDataAvailable,
304
+ object: nil ,
305
+ queue: OperationQueue . main
306
+ ) { note in
307
+ guard
308
+ // This should always be the case for Notification.Name.NSFileHandleDataAvailable
309
+ let handle = note. object as? FileHandle ,
310
+ handle === stdOutPipe. fileHandleForReading || handle === stdErrPipe. fileHandleForReading
311
+ else { return }
312
+
313
+ defer { handle. waitForDataInBackgroundAndNotify ( ) }
314
+
315
+ let string = String ( decoding: handle. availableData, as: UTF8 . self)
316
+ progress. updateFromXcodebuild ( text: string)
317
+ continuation. yield ( progress)
318
+ }
319
+
320
+ stdOutPipe. fileHandleForReading. waitForDataInBackgroundAndNotify ( )
321
+ stdErrPipe. fileHandleForReading. waitForDataInBackgroundAndNotify ( )
322
+
323
+ continuation. onTermination = { @Sendable _ in
324
+ process. terminate ( )
325
+ NotificationCenter . default. removeObserver ( observer, name: . NSFileHandleDataAvailable, object: nil )
326
+ }
327
+
328
+ do {
329
+ try process. run ( )
330
+ } catch {
331
+ continuation. finish ( throwing: error)
332
+ }
333
+
334
+ process. waitUntilExit ( )
335
+
336
+ NotificationCenter . default. removeObserver ( observer, name: . NSFileHandleDataAvailable, object: nil )
337
+
338
+ guard process. terminationReason == . exit, process. terminationStatus == 0 else {
339
+ struct ProcessExecutionError : Swift . Error { }
340
+ continuation. finish ( throwing: ProcessExecutionError ( ) )
341
+ return
342
+ }
343
+ continuation. finish ( )
344
+ }
345
+ }
346
+ }
227
347
}
228
348
229
349
extension RuntimeInstaller {
@@ -232,7 +352,8 @@ extension RuntimeInstaller {
232
352
case failedMountingDMG
233
353
case rootNeeded
234
354
case missingRuntimeSource( String )
235
- case unsupportedCryptexDiskImage
355
+ case xcode16_1OrGreaterRequired( Version )
356
+ case noXcodeSelectedFound
236
357
237
358
public var errorDescription : String ? {
238
359
switch self {
@@ -243,9 +364,11 @@ extension RuntimeInstaller {
243
364
case . rootNeeded:
244
365
return " Must be run as root to install the specified runtime "
245
366
case let . missingRuntimeSource( identifier) :
246
- return " Runtime \( identifier) is missing source url. Downloading of iOS 18 runtimes are not supported. Please install manually see https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes "
247
- case . unsupportedCryptexDiskImage:
248
- return " Cryptex Disk Image is not yet supported. "
367
+ return " Downloading runtime \( identifier) is not supported at this time. Please use `xcodes runtimes install \" \( identifier) \" ` instead. "
368
+ case let . xcode16_1OrGreaterRequired( version) :
369
+ return " Installing this runtime requires Xcode 16.1 or greater to be selected, but is currently \( version. description) "
370
+ case . noXcodeSelectedFound:
371
+ return " No Xcode is currently selected, please make sure that you have one selected and installed before trying to install this runtime "
249
372
}
250
373
}
251
374
}
@@ -294,3 +417,39 @@ extension Array {
294
417
return result
295
418
}
296
419
}
420
+
421
+
422
+ private extension Progress {
423
+ func updateFromXcodebuild( text: String ) {
424
+ self . totalUnitCount = 100
425
+ self . completedUnitCount = 0
426
+ self . localizedAdditionalDescription = " " // to not show the addtional
427
+
428
+ do {
429
+
430
+ let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"#
431
+ let downloadRegex = try NSRegularExpression ( pattern: downloadPattern)
432
+
433
+ // Search for matches in the text
434
+ if let match = downloadRegex. firstMatch ( in: text, range: NSRange ( text. startIndex... , in: text) ) {
435
+ // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes.
436
+ if let percentRange = Range ( match. range ( at: 1 ) , in: text) , let percentDouble = Double ( text [ percentRange] ) {
437
+ let percent = Int64 ( percentDouble. rounded ( ) )
438
+ self . completedUnitCount = percent
439
+ }
440
+ }
441
+
442
+ // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or
443
+ // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..."
444
+ if text. range ( of: " Installing " ) != nil {
445
+ // sets the progress to indeterminite to show animating progress
446
+ self . totalUnitCount = 0
447
+ self . completedUnitCount = 0
448
+ }
449
+
450
+ } catch {
451
+ print ( " Invalid regular expression " )
452
+ }
453
+
454
+ }
455
+ }
0 commit comments