mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
add ability to cancel runtime downloads
This commit is contained in:
parent
c5ada02a31
commit
57bf6d8a80
8 changed files with 290 additions and 77 deletions
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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")!)!
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue