@@ -102,19 +102,19 @@ 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- 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- try await downloadAndInstallUsingXcodeBuild ( runtime: matchedRuntime)
117- }
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+ try await downloadAndInstallUsingXcodeBuild ( runtime: matchedRuntime)
117+ }
118118 }
119119
120120 private func getMatchingRuntime( identifier: String ) async throws -> DownloadableRuntime {
@@ -221,119 +221,119 @@ public class RuntimeInstaller {
221221 return result
222222 }
223223
224- // MARK: Xcode 16.1 Runtime installation helpers
225- /// Downloads and installs the runtime using xcodebuild, requires Xcode 16.1+ to download a runtime using a given directory
226- /// - Parameters:
227- /// - runtime: The runtime to download and install to identify the platform and version numbers
228- private func downloadAndInstallUsingXcodeBuild( runtime: DownloadableRuntime ) async throws {
229-
230- // Make sure that we are using a version of xcode that supports this
231- try await ensureSelectedXcodeVersionForDownload ( )
232-
233- // Kick off the download/install process and get an async stream of the progress
234- let downloadStream = createXcodebuildDownloadStream ( runtime: runtime)
235-
236- // Observe the progress and update the console from it
237- for try await progress in downloadStream {
238- let formatter = NumberFormatter ( numberStyle: . percent)
239- guard Current . shell. isatty ( ) else { return }
240- // These escape codes move up a line and then clear to the end
241- Current . logging. log ( " \u{1B} [1A \u{1B} [KDownloading Runtime \( runtime. visibleIdentifier) : \( formatter. string ( from: progress. fractionCompleted) !) " )
242- }
243- }
244-
245- private func ensureSelectedXcodeVersionForDownload( ) async throws {
246- let xcodeBuildPath = Path . root. usr. bin. join ( " xcodebuild " )
247- let versionString = try await Process . run ( xcodeBuildPath, " -version " ) . async ( )
248- let versionPattern = #"Xcode (\d+\.\d+)"#
249- let versionRegex = try NSRegularExpression ( pattern: versionPattern)
250-
251- // parse out the version string (e.g. 16.1) from the xcodebuild version command and convert it to a `Version`
252- guard let match = versionRegex. firstMatch ( in: versionString. out, range: NSRange ( versionString. out. startIndex... , in: versionString. out) ) ,
253- let versionRange = Range ( match. range ( at: 1 ) , in: versionString. out) ,
254- let version = Version ( tolerant: String ( versionString. out [ versionRange] ) ) else {
255- throw Error . noXcodeSelectedFound
256- }
257-
258- guard version >= Version ( 16 , 1 , 0 ) else {
259- throw Error . xcode16_1OrGreaterRequired ( version)
260- }
261-
262- // If we made it here, we're gucci and 16.1 or greater is selected
263- }
264-
265- private func createXcodebuildDownloadStream( runtime: DownloadableRuntime ) -> AsyncThrowingStream < Progress , Swift . Error > {
266- let platform = runtime. platform. shortName
267- let version = runtime. simulatorVersion. buildUpdate
268-
269- return AsyncThrowingStream < Progress , Swift . Error > { continuation in
270- Task {
271- // Assume progress will not have data races, so we manually opt-out isolation checks.
272- let progress = Progress ( )
273- progress. kind = . file
274- progress. fileOperationKind = . downloading
275-
276- let process = Process ( )
277- let xcodeBuildPath = Path . root. usr. bin. join ( " xcodebuild " ) . url
278-
279- process. executableURL = xcodeBuildPath
280- process. arguments = [
281- " -downloadPlatform " ,
282- " \( platform) " ,
283- " -buildVersion " ,
284- " \( version) "
285- ]
286-
287- let stdOutPipe = Pipe ( )
288- process. standardOutput = stdOutPipe
289- let stdErrPipe = Pipe ( )
290- process. standardError = stdErrPipe
291-
292- let observer = NotificationCenter . default. addObserver (
293- forName: . NSFileHandleDataAvailable,
294- object: nil ,
295- queue: OperationQueue . main
296- ) { note in
297- guard
298- // This should always be the case for Notification.Name.NSFileHandleDataAvailable
299- let handle = note. object as? FileHandle ,
300- handle === stdOutPipe. fileHandleForReading || handle === stdErrPipe. fileHandleForReading
301- else { return }
302-
303- defer { handle. waitForDataInBackgroundAndNotify ( ) }
304-
305- let string = String ( decoding: handle. availableData, as: UTF8 . self)
306- progress. updateFromXcodebuild ( text: string)
307- continuation. yield ( progress)
308- }
309-
310- stdOutPipe. fileHandleForReading. waitForDataInBackgroundAndNotify ( )
311- stdErrPipe. fileHandleForReading. waitForDataInBackgroundAndNotify ( )
312-
313- continuation. onTermination = { @Sendable _ in
314- process. terminate ( )
315- NotificationCenter . default. removeObserver ( observer, name: . NSFileHandleDataAvailable, object: nil )
316- }
317-
318- do {
319- try process. run ( )
320- } catch {
321- continuation. finish ( throwing: error)
322- }
323-
324- process. waitUntilExit ( )
325-
326- NotificationCenter . default. removeObserver ( observer, name: . NSFileHandleDataAvailable, object: nil )
327-
328- guard process. terminationReason == . exit, process. terminationStatus == 0 else {
329- struct ProcessExecutionError : Swift . Error { }
330- continuation. finish ( throwing: ProcessExecutionError ( ) )
331- return
332- }
333- continuation. finish ( )
334- }
335- }
336- }
224+ // MARK: Xcode 16.1 Runtime installation helpers
225+ /// Downloads and installs the runtime using xcodebuild, requires Xcode 16.1+ to download a runtime using a given directory
226+ /// - Parameters:
227+ /// - runtime: The runtime to download and install to identify the platform and version numbers
228+ private func downloadAndInstallUsingXcodeBuild( runtime: DownloadableRuntime ) async throws {
229+
230+ // Make sure that we are using a version of xcode that supports this
231+ try await ensureSelectedXcodeVersionForDownload ( )
232+
233+ // Kick off the download/install process and get an async stream of the progress
234+ let downloadStream = createXcodebuildDownloadStream ( runtime: runtime)
235+
236+ // Observe the progress and update the console from it
237+ for try await progress in downloadStream {
238+ let formatter = NumberFormatter ( numberStyle: . percent)
239+ guard Current . shell. isatty ( ) else { return }
240+ // These escape codes move up a line and then clear to the end
241+ Current . logging. log ( " \u{1B} [1A \u{1B} [KDownloading Runtime \( runtime. visibleIdentifier) : \( formatter. string ( from: progress. fractionCompleted) !) " )
242+ }
243+ }
244+
245+ private func ensureSelectedXcodeVersionForDownload( ) async throws {
246+ let xcodeBuildPath = Path . root. usr. bin. join ( " xcodebuild " )
247+ let versionString = try await Process . run ( xcodeBuildPath, " -version " ) . async ( )
248+ let versionPattern = #"Xcode (\d+\.\d+)"#
249+ let versionRegex = try NSRegularExpression ( pattern: versionPattern)
250+
251+ // parse out the version string (e.g. 16.1) from the xcodebuild version command and convert it to a `Version`
252+ guard let match = versionRegex. firstMatch ( in: versionString. out, range: NSRange ( versionString. out. startIndex... , in: versionString. out) ) ,
253+ let versionRange = Range ( match. range ( at: 1 ) , in: versionString. out) ,
254+ let version = Version ( tolerant: String ( versionString. out [ versionRange] ) ) else {
255+ throw Error . noXcodeSelectedFound
256+ }
257+
258+ guard version >= Version ( 16 , 1 , 0 ) else {
259+ throw Error . xcode16_1OrGreaterRequired ( version)
260+ }
261+
262+ // If we made it here, we're gucci and 16.1 or greater is selected
263+ }
264+
265+ private func createXcodebuildDownloadStream( runtime: DownloadableRuntime ) -> AsyncThrowingStream < Progress , Swift . Error > {
266+ let platform = runtime. platform. shortName
267+ let version = runtime. simulatorVersion. buildUpdate
268+
269+ return AsyncThrowingStream < Progress , Swift . Error > { continuation in
270+ Task {
271+ // Assume progress will not have data races, so we manually opt-out isolation checks.
272+ let progress = Progress ( )
273+ progress. kind = . file
274+ progress. fileOperationKind = . downloading
275+
276+ let process = Process ( )
277+ let xcodeBuildPath = Path . root. usr. bin. join ( " xcodebuild " ) . url
278+
279+ process. executableURL = xcodeBuildPath
280+ process. arguments = [
281+ " -downloadPlatform " ,
282+ " \( platform) " ,
283+ " -buildVersion " ,
284+ " \( version) "
285+ ]
286+
287+ let stdOutPipe = Pipe ( )
288+ process. standardOutput = stdOutPipe
289+ let stdErrPipe = Pipe ( )
290+ process. standardError = stdErrPipe
291+
292+ let observer = NotificationCenter . default. addObserver (
293+ forName: . NSFileHandleDataAvailable,
294+ object: nil ,
295+ queue: OperationQueue . main
296+ ) { note in
297+ guard
298+ // This should always be the case for Notification.Name.NSFileHandleDataAvailable
299+ let handle = note. object as? FileHandle ,
300+ handle === stdOutPipe. fileHandleForReading || handle === stdErrPipe. fileHandleForReading
301+ else { return }
302+
303+ defer { handle. waitForDataInBackgroundAndNotify ( ) }
304+
305+ let string = String ( decoding: handle. availableData, as: UTF8 . self)
306+ progress. updateFromXcodebuild ( text: string)
307+ continuation. yield ( progress)
308+ }
309+
310+ stdOutPipe. fileHandleForReading. waitForDataInBackgroundAndNotify ( )
311+ stdErrPipe. fileHandleForReading. waitForDataInBackgroundAndNotify ( )
312+
313+ continuation. onTermination = { @Sendable _ in
314+ process. terminate ( )
315+ NotificationCenter . default. removeObserver ( observer, name: . NSFileHandleDataAvailable, object: nil )
316+ }
317+
318+ do {
319+ try process. run ( )
320+ } catch {
321+ continuation. finish ( throwing: error)
322+ }
323+
324+ process. waitUntilExit ( )
325+
326+ NotificationCenter . default. removeObserver ( observer, name: . NSFileHandleDataAvailable, object: nil )
327+
328+ guard process. terminationReason == . exit, process. terminationStatus == 0 else {
329+ struct ProcessExecutionError : Swift . Error { }
330+ continuation. finish ( throwing: ProcessExecutionError ( ) )
331+ return
332+ }
333+ continuation. finish ( )
334+ }
335+ }
336+ }
337337}
338338
339339extension RuntimeInstaller {
@@ -342,8 +342,8 @@ extension RuntimeInstaller {
342342 case failedMountingDMG
343343 case rootNeeded
344344 case missingRuntimeSource( String )
345- case xcode16_1OrGreaterRequired( Version )
346- case noXcodeSelectedFound
345+ case xcode16_1OrGreaterRequired( Version )
346+ case noXcodeSelectedFound
347347
348348 public var errorDescription : String ? {
349349 switch self {
@@ -410,36 +410,36 @@ extension Array {
410410
411411
412412private extension Progress {
413- func updateFromXcodebuild( text: String ) {
414- self . totalUnitCount = 100
415- self . completedUnitCount = 0
416- self . localizedAdditionalDescription = " " // to not show the addtional
417-
418- do {
419-
420- let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"#
421- let downloadRegex = try NSRegularExpression ( pattern: downloadPattern)
422-
423- // Search for matches in the text
424- if let match = downloadRegex. firstMatch ( in: text, range: NSRange ( text. startIndex... , in: text) ) {
425- // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes.
426- if let percentRange = Range ( match. range ( at: 1 ) , in: text) , let percentDouble = Double ( text [ percentRange] ) {
427- let percent = Int64 ( percentDouble. rounded ( ) )
428- self . completedUnitCount = percent
429- }
430- }
431-
432- // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or
433- // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..."
434- if text. range ( of: " Installing " ) != nil {
435- // sets the progress to indeterminite to show animating progress
436- self . totalUnitCount = 0
437- self . completedUnitCount = 0
438- }
439-
440- } catch {
441- print ( " Invalid regular expression " )
442- }
443-
444- }
413+ func updateFromXcodebuild( text: String ) {
414+ self . totalUnitCount = 100
415+ self . completedUnitCount = 0
416+ self . localizedAdditionalDescription = " " // to not show the addtional
417+
418+ do {
419+
420+ let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"#
421+ let downloadRegex = try NSRegularExpression ( pattern: downloadPattern)
422+
423+ // Search for matches in the text
424+ if let match = downloadRegex. firstMatch ( in: text, range: NSRange ( text. startIndex... , in: text) ) {
425+ // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes.
426+ if let percentRange = Range ( match. range ( at: 1 ) , in: text) , let percentDouble = Double ( text [ percentRange] ) {
427+ let percent = Int64 ( percentDouble. rounded ( ) )
428+ self . completedUnitCount = percent
429+ }
430+ }
431+
432+ // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or
433+ // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..."
434+ if text. range ( of: " Installing " ) != nil {
435+ // sets the progress to indeterminite to show animating progress
436+ self . totalUnitCount = 0
437+ self . completedUnitCount = 0
438+ }
439+
440+ } catch {
441+ print ( " Invalid regular expression " )
442+ }
443+
444+ }
445445}
0 commit comments