@@ -102,23 +102,20 @@ public class RuntimeInstaller {
102102 public func downloadAndInstallRuntime( identifier: String , to destinationDirectory: Path , with downloader: Downloader , shouldDelete: Bool ) async throws {
103103 let matchedRuntime = try await getMatchingRuntime ( identifier: identifier)
104104
105- if matchedRuntime. contentType == . package && !Current. shell. isRoot ( ) {
106- throw Error . rootNeeded
107- }
108-
109- let dmgUrl = try await downloadOrUseExistingArchive ( runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
110- 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)
121- }
105+ switch matchedRuntime. contentType {
106+ case . package :
107+ guard Current . shell. isRoot ( ) else {
108+ throw Error . rootNeeded
109+ }
110+ let dmgUrl = try await downloadOrUseExistingArchive ( runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
111+ try await installFromPackage ( dmgUrl: dmgUrl, runtime: matchedRuntime)
112+ case . diskImage:
113+ let dmgUrl = try await downloadOrUseExistingArchive ( runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
114+ try await installFromImage ( dmgUrl: dmgUrl)
115+ case . cryptexDiskImage:
116+ //
117+ try await downloadAndInstallUsingXcodeBuild ( runtime: matchedRuntime, to: destinationDirectory)
118+ }
122119 }
123120
124121 private func getMatchingRuntime( identifier: String ) async throws -> DownloadableRuntime {
@@ -224,6 +221,86 @@ public class RuntimeInstaller {
224221 destination. setCurrentUserAsOwner ( )
225222 return result
226223 }
224+
225+ private func downloadAndInstallUsingXcodeBuild( runtime: DownloadableRuntime , to destinationDirectory: Path ) async throws {
226+
227+ let downloadRuntime : ( String , String ) -> AsyncThrowingStream < Progress , Swift . Error > = { platform, version in
228+ return AsyncThrowingStream < Progress , Swift . Error > { continuation in
229+ Task {
230+ // Assume progress will not have data races, so we manually opt-out isolation checks.
231+ let progress = Progress ( )
232+ progress. kind = . file
233+ progress. fileOperationKind = . downloading
234+
235+ let process = Process ( )
236+ let xcodeBuildPath = Path . root. usr. bin. join ( " xcodebuild " ) . url
237+
238+ process. executableURL = xcodeBuildPath
239+ process. arguments = [
240+ " -downloadPlatform " ,
241+ " \( platform) " ,
242+ " -buildVersion " ,
243+ " \( version) "
244+ ]
245+
246+ let stdOutPipe = Pipe ( )
247+ process. standardOutput = stdOutPipe
248+ let stdErrPipe = Pipe ( )
249+ process. standardError = stdErrPipe
250+
251+ let observer = NotificationCenter . default. addObserver (
252+ forName: . NSFileHandleDataAvailable,
253+ object: nil ,
254+ queue: OperationQueue . main
255+ ) { note in
256+ guard
257+ // This should always be the case for Notification.Name.NSFileHandleDataAvailable
258+ let handle = note. object as? FileHandle ,
259+ handle === stdOutPipe. fileHandleForReading || handle === stdErrPipe. fileHandleForReading
260+ else { return }
261+
262+ defer { handle. waitForDataInBackgroundAndNotify ( ) }
263+
264+ let string = String ( decoding: handle. availableData, as: UTF8 . self)
265+ progress. updateFromXcodebuild ( text: string)
266+ continuation. yield ( progress)
267+ }
268+
269+ stdOutPipe. fileHandleForReading. waitForDataInBackgroundAndNotify ( )
270+ stdErrPipe. fileHandleForReading. waitForDataInBackgroundAndNotify ( )
271+
272+ continuation. onTermination = { @Sendable _ in
273+ process. terminate ( )
274+ NotificationCenter . default. removeObserver ( observer, name: . NSFileHandleDataAvailable, object: nil )
275+ }
276+
277+ do {
278+ try process. run ( )
279+ } catch {
280+ continuation. finish ( throwing: error)
281+ }
282+
283+ process. waitUntilExit ( )
284+
285+ NotificationCenter . default. removeObserver ( observer, name: . NSFileHandleDataAvailable, object: nil )
286+
287+ guard process. terminationReason == . exit, process. terminationStatus == 0 else {
288+ struct ProcessExecutionError : Swift . Error { }
289+ continuation. finish ( throwing: ProcessExecutionError ( ) )
290+ return
291+ }
292+ continuation. finish ( )
293+ }
294+ }
295+ }
296+
297+ for try await progress in downloadRuntime ( runtime. platform. shortName, runtime. simulatorVersion. buildUpdate) {
298+ let formatter = NumberFormatter ( numberStyle: . percent)
299+ guard Current . shell. isatty ( ) else { return }
300+ // These escape codes move up a line and then clear to the end
301+ Current . logging. log ( " \u{1B} [1A \u{1B} [KDownloading Runtime \( runtime. visibleIdentifier) : \( formatter. string ( from: progress. fractionCompleted) !) " )
302+ }
303+ }
227304}
228305
229306extension RuntimeInstaller {
@@ -232,7 +309,6 @@ extension RuntimeInstaller {
232309 case failedMountingDMG
233310 case rootNeeded
234311 case missingRuntimeSource( String )
235- case unsupportedCryptexDiskImage
236312
237313 public var errorDescription : String ? {
238314 switch self {
@@ -243,9 +319,7 @@ extension RuntimeInstaller {
243319 case . rootNeeded:
244320 return " Must be run as root to install the specified runtime "
245321 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. "
322+ return " Runtime \( identifier) is missing source url. Downloading of iOS 18 runtimes are only supported using Xcode 16.1+ and can only be installed, not just downloaded. "
249323 }
250324 }
251325 }
@@ -294,3 +368,39 @@ extension Array {
294368 return result
295369 }
296370}
371+
372+
373+ private extension Progress {
374+ func updateFromXcodebuild( text: String ) {
375+ self . totalUnitCount = 100
376+ self . completedUnitCount = 0
377+ self . localizedAdditionalDescription = " " // to not show the addtional
378+
379+ do {
380+
381+ let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"#
382+ let downloadRegex = try NSRegularExpression ( pattern: downloadPattern)
383+
384+ // Search for matches in the text
385+ if let match = downloadRegex. firstMatch ( in: text, range: NSRange ( text. startIndex... , in: text) ) {
386+ // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes.
387+ if let percentRange = Range ( match. range ( at: 1 ) , in: text) , let percentDouble = Double ( text [ percentRange] ) {
388+ let percent = Int64 ( percentDouble. rounded ( ) )
389+ self . completedUnitCount = percent
390+ }
391+ }
392+
393+ // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or
394+ // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..."
395+ if text. range ( of: " Installing " ) != nil {
396+ // sets the progress to indeterminite to show animating progress
397+ self . totalUnitCount = 0
398+ self . completedUnitCount = 0
399+ }
400+
401+ } catch {
402+ print ( " Invalid regular expression " )
403+ }
404+
405+ }
406+ }
0 commit comments