@@ -38,9 +38,6 @@ public class RuntimeInstaller {
3838 }
3939 }
4040
41-
42-
43-
4441 installed. forEach { runtime in
4542 let resolvedBetaNumber = downloadablesResponse. sdkToSeedMappings. first {
4643 $0. buildUpdate == runtime. build
@@ -102,22 +99,27 @@ public class RuntimeInstaller {
10299 public func downloadAndInstallRuntime( identifier: String , to destinationDirectory: Path , with downloader: Downloader , shouldDelete: Bool ) async throws {
103100 let matchedRuntime = try await getMatchingRuntime ( identifier: identifier)
104101
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+ }
107107 }
108108
109- let dmgUrl = try await downloadOrUseExistingArchive ( runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
110109 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)
121123 }
122124 }
123125
@@ -186,7 +188,7 @@ public class RuntimeInstaller {
186188 @MainActor
187189 public func downloadOrUseExistingArchive( runtime: DownloadableRuntime , to destinationDirectory: Path , downloader: Downloader ) async throws -> URL {
188190 guard let source = runtime. source else {
189- throw Error . missingRuntimeSource ( runtime. identifier )
191+ throw Error . missingRuntimeSource ( runtime. visibleIdentifier )
190192 }
191193 let url = URL ( string: source) !
192194 let destination = destinationDirectory/ url. lastPathComponent
@@ -224,6 +226,124 @@ public class RuntimeInstaller {
224226 destination. setCurrentUserAsOwner ( )
225227 return result
226228 }
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+ }
227347}
228348
229349extension RuntimeInstaller {
@@ -232,7 +352,8 @@ extension RuntimeInstaller {
232352 case failedMountingDMG
233353 case rootNeeded
234354 case missingRuntimeSource( String )
235- case unsupportedCryptexDiskImage
355+ case xcode16_1OrGreaterRequired( Version )
356+ case noXcodeSelectedFound
236357
237358 public var errorDescription : String ? {
238359 switch self {
@@ -243,9 +364,11 @@ extension RuntimeInstaller {
243364 case . rootNeeded:
244365 return " Must be run as root to install the specified runtime "
245366 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 "
249372 }
250373 }
251374 }
@@ -294,3 +417,39 @@ extension Array {
294417 return result
295418 }
296419}
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