add ability to cancel runtime downloads

This commit is contained in:
Matt Kiazyk 2023-12-02 09:24:54 -06:00
parent c5ada02a31
commit 57bf6d8a80
8 changed files with 290 additions and 77 deletions

View file

@ -46,16 +46,33 @@ extension AppState {
}
func downloadRuntime(runtime: DownloadableRuntime) {
Task {
runtimePublishers[runtime.identifier] = Task {
do {
try await downloadRunTimeFull(runtime: runtime)
let downloadedURL = try await downloadRunTimeFull(runtime: runtime)
if !Task.isCancelled {
Logger.appState.debug("Installing rungtime: \(runtime.name)")
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .installing)
}
switch runtime.contentType {
case .package:
// not supported yet (do we need to for old packages?)
throw "Installing via package not support - please install manually from \(downloadedURL.description)"
case .diskImage:
try await self.installFromImage(dmgURL: downloadedURL)
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .trashingArchive)
}
try Current.files.removeItem(at: downloadedURL)
}
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installed
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installed
}
updateInstalledRuntimes()
}
updateInstalledRuntimes()
}
catch {
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
@ -67,38 +84,44 @@ extension AppState {
}
}
func downloadRunTimeFull(runtime: DownloadableRuntime) async throws {
func downloadRunTimeFull(runtime: DownloadableRuntime) async throws -> URL {
// sets a proper cookie for runtimes
try await validateADCSession(path: runtime.downloadPath)
let downloader = Downloader(rawValue: UserDefaults.standard.string(forKey: "downloader") ?? "aria2") ?? .aria2
let url = URL(string: runtime.source)!
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
// aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
var aria2DownloadIsIncomplete = false
if case .aria2 = downloader, aria2DownloadMetadataPath.exists {
aria2DownloadIsIncomplete = true
}
if Current.files.fileExistsAtPath(expectedRuntimePath.string), aria2DownloadIsIncomplete == false {
Logger.appState.info("Found existing runtime that will be used for installation at \(expectedRuntimePath).")
return expectedRuntimePath.url
}
Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)")
let url = try await self.downloadRuntime(for: runtime, downloader: downloader, progressChanged: { [unowned self] progress in
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .downloading(progress: progress))
}
}).async()
Logger.appState.debug("Done downloading: \(url)")
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .installing)
}
switch runtime.contentType {
case .package:
// not supported yet (do we need to for old packages?)
throw "Installing via package not support - please install manually from \(url.description)"
case .diskImage:
try await self.installFromImage(dmgURL: url)
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .trashingArchive)
}
try Current.files.removeItem(at: url)
switch downloader {
case .aria2:
let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)!
for try await progress in downloadRuntimeWithAria2(runtime, to: expectedRuntimePath, aria2Path: aria2Path) {
DispatchQueue.main.async {
Logger.appState.debug("Downloading: \(progress.fractionCompleted)")
self.setInstallationStep(of: runtime, to: .downloading(progress: progress))
}
}
Logger.appState.debug("Done downloading")
case .urlSession:
throw "Downloading runtimes with URLSession is not supported. Please use aria2"
}
return expectedRuntimePath.url
}
@MainActor
func downloadRuntime(for runtime: DownloadableRuntime, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
// Check to see if the dmg is in the expected path in case it was downloaded but failed to install
@ -156,9 +179,36 @@ extension AppState {
.eraseToAnyPublisher()
}
public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path) -> AsyncThrowingStream<Progress, Error> {
let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: runtime.url) ?? []
return Current.shell.downloadWithAria2Async(aria2Path, runtime.url, destination, cookies)
}
public func installFromImage(dmgURL: URL) async throws {
try await self.runtimeService.installRuntimeImage(dmgURL: dmgURL)
}
func cancelRuntimeInstall(runtime: DownloadableRuntime) {
// Cancel the publisher
runtimePublishers[runtime.identifier]?.cancel()
runtimePublishers[runtime.identifier] = nil
// If the download is cancelled by the user, clean up the download files that aria2 creates.
let url = URL(string: runtime.source)!
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
try? Current.files.removeItem(at: expectedRuntimePath.url)
try? Current.files.removeItem(at: aria2DownloadMetadataPath.url)
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .notInstalled
updateInstalledRuntimes()
}
}
extension AnyPublisher {
@ -181,3 +231,42 @@ extension AnyPublisher {
}
}
}
extension AnyPublisher where Failure: Error {
struct Subscriber {
fileprivate let send: (Output) -> Void
fileprivate let complete: (Subscribers.Completion<Failure>) -> Void
func send(_ value: Output) { self.send(value) }
func send(completion: Subscribers.Completion<Failure>) { self.complete(completion) }
}
init(_ closure: (Subscriber) -> AnyCancellable) {
let subject = PassthroughSubject<Output, Failure>()
let subscriber = Subscriber(
send: subject.send,
complete: subject.send(completion:)
)
let cancel = closure(subscriber)
self = subject
.handleEvents(receiveCancel: cancel.cancel)
.eraseToAnyPublisher()
}
}
extension AnyPublisher where Failure == Error {
init(taskPriority: TaskPriority? = nil, asyncFunc: @escaping () async throws -> Output) {
self.init { subscriber in
let task = Task(priority: taskPriority) {
do {
subscriber.send(try await asyncFunc())
subscriber.send(completion: .finished)
} catch {
subscriber.send(completion: .failure(error))
}
}
return AnyCancellable { task.cancel() }
}
}
}

View file

@ -112,7 +112,7 @@ class AppState: ObservableObject {
var cancellables = Set<AnyCancellable>()
private var installationPublishers: [Version: AnyCancellable] = [:]
internal var runtimePublishers: [String: AnyCancellable] = [:]
internal var runtimePublishers: [String: Task<(), any Error>] = [:]
private var selectPublisher: AnyCancellable?
private var uninstallPublisher: AnyCancellable?
private var autoInstallTimer: Timer?

View file

@ -111,9 +111,84 @@ public struct Shell {
return (progress, publisher)
}
// TODO: Support using aria2 using AysncStream/AsyncSequence
// public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) async throws -> Progress = { aria2Path, url, destination, cookies in
public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream<Progress, Error> = { aria2Path, url, destination, cookies in
return AsyncThrowingStream<Progress, Error> { continuation in
Task {
var progress = Progress()
progress.kind = .file
progress.fileOperationKind = .downloading
let process = Process()
process.executableURL = aria2Path.url
process.arguments = [
"--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))",
"--max-connection-per-server=16",
"--split=16",
"--summary-interval=1",
"--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process
"--dir=\(destination.parent.string)",
"--out=\(destination.basename())",
"--human-readable=false", // sets the output to use bytes instead of formatting
url.absoluteString,
]
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.updateFromAria2(string: 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 {
if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) {
continuation.finish(throwing: aria2cError)
} else {
continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: ""))
}
return
}
continuation.finish()
}
}
}
public var unxipExperiment: (URL) -> AnyPublisher<ProcessOutput, Error> = { url in
let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)!

View file

@ -67,6 +67,23 @@ struct CancelInstallButton: View {
}
}
struct CancelRuntimeInstallButton: View {
@EnvironmentObject var appState: AppState
let runtime: DownloadableRuntime?
var body: some View {
Button(action: cancelInstall) {
Text("Cancel")
.help(localizeString("StopInstallation"))
}
}
private func cancelInstall() {
guard let runtime = runtime else { return }
appState.presentedAlert = .cancelRuntimeInstall(runtime: runtime)
}
}
struct SelectButton: View {
@EnvironmentObject var appState: AppState
let xcode: Xcode?

View file

@ -1,7 +1,9 @@
import Foundation
import XcodesKit
enum XcodesAlert: Identifiable {
case cancelInstall(xcode: Xcode)
case cancelRuntimeInstall(runtime: DownloadableRuntime)
case privilegedHelper
case generic(title: String, message: String)
case checkMinSupportedVersion(xcode: AvailableXcode, macOS: String)
@ -12,6 +14,7 @@ enum XcodesAlert: Identifiable {
case .privilegedHelper: return 2
case .generic: return 3
case .checkMinSupportedVersion: return 4
case .cancelRuntimeInstall: return 5
}
}
}

View file

@ -7,6 +7,7 @@
//
import SwiftUI
import XcodesKit
struct RuntimesView: View {
@EnvironmentObject var appState: AppState
@ -14,49 +15,61 @@ struct RuntimesView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Platforms")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
let builds = xcode.sdks?.allBuilds()
let runtimes = builds?.flatMap { sdkBuild in
appState.downloadableRuntimes.filter {
$0.sdkBuildUpdate == sdkBuild
}
}
ForEach(runtimes ?? [], id: \.simulatorVersion.buildUpdate) { runtime in
VStack {
HStack {
Text("\(runtime.visibleIdentifier)")
.font(.subheadline)
Spacer()
Text(runtime.downloadFileSizeString)
.font(.subheadline)
// it's installed if we have a path
if let path = appState.runtimeInstallPath(xcode: xcode, runtime: runtime) {
Button(action: { appState.reveal(path: path.string) }) {
Image(systemName: "arrow.right.circle.fill")
}
.buttonStyle(PlainButtonStyle())
.help("RevealInFinder")
} else {
DownloadRuntimeButton(runtime: runtime)
}
}
switch runtime.installState {
case .installing(let installationStep):
RuntimeInstallationStepDetailView(installationStep: installationStep)
.fixedSize(horizontal: false, vertical: true)
default:
EmptyView()
}
}
}
Text("Platforms")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
let builds = xcode.sdks?.allBuilds()
let runtimes = builds?.flatMap { sdkBuild in
appState.downloadableRuntimes.filter {
$0.sdkBuildUpdate == sdkBuild
}
}
ForEach(runtimes ?? [], id: \.simulatorVersion.buildUpdate) { runtime in
VStack {
runtimeRow(runtime: runtime)
}
}
}
}
@ViewBuilder
func runtimeRow(runtime: DownloadableRuntime) -> some View {
HStack {
Text("\(runtime.visibleIdentifier)")
.font(.subheadline)
Spacer()
Text(runtime.downloadFileSizeString)
.font(.subheadline)
switch runtime.installState {
case .installed, .notInstalled:
// it's installed if we have a path
if let path = appState.runtimeInstallPath(xcode: xcode, runtime: runtime) {
Button(action: { appState.reveal(path: path.string) }) {
Image(systemName: "arrow.right.circle.fill")
}
.buttonStyle(PlainButtonStyle())
.help("RevealInFinder")
} else {
DownloadRuntimeButton(runtime: runtime)
}
case .installing(_):
CancelRuntimeInstallButton(runtime: runtime)
}
}
switch runtime.installState {
case .installing(let installationStep):
RuntimeInstallationStepDetailView(installationStep: installationStep)
.fixedSize(horizontal: false, vertical: true)
default:
EmptyView()
}
}
}

View file

@ -1,5 +1,6 @@
import ErrorHandling
import SwiftUI
import XcodesKit
struct MainWindow: View {
@EnvironmentObject var appState: AppState
@ -176,7 +177,21 @@ struct MainWindow: View {
),
secondaryButton: .cancel(Text("Cancel"))
)
case let .cancelRuntimeInstall(runtime):
return Alert(
title: Text(String(format: localizeString("Alert.CancelInstall.Runtimes.Title"), runtime.name)),
message: Text("Alert.CancelInstall.Message"),
primaryButton: .destructive(
Text("Alert.CancelInstall.PrimaryButton"),
action: {
self.appState.cancelRuntimeInstall(runtime: runtime)
}
),
secondaryButton: .cancel(Text("Cancel"))
)
}
}
}

View file

@ -167,6 +167,7 @@
"Alert.Uninstall.Error.Message.FileNotFound" = "Could not find file \"%@\".";
// Cancel Install
"Alert.CancelInstall.Runtimes.Title" = "Are you sure you want to stop the installation of Runtime %@?";
"Alert.CancelInstall.Title" = "Are you sure you want to stop the installation of Xcode %@?";
"Alert.CancelInstall.Message" = "Any progress will be discarded.";
"Alert.CancelInstall.PrimaryButton" = "Stop Installation";