mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
feat: support downloading of cryptex runtimes
This commit is contained in:
parent
74237f8fc1
commit
20b9833b67
6 changed files with 190 additions and 2 deletions
|
|
@ -4,6 +4,7 @@ import OSLog
|
|||
import Combine
|
||||
import Path
|
||||
import AppleAPI
|
||||
import Version
|
||||
|
||||
extension AppState {
|
||||
func updateDownloadableRuntimes() {
|
||||
|
|
@ -48,6 +49,69 @@ extension AppState {
|
|||
}
|
||||
|
||||
func downloadRuntime(runtime: DownloadableRuntime) {
|
||||
guard let selectedXcode = self.allXcodes.first(where: { $0.selected }) else {
|
||||
Logger.appState.error("No selected Xcode")
|
||||
DispatchQueue.main.async {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "No selected Xcode. Please make an Xcode active")
|
||||
}
|
||||
return
|
||||
}
|
||||
// new runtimes
|
||||
if runtime.contentType == .cryptexDiskImage {
|
||||
// only selected xcodes > 16.1 beta 3 can download runtimes via a xcodebuild -downloadPlatform version
|
||||
// only Runtimes coming from cryptexDiskImage can be downloaded via xcodebuild
|
||||
if selectedXcode.version > Version(major: 16, minor: 0, patch: 0) {
|
||||
downloadRuntimeViaXcodeBuild(runtime: runtime)
|
||||
} else {
|
||||
// not supported
|
||||
Logger.appState.error("Trying to download a runtime we can't download")
|
||||
DispatchQueue.main.async {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "Sorry. Apple only supports downloading runtimes iOS 18+, tvOS 18+, watchOS 11+, visionOS 2+ with Xcode 16.1+. Please download and make active.")
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
downloadRuntimeObseleteWay(runtime: runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadRuntimeViaXcodeBuild(runtime: DownloadableRuntime) {
|
||||
runtimePublishers[runtime.identifier] = Task {
|
||||
do {
|
||||
for try await progress in Current.shell.downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate) {
|
||||
if progress.isIndeterminate {
|
||||
DispatchQueue.main.async {
|
||||
self.setInstallationStep(of: runtime, to: .installing, postNotification: false)
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Logger.appState.debug("Done downloading runtime - \(runtime.name)")
|
||||
DispatchQueue.main.async {
|
||||
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
|
||||
self.downloadableRuntimes[index].installState = .installed
|
||||
self.update()
|
||||
}
|
||||
|
||||
} catch {
|
||||
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
|
||||
DispatchQueue.main.async {
|
||||
self.error = error
|
||||
if let error = error as? String {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error)
|
||||
} else {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadRuntimeObseleteWay(runtime: DownloadableRuntime) {
|
||||
runtimePublishers[runtime.identifier] = Task {
|
||||
do {
|
||||
let downloadedURL = try await downloadRunTimeFull(runtime: runtime)
|
||||
|
|
|
|||
|
|
@ -196,6 +196,77 @@ public struct Shell {
|
|||
return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"])
|
||||
}
|
||||
|
||||
public var downloadRuntime: (String, String) -> AsyncThrowingStream<Progress, Error> = { platform, version in
|
||||
return AsyncThrowingStream<Progress, Error> { continuation in
|
||||
Task {
|
||||
// Assume progress will not have data races, so we manually opt-out isolation checks.
|
||||
nonisolated(unsafe) var 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)
|
||||
|
||||
// TODO: fix warning. ObservingProgressView is currently tied to an updating progress
|
||||
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 {
|
||||
continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: ""))
|
||||
return
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct Files {
|
||||
|
|
|
|||
|
|
@ -70,5 +70,38 @@ 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 {
|
||||
Logger.appState.error("Invalid regular expression")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public struct ObservingProgressIndicator: View {
|
|||
self.progress = progress
|
||||
cancellable = progress.publisher(for: \.fractionCompleted)
|
||||
.combineLatest(progress.publisher(for: \.localizedAdditionalDescription))
|
||||
.combineLatest(progress.publisher(for: \.isIndeterminate))
|
||||
.throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
}
|
||||
|
|
@ -82,6 +83,18 @@ struct ObservingProgressBar_Previews: PreviewProvider {
|
|||
style: .bar,
|
||||
showsAdditionalDescription: true
|
||||
)
|
||||
|
||||
ObservingProgressIndicator(
|
||||
configure(Progress()) {
|
||||
$0.kind = .file
|
||||
$0.fileOperationKind = .downloading
|
||||
$0.totalUnitCount = 0
|
||||
$0.completedUnitCount = 0
|
||||
},
|
||||
controlSize: .regular,
|
||||
style: .bar,
|
||||
showsAdditionalDescription: true
|
||||
)
|
||||
}
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ struct ProgressIndicator: NSViewRepresentable {
|
|||
nsView.doubleValue = doubleValue
|
||||
nsView.controlSize = controlSize
|
||||
nsView.isIndeterminate = isIndeterminate
|
||||
nsView.usesThreadedAnimation = true
|
||||
|
||||
nsView.style = style
|
||||
nsView.startAnimation(nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,12 @@ struct RuntimeInstallationStepDetailView: View {
|
|||
)
|
||||
|
||||
case .installing, .trashingArchive:
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
ObservingProgressIndicator(
|
||||
Progress(),
|
||||
controlSize: .regular,
|
||||
style: .bar,
|
||||
showsAdditionalDescription: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue