@@ -113,8 +113,7 @@ public class RuntimeInstaller {
113113 let dmgUrl = try await downloadOrUseExistingArchive ( runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
114114 try await installFromImage ( dmgUrl: dmgUrl)
115115 case . cryptexDiskImage:
116- //
117- try await downloadAndInstallUsingXcodeBuild ( runtime: matchedRuntime, to: destinationDirectory)
116+ try await downloadAndInstallUsingXcodeBuild ( runtime: matchedRuntime)
118117 }
119118 }
120119
@@ -222,85 +221,119 @@ public class RuntimeInstaller {
222221 return result
223222 }
224223
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 ( )
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 {
284229
285- NotificationCenter . default. removeObserver ( observer, name: . NSFileHandleDataAvailable, object: nil )
230+ // Make sure that we are using a version of xcode that supports this
231+ try await ensureSelectedXcodeVersionForDownload ( )
286232
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- }
233+ // Kick off the download/install process and get an async stream of the progress
234+ let downloadStream = createXcodebuildDownloadStream ( runtime: runtime)
296235
297- for try await progress in downloadRuntime ( runtime. platform. shortName, runtime. simulatorVersion. buildUpdate) {
236+ // Observe the progress and update the console from it
237+ for try await progress in downloadStream {
298238 let formatter = NumberFormatter ( numberStyle: . percent)
299239 guard Current . shell. isatty ( ) else { return }
300240 // These escape codes move up a line and then clear to the end
301241 Current . logging. log ( " \u{1B} [1A \u{1B} [KDownloading Runtime \( runtime. visibleIdentifier) : \( formatter. string ( from: progress. fractionCompleted) !) " )
302242 }
303243 }
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+ }
304337}
305338
306339extension RuntimeInstaller {
@@ -309,6 +342,8 @@ extension RuntimeInstaller {
309342 case failedMountingDMG
310343 case rootNeeded
311344 case missingRuntimeSource( String )
345+ case xcode16_1OrGreaterRequired( Version )
346+ case noXcodeSelectedFound
312347
313348 public var errorDescription : String ? {
314349 switch self {
@@ -320,6 +355,10 @@ extension RuntimeInstaller {
320355 return " Must be run as root to install the specified runtime "
321356 case let . missingRuntimeSource( identifier) :
322357 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. "
358+ case let . xcode16_1OrGreaterRequired( version) :
359+ return " Installing this runtime requires Xcode 16.1 or greater to be selected, but is currently \( version. description) "
360+ case . noXcodeSelectedFound:
361+ return " No Xcode is currently selected, please make sure that you have one selected and installed before trying to install this runtime "
323362 }
324363 }
325364 }
0 commit comments