Skip to content

Commit d1439a4

Browse files
authored
Merge pull request #391 from spadafiva/joe/download-xcode-18-runtimes
Fixes iOS 18+ runtime downloads
2 parents 6a619e7 + d0b01bc commit d1439a4

File tree

1 file changed

+180
-21
lines changed

1 file changed

+180
-21
lines changed

Sources/XcodesKit/RuntimeInstaller.swift

Lines changed: 180 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

229349
extension 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

Comments
 (0)