Skip to content

Commit 0d631e6

Browse files
authored
Merge pull request #453 from XcodesOrg/matt/cancelRuntimeDownload
Add ability to cancel runtime downloads
2 parents b3e91fa + 1f032a5 commit 0d631e6

8 files changed

Lines changed: 416 additions & 451 deletions

File tree

Xcodes/Backend/AppState+Runtimes.swift

Lines changed: 119 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,33 @@ extension AppState {
4646
}
4747

4848
func downloadRuntime(runtime: DownloadableRuntime) {
49-
Task {
49+
runtimePublishers[runtime.identifier] = Task {
5050
do {
51-
try await downloadRunTimeFull(runtime: runtime)
51+
let downloadedURL = try await downloadRunTimeFull(runtime: runtime)
52+
if !Task.isCancelled {
53+
Logger.appState.debug("Installing rungtime: \(runtime.name)")
54+
DispatchQueue.main.async {
55+
self.setInstallationStep(of: runtime, to: .installing)
56+
}
57+
switch runtime.contentType {
58+
case .package:
59+
// not supported yet (do we need to for old packages?)
60+
throw "Installing via package not support - please install manually from \(downloadedURL.description)"
61+
case .diskImage:
62+
try await self.installFromImage(dmgURL: downloadedURL)
63+
DispatchQueue.main.async {
64+
self.setInstallationStep(of: runtime, to: .trashingArchive)
65+
}
66+
try Current.files.removeItem(at: downloadedURL)
67+
}
5268

53-
DispatchQueue.main.async {
54-
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
55-
self.downloadableRuntimes[index].installState = .installed
69+
DispatchQueue.main.async {
70+
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
71+
self.downloadableRuntimes[index].installState = .installed
72+
}
73+
updateInstalledRuntimes()
5674
}
57-
58-
updateInstalledRuntimes()
75+
5976
}
6077
catch {
6178
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
@@ -67,38 +84,44 @@ extension AppState {
6784
}
6885
}
6986

70-
func downloadRunTimeFull(runtime: DownloadableRuntime) async throws {
87+
func downloadRunTimeFull(runtime: DownloadableRuntime) async throws -> URL {
7188
// sets a proper cookie for runtimes
7289
try await validateADCSession(path: runtime.downloadPath)
7390

7491
let downloader = Downloader(rawValue: UserDefaults.standard.string(forKey: "downloader") ?? "aria2") ?? .aria2
75-
Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)")
7692

77-
78-
let url = try await self.downloadRuntime(for: runtime, downloader: downloader, progressChanged: { [unowned self] progress in
79-
DispatchQueue.main.async {
80-
self.setInstallationStep(of: runtime, to: .downloading(progress: progress))
81-
}
82-
}).async()
83-
84-
Logger.appState.debug("Done downloading: \(url)")
85-
DispatchQueue.main.async {
86-
self.setInstallationStep(of: runtime, to: .installing)
93+
let url = URL(string: runtime.source)!
94+
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
95+
// aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
96+
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
97+
var aria2DownloadIsIncomplete = false
98+
if case .aria2 = downloader, aria2DownloadMetadataPath.exists {
99+
aria2DownloadIsIncomplete = true
87100
}
88-
switch runtime.contentType {
89-
case .package:
90-
// not supported yet (do we need to for old packages?)
91-
throw "Installing via package not support - please install manually from \(url.description)"
92-
case .diskImage:
93-
try await self.installFromImage(dmgURL: url)
94-
DispatchQueue.main.async {
95-
self.setInstallationStep(of: runtime, to: .trashingArchive)
96-
}
97-
try Current.files.removeItem(at: url)
101+
if Current.files.fileExistsAtPath(expectedRuntimePath.string), aria2DownloadIsIncomplete == false {
102+
Logger.appState.info("Found existing runtime that will be used for installation at \(expectedRuntimePath).")
103+
return expectedRuntimePath.url
104+
}
105+
106+
Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)")
107+
switch downloader {
108+
case .aria2:
109+
let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)!
110+
for try await progress in downloadRuntimeWithAria2(runtime, to: expectedRuntimePath, aria2Path: aria2Path) {
111+
DispatchQueue.main.async {
112+
Logger.appState.debug("Downloading: \(progress.fractionCompleted)")
113+
self.setInstallationStep(of: runtime, to: .downloading(progress: progress))
114+
}
115+
}
116+
Logger.appState.debug("Done downloading")
117+
118+
case .urlSession:
119+
throw "Downloading runtimes with URLSession is not supported. Please use aria2"
98120
}
121+
return expectedRuntimePath.url
99122
}
123+
100124

101-
@MainActor
102125
func downloadRuntime(for runtime: DownloadableRuntime, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
103126
// Check to see if the dmg is in the expected path in case it was downloaded but failed to install
104127

@@ -156,9 +179,36 @@ extension AppState {
156179
.eraseToAnyPublisher()
157180
}
158181

182+
public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path) -> AsyncThrowingStream<Progress, Error> {
183+
let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: runtime.url) ?? []
184+
185+
return Current.shell.downloadWithAria2Async(aria2Path, runtime.url, destination, cookies)
186+
}
187+
188+
159189
public func installFromImage(dmgURL: URL) async throws {
160190
try await self.runtimeService.installRuntimeImage(dmgURL: dmgURL)
161191
}
192+
193+
func cancelRuntimeInstall(runtime: DownloadableRuntime) {
194+
// Cancel the publisher
195+
196+
runtimePublishers[runtime.identifier]?.cancel()
197+
runtimePublishers[runtime.identifier] = nil
198+
199+
// If the download is cancelled by the user, clean up the download files that aria2 creates.
200+
let url = URL(string: runtime.source)!
201+
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
202+
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
203+
204+
try? Current.files.removeItem(at: expectedRuntimePath.url)
205+
try? Current.files.removeItem(at: aria2DownloadMetadataPath.url)
206+
207+
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
208+
self.downloadableRuntimes[index].installState = .notInstalled
209+
210+
updateInstalledRuntimes()
211+
}
162212
}
163213

164214
extension AnyPublisher {
@@ -181,3 +231,42 @@ extension AnyPublisher {
181231
}
182232
}
183233
}
234+
extension AnyPublisher where Failure: Error {
235+
struct Subscriber {
236+
fileprivate let send: (Output) -> Void
237+
fileprivate let complete: (Subscribers.Completion<Failure>) -> Void
238+
239+
func send(_ value: Output) { self.send(value) }
240+
func send(completion: Subscribers.Completion<Failure>) { self.complete(completion) }
241+
}
242+
243+
init(_ closure: (Subscriber) -> AnyCancellable) {
244+
let subject = PassthroughSubject<Output, Failure>()
245+
246+
let subscriber = Subscriber(
247+
send: subject.send,
248+
complete: subject.send(completion:)
249+
)
250+
let cancel = closure(subscriber)
251+
252+
self = subject
253+
.handleEvents(receiveCancel: cancel.cancel)
254+
.eraseToAnyPublisher()
255+
}
256+
}
257+
258+
extension AnyPublisher where Failure == Error {
259+
init(taskPriority: TaskPriority? = nil, asyncFunc: @escaping () async throws -> Output) {
260+
self.init { subscriber in
261+
let task = Task(priority: taskPriority) {
262+
do {
263+
subscriber.send(try await asyncFunc())
264+
subscriber.send(completion: .finished)
265+
} catch {
266+
subscriber.send(completion: .failure(error))
267+
}
268+
}
269+
return AnyCancellable { task.cancel() }
270+
}
271+
}
272+
}

Xcodes/Backend/AppState.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class AppState: ObservableObject {
112112

113113
var cancellables = Set<AnyCancellable>()
114114
private var installationPublishers: [Version: AnyCancellable] = [:]
115-
internal var runtimePublishers: [String: AnyCancellable] = [:]
115+
internal var runtimePublishers: [String: Task<(), any Error>] = [:]
116116
private var selectPublisher: AnyCancellable?
117117
private var uninstallPublisher: AnyCancellable?
118118
private var autoInstallTimer: Timer?

Xcodes/Backend/Environment.swift

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,84 @@ public struct Shell {
111111

112112
return (progress, publisher)
113113
}
114-
// TODO: Support using aria2 using AysncStream/AsyncSequence
115-
// public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) async throws -> Progress = { aria2Path, url, destination, cookies in
116-
114+
115+
public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream<Progress, Error> = { aria2Path, url, destination, cookies in
116+
return AsyncThrowingStream<Progress, Error> { continuation in
117+
118+
Task {
119+
var progress = Progress()
120+
progress.kind = .file
121+
progress.fileOperationKind = .downloading
122+
123+
let process = Process()
124+
process.executableURL = aria2Path.url
125+
process.arguments = [
126+
"--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))",
127+
"--max-connection-per-server=16",
128+
"--split=16",
129+
"--summary-interval=1",
130+
"--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process
131+
"--dir=\(destination.parent.string)",
132+
"--out=\(destination.basename())",
133+
"--human-readable=false", // sets the output to use bytes instead of formatting
134+
url.absoluteString,
135+
]
136+
let stdOutPipe = Pipe()
137+
process.standardOutput = stdOutPipe
138+
let stdErrPipe = Pipe()
139+
process.standardError = stdErrPipe
140+
141+
let observer = NotificationCenter.default.addObserver(
142+
forName: .NSFileHandleDataAvailable,
143+
object: nil,
144+
queue: OperationQueue.main
145+
) { note in
146+
guard
147+
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
148+
let handle = note.object as? FileHandle,
149+
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
150+
else { return }
151+
152+
defer { handle.waitForDataInBackgroundAndNotify() }
153+
154+
let string = String(decoding: handle.availableData, as: UTF8.self)
155+
// TODO: fix warning. ObservingProgressView is currently tied to an updating progress
156+
progress.updateFromAria2(string: string)
157+
158+
continuation.yield(progress)
159+
}
160+
161+
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
162+
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
163+
164+
continuation.onTermination = { @Sendable _ in
165+
process.terminate()
166+
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
167+
}
168+
169+
do {
170+
try process.run()
171+
} catch {
172+
continuation.finish(throwing: error)
173+
}
174+
175+
process.waitUntilExit()
176+
177+
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
178+
179+
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
180+
if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) {
181+
continuation.finish(throwing: aria2cError)
182+
} else {
183+
continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: ""))
184+
}
185+
return
186+
}
187+
continuation.finish()
188+
}
189+
}
190+
}
191+
117192

118193
public var unxipExperiment: (URL) -> AnyPublisher<ProcessOutput, Error> = { url in
119194
let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)!

Xcodes/Backend/XcodeCommands.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ struct CancelInstallButton: View {
6767
}
6868
}
6969

70+
struct CancelRuntimeInstallButton: View {
71+
@EnvironmentObject var appState: AppState
72+
let runtime: DownloadableRuntime?
73+
74+
var body: some View {
75+
Button(action: cancelInstall) {
76+
Text("Cancel")
77+
.help(localizeString("StopInstallation"))
78+
}
79+
}
80+
81+
private func cancelInstall() {
82+
guard let runtime = runtime else { return }
83+
appState.presentedAlert = .cancelRuntimeInstall(runtime: runtime)
84+
}
85+
}
86+
7087
struct SelectButton: View {
7188
@EnvironmentObject var appState: AppState
7289
let xcode: Xcode?

Xcodes/Frontend/Common/XcodesAlert.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import Foundation
2+
import XcodesKit
23

34
enum XcodesAlert: Identifiable {
45
case cancelInstall(xcode: Xcode)
6+
case cancelRuntimeInstall(runtime: DownloadableRuntime)
57
case privilegedHelper
68
case generic(title: String, message: String)
79
case checkMinSupportedVersion(xcode: AvailableXcode, macOS: String)
@@ -12,6 +14,7 @@ enum XcodesAlert: Identifiable {
1214
case .privilegedHelper: return 2
1315
case .generic: return 3
1416
case .checkMinSupportedVersion: return 4
17+
case .cancelRuntimeInstall: return 5
1518
}
1619
}
1720
}

0 commit comments

Comments
 (0)