This commit is contained in:
Matt Kiazyk 2023-11-23 10:37:41 -06:00
parent 487cbb0045
commit 6ffce23616
21 changed files with 237 additions and 231 deletions

View file

@ -79,7 +79,6 @@
CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */; };
CAC28188259EE27200B8AB0B /* CombineExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = CAC28187259EE27200B8AB0B /* CombineExpectations */; };
CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */; };
CAC281DA259F985100B8AB0B /* InstallationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281D9259F985100B8AB0B /* InstallationStep.swift */; };
CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281E1259FA44600B8AB0B /* Bundle+XcodesTests.swift */; };
CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */; };
CAC9F92D25BCDA4400B4965F /* HelperInstallState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC9F92C25BCDA4400B4965F /* HelperInstallState.swift */; };
@ -269,7 +268,6 @@
CABFAA422593104F00380FEE /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+InfoPlistValues.swift"; sourceTree = "<group>"; };
CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservingProgressIndicator.swift; sourceTree = "<group>"; };
CAC281D9259F985100B8AB0B /* InstallationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationStep.swift; sourceTree = "<group>"; };
CAC281E1259FA44600B8AB0B /* Bundle+XcodesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+XcodesTests.swift"; sourceTree = "<group>"; };
CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Mock.swift"; sourceTree = "<group>"; };
CAC9F92C25BCDA4400B4965F /* HelperInstallState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperInstallState.swift; sourceTree = "<group>"; };
@ -482,7 +480,6 @@
CABFA9AC2592EEE900380FEE /* Foundation.swift */,
CA9FF9352595B44700E47BAF /* HelperClient.swift */,
CAC9F92C25BCDA4400B4965F /* HelperInstallState.swift */,
CAC281D9259F985100B8AB0B /* InstallationStep.swift */,
CA9FF8862595607900E47BAF /* InstalledXcode.swift */,
CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */,
E89342F925EDCC17007CF557 /* NotificationManager.swift */,
@ -863,7 +860,6 @@
CAFE4ABC25B7D54B0064FE51 /* UpdatesPreferencePane.swift in Sources */,
CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */,
CABFA9C32592EEEA00380FEE /* Downloads.swift in Sources */,
CAC281DA259F985100B8AB0B /* InstallationStep.swift in Sources */,
E8CBDB8B27AE02FF00B22292 /* ExperiementsPreferencePane.swift in Sources */,
E8E98A9625D863D700EC89A0 /* InstallationStepDetailView.swift in Sources */,
CA378F992466567600A58CE0 /* AppState.swift in Sources */,
@ -1427,8 +1423,6 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/xcodereleases/data";
requirement = {
kind = revision;
revision = a43ad89e536d7a3da525fcc23fb182c37b756ecc;
branch = main;
kind = branch;
};

View file

@ -23,7 +23,7 @@
"package": "XcodeReleases",
"repositoryURL": "https://github.com/xcodereleases/data",
"state": {
"branch": null,
"branch": "main",
"revision": "a43ad89e536d7a3da525fcc23fb182c37b756ecc",
"version": null
}
@ -69,8 +69,8 @@
"repositoryURL": "https://github.com/mxcl/Path.swift",
"state": {
"branch": null,
"revision": "9c6f807b0a76be0e27aecc908bc6f173400d839e",
"version": "1.4.0"
"revision": "8e355c28e9393c42e58b18c54cace2c42c98a616",
"version": "1.4.1"
}
},
{

View file

@ -490,7 +490,7 @@ extension AppState {
// MARK: -
func setInstallationStep(of version: Version, to step: InstallationStep) {
func setInstallationStep(of version: Version, to step: XcodeInstallationStep) {
DispatchQueue.main.async {
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
self.allXcodes[index].installState = .installing(step)
@ -499,14 +499,13 @@ extension AppState {
Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal)
}
}
func setInstallationStep(of runtime: DownloadableRuntime, to step: InstallationStep) {
func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep) {
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installing(step)
// let xcode = self.allXcodes[index]
// Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal)
Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal)
}
}
}

View file

@ -47,7 +47,12 @@ extension AppState {
func downloadRuntime(runtime: DownloadableRuntime) {
Task {
try? await downloadRunTimeFull(runtime: runtime)
do {
try await downloadRunTimeFull(runtime: runtime)
}
catch {
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
}
}
// self.runtimePublishers[runtime.identifier] = downloadRunTimeFull(runtime: runtime)
@ -78,33 +83,20 @@ extension AppState {
let downloader = Downloader(rawValue: UserDefaults.standard.string(forKey: "downloader") ?? "aria2") ?? .aria2
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()
return validateADCSession(path: runtime.downloadPath)
.flatMap { _ in
// we shouldn't have to be authenticated to download runtimes
let downloader = Downloader(rawValue: UserDefaults.standard.string(forKey: "downloader") ?? "aria2") ?? .aria2
Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)")
return self.downloadRuntime(for: runtime, downloader: downloader, progressChanged: { [unowned self] progress in
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .downloading(progress: progress))
}
})
.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()
Logger.appState.debug("Done downloading: \(url)")
//self.setInstallationStep(of: runtime, to: .downloading(progress: progress))
switch runtime.contentType {
case .package:
try await self.installFromPackage(dmgURL: url, runtime: runtime)
case .diskImage:
try await self.installFromImage(dmgURL: url)
}
}
func downloadRuntime(for runtime: DownloadableRuntime, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
@ -142,7 +134,7 @@ extension AppState {
progressChanged: progressChanged)
case .urlSession:
// TODO: Support runtime download via URL Session
return Just(runtime.url)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
@ -171,53 +163,56 @@ extension AppState {
.eraseToAnyPublisher()
}
public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) async -> URL {
public func installFromImage(dmgURL: URL) async throws {
try? self.runtimeService.installRuntimeImage(dmgURL: dmgURL)
}
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> {
public func installFromPackage(dmgURL: URL, runtime: DownloadableRuntime) async throws {
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()
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)")
}
}
}
extension AnyPublisher {
func async() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = first()
.sink { result in
switch result {
case .finished:
break
case let .failure(error):
continuation.resume(throwing: error)
}
cancellable?.cancel()
} receiveValue: { value in
continuation.resume(with: .success(value))
}
}
}
}

View file

@ -112,100 +112,73 @@ 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 {
// 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",
// "--summaraasdy-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.kinasdas
// progress.fileOperationKind = .downloadingasdfasd
//
// 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) {
// return promise(.failure(aria2cError))
// throw aria2cError
// } else {
// return promise(.failure(ProcessExecutionError(process: process, standardOutput: "", standardError: "")))
// throw ProcessExecutionError(process: process, standardOutput: "", standardError: "")
// }
// }
// promise(.success(()))
// return
// }
// }
// try process.run()
// } catch {
// throw error
// }
// .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")!)!

View file

@ -1,45 +0,0 @@
//import Foundation
//
///// A numbered step
//enum InstallationStep: Equatable, CustomStringConvertible {
// case downloading(progress: Progress)
// case unarchiving
// case moving(destination: String)
// case trashingArchive
// case checkingSecurity
// case finishing
//
// var description: String {
// "(\(stepNumber)/\(stepCount)) \(message)"
// }
//
// var message: String {
// switch self {
// case .downloading:
// return localizeString("Downloading")
// case .unarchiving:
// return localizeString("Unarchiving")
// case .moving(let destination):
// return String(format: localizeString("Moving"), destination)
// case .trashingArchive:
// return localizeString("TrashingArchive")
// case .checkingSecurity:
// return localizeString("CheckingSecurity")
// case .finishing:
// return localizeString("Finishing")
// }
// }
//
// var stepNumber: Int {
// switch self {
// case .downloading: return 1
// case .unarchiving: return 2
// case .moving: return 3
// case .trashingArchive: return 4
// case .checkingSecurity: return 5
// case .finishing: return 6
// }
// }
//
// var stepCount: Int { 6 }
//}

View file

@ -4,6 +4,8 @@ import os.log
import Path
import XcodesKit
public typealias ProcessOutput = (status: Int32, out: String, err: String)
extension Process {
@discardableResult
static func run(_ executable: any Pathish, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher<ProcessOutput, Error> {

View file

@ -4,7 +4,7 @@ import XcodesKit
enum XcodeInstallState: Equatable {
case notInstalled
case installing(InstallationStep)
case installing(XcodeInstallationStep)
case installed(Path)
var notInstalled: Bool {

View file

@ -2,7 +2,7 @@ import SwiftUI
import XcodesKit
struct InstallationStepDetailView: View {
let installationStep: InstallationStep
let installationStep: XcodeInstallationStep
var body: some View {
VStack(alignment: .leading, spacing: 0) {

View file

@ -2,7 +2,7 @@ import SwiftUI
import XcodesKit
struct InstallationStepRowView: View {
let installationStep: InstallationStep
let installationStep: XcodeInstallationStep
let highlighted: Bool
let cancel: () -> Void

View file

@ -1,4 +1,4 @@
{\rtf1\ansi\ansicpg1252\cocoartf2709
{\rtf1\ansi\ansicpg1252\cocoartf2758
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 .SFNS-Regular;}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}

View file

@ -1,7 +0,0 @@
import Foundation
public struct Environment {
public var shell = Shell()
}
public var Current = Environment()

View file

@ -0,0 +1,34 @@
//
// RuntimeInstallState.swift
//
//
// Created by Matt Kiazyk on 2023-11-23.
//
import Foundation
import Path
public enum RuntimeInstallState: Equatable {
case notInstalled
case installing(RuntimeInstallationStep)
case installed(Path)
var notInstalled: Bool {
switch self {
case .notInstalled: return true
default: return false
}
}
var installing: Bool {
switch self {
case .installing: return true
default: return false
}
}
var installed: Bool {
switch self {
case .installed: return true
default: return false
}
}
}

View file

@ -0,0 +1,51 @@
//
// RuntimeInstallationStep.swift
//
//
// Created by Matt Kiazyk on 2023-11-23.
//
import Foundation
public enum RuntimeInstallationStep: Equatable, CustomStringConvertible {
case downloading(progress: Progress)
case unarchiving
case moving(destination: String)
case trashingArchive
case checkingSecurity
case finishing
public var description: String {
"(\(stepNumber)/\(stepCount)) \(message)"
}
public var message: String {
switch self {
case .downloading:
return localizeString("Downloading")
case .unarchiving:
return localizeString("Unarchiving")
case .moving(let destination):
return String(format: localizeString("Moving"), destination)
case .trashingArchive:
return localizeString("TrashingArchive")
case .checkingSecurity:
return localizeString("CheckingSecurity")
case .finishing:
return localizeString("Finishing")
}
}
public var stepNumber: Int {
switch self {
case .downloading: return 1
case .unarchiving: return 2
case .moving: return 3
case .trashingArchive: return 4
case .checkingSecurity: return 5
case .finishing: return 6
}
}
public var stepCount: Int { 6 }
}

View file

@ -29,7 +29,7 @@ public struct DownloadableRuntime: Codable {
}
// dynamically updated - not decoded
public var installState: InstallState = .notInstalled
public var installState: RuntimeInstallState = .notInstalled
public var sdkBuildUpdate: String?
enum CodingKeys: CodingKey {

View file

@ -8,9 +8,9 @@
import Foundation
import Path
public enum InstallState: Equatable {
public enum XcodeInstallState: Equatable {
case notInstalled
case installing(InstallationStep)
case installing(XcodeInstallationStep)
case installed(Path)
var notInstalled: Bool {

View file

@ -8,7 +8,7 @@
import Foundation
// A numbered step
public enum InstallationStep: Equatable, CustomStringConvertible {
public enum XcodeInstallationStep: Equatable, CustomStringConvertible {
case downloading(progress: Progress)
case unarchiving
case moving(destination: String)
@ -50,6 +50,7 @@ public enum InstallationStep: Equatable, CustomStringConvertible {
public var stepCount: Int { 6 }
}
func localizeString(_ key: String, comment: String = "") -> String {
if #available(macOS 12, *) {
return String(localized: String.LocalizationValue(key))

View file

@ -1,7 +1,7 @@
import Foundation
import Path
public struct Shell {
public struct XcodesShell {
public var installedRuntimes: () async throws -> ProcessOutput = {
try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j")
}

View file

@ -0,0 +1,7 @@
import Foundation
public struct XcodesKitEnvironment {
public var shell = XcodesShell()
}
public var Current = XcodesKitEnvironment()

View file

@ -4,6 +4,8 @@ import CombineExpectations
import Path
import Version
import XCTest
import XcodesKit
@testable import Xcodes
class AppStateTests: XCTestCase {

View file

@ -2,8 +2,8 @@ import Combine
import Foundation
@testable import Xcodes
extension Environment {
static var mock = Environment(
extension Xcodes.Environment {
static var mock = Xcodes.Environment(
shell: .mock,
files: .mock,
network: .mock,