-
Notifications
You must be signed in to change notification settings - Fork 134
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
Supports iOS 18+ runtime downloads #391
Merged
MattKiazyk
merged 6 commits into
XcodesOrg:main
from
spadafiva:joe/download-xcode-18-runtimes
Oct 30, 2024
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c763a32
Gets downloads working a bit
spadafiva d161a5e
Fixes up error messages and checks for correct version
spadafiva 66c012b
Convert tabs to spaces to match project
spadafiva c5127ee
Adds small documentation to new functions added
spadafiva ec44af8
Restore delete behavior to fix tests
spadafiva d0b01bc
Updated some error descriptions
MattKiazyk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,9 +38,6 @@ public class RuntimeInstaller { | |
} | ||
} | ||
|
||
|
||
|
||
|
||
installed.forEach { runtime in | ||
let resolvedBetaNumber = downloadablesResponse.sdkToSeedMappings.first { | ||
$0.buildUpdate == runtime.build | ||
|
@@ -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) | ||
} | ||
} | ||
|
||
|
@@ -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 | ||
|
@@ -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 { | ||
|
@@ -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 { | ||
|
@@ -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" | ||
} | ||
} | ||
} | ||
|
@@ -294,3 +417,39 @@ extension Array { | |
return result | ||
} | ||
} | ||
|
||
|
||
private extension Progress { | ||
func updateFromXcodebuild(text: String) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
} | ||
|
||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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