WIP download runtime, refactor

This commit is contained in:
Matt Kiazyk 2023-09-22 15:35:26 -05:00
parent 7325502853
commit dc5a8b03b6
8 changed files with 297 additions and 61 deletions

View file

@ -466,7 +466,8 @@ extension AppState {
let xcode = self.allXcodes[index]
Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal)
}
}w func setInstallationStep(of runtime: DownloadableRuntime, to step: InstallationStep) {
}
func setInstallationStep(of runtime: DownloadableRuntime, to step: InstallationStep) {
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }

View file

@ -46,28 +46,38 @@ extension AppState {
}
func downloadRuntime(runtime: DownloadableRuntime) {
self.runtimePublishers[runtime.identifier] = downloadRunTimeFull(runtime: runtime)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [unowned self] completion in
self.runtimePublishers[runtime.identifier] = nil
if case let .failure(error) = completion {
// // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
// if error as? AuthenticationError != .invalidSession {
// self.error = error
// self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
// }
// if let index = self.allXcodes.firstIndex(where: { $0.id == id }) {
// self.allXcodes[index].installState = .notInstalled
// }
}
},
receiveValue: { _ in }
)
Task {
try? await downloadRunTimeFull(runtime: runtime)
}
// self.runtimePublishers[runtime.identifier] = downloadRunTimeFull(runtime: runtime)
// .receive(on: DispatchQueue.main)
// .sink(
// receiveCompletion: { [unowned self] completion in
// self.runtimePublishers[runtime.identifier] = nil
// if case let .failure(error) = completion {
// Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
//// // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
//// if error as? AuthenticationError != .invalidSession {
//// self.error = error
//// self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
//// }
//// if let index = self.allXcodes.firstIndex(where: { $0.id == id }) {
//// self.allXcodes[index].installState = .notInstalled
//// }
// }
// },
// receiveValue: { _ in }
// )
}
func downloadRunTimeFull(runtime: DownloadableRuntime) -> AnyPublisher<(DownloadableRuntime, URL), Error> {
// gets a proper cookie for runtimes
func downloadRunTimeFull(runtime: DownloadableRuntime) async throws {
// sets a proper cookie for runtimes
try await validateADCSession(path: runtime.downloadPath)
let downloader = Downloader(rawValue: UserDefaults.standard.string(forKey: "downloader") ?? "aria2") ?? .aria2
Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)")
return validateADCSession(path: runtime.downloadPath)
.flatMap { _ in
@ -82,6 +92,18 @@ extension AppState {
})
.map { return (runtime, $0) }
}
.flatMap { runtime, url -> AnyPublisher<URL, Error> in
switch runtime.contentType {
case .package:
return self.installFromPackage(dmgURL: url, runtime: runtime)
case .diskImage:
return self.installFromImage(dmgURL: url)
}
}
.map { url in
// Done deleting
Logger.appState.debug("URL: \(url)")
}
.eraseToAnyPublisher()
}
@ -92,7 +114,9 @@ extension AppState {
// use runtime.url for final with cookies
// Check to see if the archive is in the expected path in case it was downloaded but failed to install
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(runtime.name).\(runtime.name.suffix(fromLast: "."))"
// let expectedRuntimePath = Path.xcodesApplicationSupport/"\(runtime.name).\(runtime.name.suffix(fromLast: "."))"
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
@ -106,7 +130,8 @@ extension AppState {
.eraseToAnyPublisher()
}
else {
// let destination = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))"
Logger.appState.info("Downloading runtime: \(url.lastPathComponent)")
switch downloader {
case .aria2:
let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)!
@ -115,12 +140,7 @@ extension AppState {
to: expectedRuntimePath,
aria2Path: aria2Path,
progressChanged: progressChanged)
// return downloadXcodeWithAria2(
// availableXcode,
// to: destination,
// aria2Path: aria2Path,
// progressChanged: progressChanged
// )
case .urlSession:
return Just(runtime.url)
@ -134,7 +154,6 @@ extension AppState {
// )
}
}
}
public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
@ -151,4 +170,54 @@ extension AppState {
.map { _ in destination.url }
.eraseToAnyPublisher()
}
public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) async -> URL {
}
public func installFromImage(dmgURL: URL) -> AnyPublisher<URL, Error> {
try? self.runtimeService.installRuntimeImage(dmgURL: dmgURL)
return Just(dmgURL)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
public func installFromPackage(dmgURL: URL, runtime: DownloadableRuntime) -> AnyPublisher<URL, Error> {
Logger.appState.info("Mounting DMG")
Task {
do {
let mountedUrl = try await self.runtimeService.mountDMG(dmgUrl: dmgURL)
// 2-Get the first path under the mounted path, should be a .pkg
let pkgPath = Path(url: mountedUrl)!.ls().first!
try Path.xcodesCaches.mkdir().setCurrentUserAsOwner()
let expandedPkgPath = Path.xcodesCaches/runtime.identifier
//try expandedPkgPath.mkdir()
Logger.appState.info("PKG Path: \(pkgPath)")
Logger.appState.info("Expanded PKG Path: \(expandedPkgPath)")
//try? Current.files.removeItem(at: expandedPkgPath.url)
// 5-Expand (not install) the pkg to temporary path
try await self.runtimeService.expand(pkgPath: pkgPath, expandedPkgPath: expandedPkgPath)
//try await self.runtimeService.unmountDMG(mountedURL: mountedUrl)
} catch {
Logger.appState.error("Error installing runtime: \(error.localizedDescription)")
}
}
return Just(dmgURL)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}

View file

@ -186,6 +186,14 @@ class AppState: ObservableObject {
.eraseToAnyPublisher()
}
func validateADCSession(path: String) async throws {
let result = try await Current.network.dataTaskAsync(with: URLRequest.downloadADCAuth(path: path))
let httpResponse = result.1 as! HTTPURLResponse
if httpResponse.statusCode == 401 {
throw AuthenticationError.notAuthorized
}
}
func validateSession() -> AnyPublisher<Void, Error> {
return Current.network.validateSession()
@ -799,30 +807,17 @@ class AppState: ObservableObject {
}
// MARK: Runtimes
func getRunTimes(xcode: Xcode) -> [DownloadableRuntime] {
let builds = xcode.sdks?.allBuilds()
let runtimes: [DownloadableRuntime]? = builds?.flatMap { sdkBuild in
downloadableRuntimes.filter {
$0.sdkBuildUpdate == sdkBuild
}
func runtimeInstallPath(xcode: Xcode, runtime: DownloadableRuntime) -> Path? {
if let coreSimulatorInfo = installedRuntimes.filter({ $0.runtimeInfo.build == runtime.sdkBuildUpdate }).first {
let urlString = coreSimulatorInfo.path["relative"]!
// app was not allowed to open up file:// url's so remove
let fileRemovedString = urlString.replacingOccurrences(of: "file://", with: "")
let url = URL(fileURLWithPath: fileRemovedString)
return Path(url: url)!
}
let updatedRuntimes = runtimes?.map { runtime in
var updatedRuntime = runtime
if let coreSimulatorInfo = installedRuntimes.filter({ $0.runtimeInfo.build == runtime.sdkBuildUpdate }).first {
let url = URL(fileURLWithPath: coreSimulatorInfo.path["relative"]!)
updatedRuntime.installState = .installed(Path(url: url)!)
} else {
updatedRuntime.installState = .notInstalled
}
return updatedRuntime
}
return updatedRuntimes ?? []
return nil
}
// MARK: - Private

View file

@ -112,6 +112,101 @@ public struct Shell {
return (progress, publisher)
}
public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) async throws -> Progress = { aria2Path, url, destination, cookies in
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
var progress = Progress()
progress.kind = .file
progress.fileOperationKind = .downloading
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)
progress.updateFromAria2(string: string)
}
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
do {
defer {
//DispatchQueue.global(qos: .default).async {
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) {
throw aria2cError
} else {
throw ProcessExecutionError(process: process, standardOutput: "", standardError: "")
}
}
return
// }
}
try process.run()
} catch {
throw error
}
// let publisher = Deferred {
// Future<Void, Error> { promise in
// DispatchQueue.global(qos: .default).async {
// 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) {
// return promise(.failure(aria2cError))
// } else {
// return promise(.failure(ProcessExecutionError(process: process, standardOutput: "", standardError: "")))
// }
// }
// promise(.success(()))
// }
// }
// }
// .handleEvents(receiveCancel: {
// process.terminate()
// NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
// })
// .eraseToAnyPublisher()
//
// return (progress, publisher)
}
public var unxipExperiment: (URL) -> AnyPublisher<ProcessOutput, Error> = { url in
let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)!
return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"])
@ -189,10 +284,15 @@ public struct Network {
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
public func dataTask(with request: URLRequest) -> AnyPublisher<URLSession.DataTaskPublisher.Output, Error> {
dataTask(request)
}
public func dataTaskAsync(with request: URLRequest) async throws -> (Data, URLResponse) {
return try await AppleAPI.Current.network.session.data(for: request)
}
public var downloadTask: (URL, URL, Data?) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) }
public func downloadTask(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) {

View file

@ -32,4 +32,15 @@ extension Path {
static var runtimeCacheFile: Path {
return xcodesApplicationSupport/"downloadable-runtimes.json"
}
static var xcodesCaches: Path {
return caches/"com.xcodesorg.xcodesapp"
}
@discardableResult
func setCurrentUserAsOwner() -> Path {
let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName()
try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string)
return self
}
}

View file

@ -249,14 +249,22 @@ struct InfoPane: View {
@ViewBuilder
private func runtimes(for xcode: Xcode) -> some View {
let runtimes = appState.getRunTimes(xcode: xcode)
VStack(alignment: .leading) {
Text("Platforms")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(runtimes, id: \.simulatorVersion.buildUpdate) { runtime in
let builds = xcode.sdks?.allBuilds()
let runtimes = builds?.flatMap { sdkBuild in
appState.downloadableRuntimes.filter {
$0.sdkBuildUpdate == sdkBuild
}
}
// let runtimes = appState.getRunTimes(xcode: xcode)
ForEach(runtimes ?? [], id: \.simulatorVersion.buildUpdate) { runtime in
VStack {
HStack {
Text("\(runtime.visibleIdentifier)")
@ -264,19 +272,26 @@ struct InfoPane: View {
Spacer()
Text(runtime.downloadFileSizeString)
.font(.subheadline)
DownloadRuntimeButton(runtime: runtime)
// 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 .notInstalled:
Text("NOT INSTALLED")
case .installing(let installationStep):
Text("INSTALLING")
InstallationStepDetailView(installationStep: installationStep)
.fixedSize(horizontal: false, vertical: true)
case .installed(let path):
Text(path.string)
default:
EmptyView()
}
}

View file

@ -8,6 +8,10 @@ extension URL {
public struct RuntimeService {
var networkService: AsyncHTTPNetworkService
public enum Error: LocalizedError, Equatable {
case unavailableRuntime(String)
case failedMountingDMG
}
public init() {
networkService = AsyncHTTPNetworkService()
@ -50,7 +54,30 @@ public struct RuntimeService {
}
}
public func installRuntimeImage(dmgURL: URL) throws {
Task {
_ = try await Current.shell.installRuntimeImage(dmgURL)
}
}
public func mountDMG(dmgUrl: URL) async throws -> URL {
let resultPlist = try await Current.shell.mountDmg(dmgUrl)
let dict = try? (PropertyListSerialization.propertyList(from: resultPlist.out.data(using: .utf8)!, format: nil) as? NSDictionary)
let systemEntities = dict?["system-entities"] as? NSArray
guard let path = systemEntities?.compactMap ({ ($0 as? NSDictionary)?["mount-point"] as? String }).first else {
throw Error.failedMountingDMG
}
return URL(fileURLWithPath: path)
}
public func unmountDMG(mountedURL: URL) async throws {
let url = try await Current.shell.unmountDmg(mountedURL)
}
public func expand(pkgPath: Path, expandedPkgPath: Path) async throws {
_ = try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url)
}
}

View file

@ -5,4 +5,22 @@ public struct Shell {
public var installedRuntimes: () async throws -> ProcessOutput = {
try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j")
}
public var mountDmg: (URL) async throws -> ProcessOutput = {
try await Process.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path)
}
public var unmountDmg: (URL) async throws -> ProcessOutput = {
try await Process.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path)
}
public var expandPkg: (URL, URL) async throws -> ProcessOutput = {
try await Process.run(Path.root.usr.sbin.join("pkgutil"), "--verbose", "--expand", $0.path, $1.path)
}
public var createPkg: (URL, URL) async throws -> ProcessOutput = {
try await Process.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path)
}
public var installPkg: (URL, String) async throws -> ProcessOutput = {
try await Process.run(Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1)
}
public var installRuntimeImage: (URL) async throws -> ProcessOutput = {
try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $0.path)
}
}