diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 009279d..f39c105 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -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 { // 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 { + 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) -> Void + + func send(_ value: Output) { self.send(value) } + func send(completion: Subscribers.Completion) { self.complete(completion) } + } + + init(_ closure: (Subscriber) -> AnyCancellable) { + let subject = PassthroughSubject() + + 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() } + } + } +} diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 9db4dbe..7de4af7 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -112,7 +112,7 @@ class AppState: ObservableObject { var cancellables = Set() 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? diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 6d77108..8470913 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -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 = { aria2Path, url, destination, cookies in + return AsyncThrowingStream { 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 = { url in let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)! diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index ce36399..2913af5 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -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? diff --git a/Xcodes/Frontend/Common/XcodesAlert.swift b/Xcodes/Frontend/Common/XcodesAlert.swift index c928501..38636cb 100644 --- a/Xcodes/Frontend/Common/XcodesAlert.swift +++ b/Xcodes/Frontend/Common/XcodesAlert.swift @@ -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 } } } diff --git a/Xcodes/Frontend/InfoPane/RuntimesView.swift b/Xcodes/Frontend/InfoPane/RuntimesView.swift index e9e894e..5148c71 100644 --- a/Xcodes/Frontend/InfoPane/RuntimesView.swift +++ b/Xcodes/Frontend/InfoPane/RuntimesView.swift @@ -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() + } } } diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 5c74ea4..b8df774 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -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")) + ) } + } } diff --git a/Xcodes/Resources/en.lproj/Localizable.strings b/Xcodes/Resources/en.lproj/Localizable.strings index 66b4ace..358468e 100644 --- a/Xcodes/Resources/en.lproj/Localizable.strings +++ b/Xcodes/Resources/en.lproj/Localizable.strings @@ -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";