Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes iOS 18+ runtime downloads #391

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 179 additions & 20 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 @@ -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()
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More or less verbatim from the other PR

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 "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."
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) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken directly from the other PR

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")
}

}
}