Skip to content

Commit

Permalink
Merge pull request #391 from spadafiva/joe/download-xcode-18-runtimes
Browse files Browse the repository at this point in the history
Fixes iOS 18+ runtime downloads
  • Loading branch information
MattKiazyk authored Oct 30, 2024
2 parents 6a619e7 + d0b01bc commit d1439a4
Showing 1 changed file with 180 additions and 21 deletions.
201 changes: 180 additions & 21 deletions Sources/XcodesKit/RuntimeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,6 @@ public class RuntimeInstaller {
}
}




installed.forEach { runtime in
let resolvedBetaNumber = downloadablesResponse.sdkToSeedMappings.first {
$0.buildUpdate == runtime.build
Expand Down Expand Up @@ -102,22 +99,27 @@ public class RuntimeInstaller {
public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool) async throws {
let matchedRuntime = try await getMatchingRuntime(identifier: identifier)

if matchedRuntime.contentType == .package && !Current.shell.isRoot() {
throw Error.rootNeeded
let deleteIfNeeded: (URL) -> Void = { dmgUrl in
if shouldDelete {
Current.logging.log("Deleting Archive")
try? Current.files.removeItem(at: dmgUrl)
}
}

let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
switch matchedRuntime.contentType {
case .package:
try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime)
case .diskImage:
try await installFromImage(dmgUrl: dmgUrl)
case .cryptexDiskImage:
throw Error.unsupportedCryptexDiskImage
}
if shouldDelete {
Current.logging.log("Deleting Archive")
try? Current.files.removeItem(at: dmgUrl)
case .package:
guard Current.shell.isRoot() else {
throw Error.rootNeeded
}
let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime)
deleteIfNeeded(dmgUrl)
case .diskImage:
let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader)
try await installFromImage(dmgUrl: dmgUrl)
deleteIfNeeded(dmgUrl)
case .cryptexDiskImage:
try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime)
}
}

Expand Down Expand Up @@ -186,7 +188,7 @@ public class RuntimeInstaller {
@MainActor
public func downloadOrUseExistingArchive(runtime: DownloadableRuntime, to destinationDirectory: Path, downloader: Downloader) async throws -> URL {
guard let source = runtime.source else {
throw Error.missingRuntimeSource(runtime.identifier)
throw Error.missingRuntimeSource(runtime.visibleIdentifier)
}
let url = URL(string: source)!
let destination = destinationDirectory/url.lastPathComponent
Expand Down Expand Up @@ -224,6 +226,124 @@ public class RuntimeInstaller {
destination.setCurrentUserAsOwner()
return result
}

// MARK: Xcode 16.1 Runtime installation helpers
/// Downloads and installs the runtime using xcodebuild, requires Xcode 16.1+ to download a runtime using a given directory
/// - Parameters:
/// - runtime: The runtime to download and install to identify the platform and version numbers
private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime) async throws {

// Make sure that we are using a version of xcode that supports this
try await ensureSelectedXcodeVersionForDownload()

// Kick off the download/install process and get an async stream of the progress
let downloadStream = createXcodebuildDownloadStream(runtime: runtime)

// Observe the progress and update the console from it
for try await progress in downloadStream {
let formatter = NumberFormatter(numberStyle: .percent)
guard Current.shell.isatty() else { return }
// These escape codes move up a line and then clear to the end
Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)")
}
}

/// Checks the existing `xcodebuild -version` to ensure that the version is appropriate to use for downloading the cryptex style 16.1+ downloads
/// otherwise will throw an error
private func ensureSelectedXcodeVersionForDownload() async throws {
let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild")
let versionString = try await Process.run(xcodeBuildPath, "-version").async()
let versionPattern = #"Xcode (\d+\.\d+)"#
let versionRegex = try NSRegularExpression(pattern: versionPattern)

// parse out the version string (e.g. 16.1) from the xcodebuild version command and convert it to a `Version`
guard let match = versionRegex.firstMatch(in: versionString.out, range: NSRange(versionString.out.startIndex..., in: versionString.out)),
let versionRange = Range(match.range(at: 1), in: versionString.out),
let version = Version(tolerant: String(versionString.out[versionRange])) else {
throw Error.noXcodeSelectedFound
}

// actually compare the version against version 16.1 to ensure it's equal or greater
guard version >= Version(16, 1, 0) else {
throw Error.xcode16_1OrGreaterRequired(version)
}

// If we made it here, we're gucci and 16.1 or greater is selected
}

// Creates and invokes the xcodebuild install command and converts it to a stream of Progress
private func createXcodebuildDownloadStream(runtime: DownloadableRuntime) -> AsyncThrowingStream<Progress, Swift.Error> {
let platform = runtime.platform.shortName
let version = runtime.simulatorVersion.buildUpdate

return AsyncThrowingStream<Progress, Swift.Error> { continuation in
Task {
// Assume progress will not have data races, so we manually opt-out isolation checks.
let progress = Progress()
progress.kind = .file
progress.fileOperationKind = .downloading

let process = Process()
let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url

process.executableURL = xcodeBuildPath
process.arguments = [
"-downloadPlatform",
"\(platform)",
"-buildVersion",
"\(version)"
]

let stdOutPipe = Pipe()
process.standardOutput = stdOutPipe
let stdErrPipe = Pipe()
process.standardError = stdErrPipe

let observer = NotificationCenter.default.addObserver(
forName: .NSFileHandleDataAvailable,
object: nil,
queue: OperationQueue.main
) { note in
guard
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
let handle = note.object as? FileHandle,
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
else { return }

defer { handle.waitForDataInBackgroundAndNotify() }

let string = String(decoding: handle.availableData, as: UTF8.self)
progress.updateFromXcodebuild(text: string)
continuation.yield(progress)
}

stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()

continuation.onTermination = { @Sendable _ in
process.terminate()
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
}

do {
try process.run()
} catch {
continuation.finish(throwing: error)
}

process.waitUntilExit()

NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)

guard process.terminationReason == .exit, process.terminationStatus == 0 else {
struct ProcessExecutionError: Swift.Error {}
continuation.finish(throwing: ProcessExecutionError())
return
}
continuation.finish()
}
}
}
}

extension RuntimeInstaller {
Expand All @@ -232,7 +352,8 @@ extension RuntimeInstaller {
case failedMountingDMG
case rootNeeded
case missingRuntimeSource(String)
case unsupportedCryptexDiskImage
case xcode16_1OrGreaterRequired(Version)
case noXcodeSelectedFound

public var errorDescription: String? {
switch self {
Expand All @@ -243,9 +364,11 @@ extension RuntimeInstaller {
case .rootNeeded:
return "Must be run as root to install the specified runtime"
case let .missingRuntimeSource(identifier):
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"
case .unsupportedCryptexDiskImage:
return "Cryptex Disk Image is not yet supported."
return "Downloading runtime \(identifier) is not supported at this time. Please use `xcodes runtimes install \"\(identifier)\"` instead."
case let .xcode16_1OrGreaterRequired(version):
return "Installing this runtime requires Xcode 16.1 or greater to be selected, but is currently \(version.description)"
case .noXcodeSelectedFound:
return "No Xcode is currently selected, please make sure that you have one selected and installed before trying to install this runtime"
}
}
}
Expand Down Expand Up @@ -294,3 +417,39 @@ extension Array {
return result
}
}


private extension Progress {
func updateFromXcodebuild(text: String) {
self.totalUnitCount = 100
self.completedUnitCount = 0
self.localizedAdditionalDescription = "" // to not show the addtional

do {

let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"#
let downloadRegex = try NSRegularExpression(pattern: downloadPattern)

// Search for matches in the text
if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) {
// Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes.
if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) {
let percent = Int64(percentDouble.rounded())
self.completedUnitCount = percent
}
}

// "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or
// "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..."
if text.range(of: "Installing") != nil {
// sets the progress to indeterminite to show animating progress
self.totalUnitCount = 0
self.completedUnitCount = 0
}

} catch {
print("Invalid regular expression")
}

}
}

0 comments on commit d1439a4

Please sign in to comment.