From f5d9f42de6045c7b10f05095e1b31f7a201db153 Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Fri, 7 Oct 2022 16:11:30 +0200 Subject: [PATCH 01/12] Support runtime downloading --- Sources/XcodesKit/Downloader.swift | 69 ++++++ Sources/XcodesKit/Environment.swift | 2 +- Sources/XcodesKit/Models+Runtimes.swift | 2 +- Sources/XcodesKit/RuntimeList.swift | 95 ++++++++- Sources/XcodesKit/SessionController.swift | 141 +++++++++++++ Sources/XcodesKit/URLSession+Promise.swift | 2 +- Sources/XcodesKit/XcodeInstaller.swift | 231 +++------------------ Sources/xcodes/App.swift | 56 ++++- Tests/XcodesKitTests/XcodesKitTests.swift | 214 +++++++++---------- 9 files changed, 486 insertions(+), 326 deletions(-) create mode 100644 Sources/XcodesKit/Downloader.swift create mode 100644 Sources/XcodesKit/SessionController.swift diff --git a/Sources/XcodesKit/Downloader.swift b/Sources/XcodesKit/Downloader.swift new file mode 100644 index 0000000..ed75128 --- /dev/null +++ b/Sources/XcodesKit/Downloader.swift @@ -0,0 +1,69 @@ +import PromiseKit +import Foundation +import Path +import AppleAPI + +public enum Downloader { + case urlSession + case aria2(Path) + + + func download(url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { + switch self { + case .urlSession: + if Current.shell.isatty() { + Current.logging.log("Downloading with urlSession - for faster downloads install aria2 (`brew install aria2`)".black.onYellow) + // Add 1 extra line as we are overwriting with download progress + Current.logging.log("") + } + return withUrlSession(url: url, to: destination, progressChanged: progressChanged) + case .aria2(let aria2Path): + if Current.shell.isatty() { + Current.logging.log("Downloading with aria2 at \(aria2Path)".green) + // Add 1 extra line as we are overwriting with download progress + Current.logging.log("") + } + return withAria(aria2Path: aria2Path, url: url, to: destination, progressChanged: progressChanged) + } + } + + private func withAria(aria2Path: Path, url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { + let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: url) ?? [] + return attemptRetryableTask(maximumRetryCount: 3) { + let (progress, promise) = Current.shell.downloadWithAria2( + aria2Path, + url, + destination, + cookies + ) + progressChanged(progress) + return promise.map { _ in destination.url } + } + } + + private func withUrlSession(url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { + let resumeDataPath = destination.parent/(destination.basename() + ".resumedata") + let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string) + + return attemptResumableTask(maximumRetryCount: 3) { resumeData in + let (progress, promise) = Current.network.downloadTask(with: url, + to: destination.url, + resumingWith: resumeData ?? persistedResumeData) + progressChanged(progress) + return promise.map { $0.saveLocation } + } + .tap { result in + self.persistOrCleanUpResumeData(at: resumeDataPath, for: result) + } + } + + private func persistOrCleanUpResumeData(at path: Path, for result: Result) { + switch result { + case .fulfilled: + try? Current.files.removeItem(at: path.url) + case .rejected(let error): + guard let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data else { return } + Current.files.createFile(atPath: path.string, contents: resumeData) + } + } +} diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index dc1e5e1..cd84705 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -72,7 +72,7 @@ public struct Shell { let process = Process() process.executableURL = aria2Path.url process.arguments = [ - "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", + "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", "--max-connection-per-server=16", "--split=16", "--summary-interval=1", diff --git a/Sources/XcodesKit/Models+Runtimes.swift b/Sources/XcodesKit/Models+Runtimes.swift index e3ad03f..6edefd5 100644 --- a/Sources/XcodesKit/Models+Runtimes.swift +++ b/Sources/XcodesKit/Models+Runtimes.swift @@ -29,7 +29,7 @@ public struct DownloadableRuntime: Decodable { return Int(foundString)! } - var visibleName: String { + var visibleIdentifier: String { let betaSuffix = betaVersion.flatMap { "-beta\($0)" } ?? "" return platform.shortName + " " + simulatorVersion.version + betaSuffix } diff --git a/Sources/XcodesKit/RuntimeList.swift b/Sources/XcodesKit/RuntimeList.swift index 5519977..b4cea19 100644 --- a/Sources/XcodesKit/RuntimeList.swift +++ b/Sources/XcodesKit/RuntimeList.swift @@ -4,7 +4,11 @@ import Version import Path public class RuntimeList { - public init() { + + private var sessionController: SessionController + + public init(sessionController: SessionController) { + self.sessionController = sessionController } public func printAvailableRuntimes(includeBetas: Bool) async throws { @@ -14,7 +18,7 @@ public class RuntimeList { Current.logging.log("-- \(platform.shortName) --") for downloadable in downloadables { let matchingInstalledRuntimes = installed.remove { $0.build == downloadable.simulatorVersion.buildUpdate } - let name = downloadable.visibleName + let name = downloadable.visibleIdentifier if !matchingInstalledRuntimes.isEmpty { for matchingInstalledRuntime in matchingInstalledRuntimes { switch matchingInstalledRuntime.kind { @@ -47,6 +51,93 @@ public class RuntimeList { return first.identifier.uuidString.compare(second.identifier.uuidString, options: .numeric) == .orderedAscending } } + + public func downloadAndInstallRuntime(identifier: String, downloader: Downloader) async throws { + let downloadables = try await downloadableRuntimes(includeBetas: true) + guard let matchedRuntime = downloadables.first(where: { $0.visibleIdentifier == identifier }) else { + throw Error.unavailableRuntime(identifier) + } + _ = try await download(runtime: matchedRuntime, downloader: downloader) + } + + private func download(runtime: DownloadableRuntime, downloader: Downloader) async throws -> URL { + let url = URL(string: runtime.source)! + let destination = Path.xcodesApplicationSupport/url.lastPathComponent + let aria2DownloadMetadataPath = destination.parent/(destination.basename() + ".aria2") + var aria2DownloadIsIncomplete = false + if case .aria2 = downloader, aria2DownloadMetadataPath.exists { + aria2DownloadIsIncomplete = true + } + + if Current.shell.isatty() { + // Move to the next line so that the escape codes below can move up a line and overwrite it with download progress + Current.logging.log("") + } else { + Current.logging.log("\(InstallationStep.downloading(identifier: runtime.visibleIdentifier, progress: nil))") + } + + if Current.files.fileExistsAtPath(destination.string), aria2DownloadIsIncomplete == false { + Current.logging.log("(1/1) Found existing Runtime that will be used for installation at \(destination).") + return destination.url + } + if runtime.authentication == .virtual { + try await sessionController.loginIfNeeded().async() + } + let formatter = NumberFormatter(numberStyle: .percent) + var observation: NSKeyValueObservation? + let result = try await downloader.download(url: url, to: destination, progressChanged: { progress in + observation?.invalidate() + observation = progress.observe(\.fractionCompleted) { progress, _ in + guard Current.shell.isatty() else { return } + // These escape codes move up a line and then clear to the end + Current.logging.log("\u{1B}[1A\u{1B}[K\(InstallationStep.downloading(identifier: runtime.visibleIdentifier, progress: formatter.string(from: progress.fractionCompleted)!))") + } + }).async() + observation?.invalidate() + return result + } +} + +extension RuntimeList { + public enum Error: LocalizedError, Equatable { + case unavailableRuntime(String) + + public var errorDescription: String? { + switch self { + case let .unavailableRuntime(version): + return "Could not find runtime \(version)." + } + } + } + + enum InstallationStep: CustomStringConvertible { + case downloading(identifier: String, progress: String?) + + var description: String { + return "(\(stepNumber)/\(InstallationStep.installStepCount)) \(message)" + } + + var message: String { + switch self { + case .downloading(let version, let progress): + if let progress = progress { + return "Downloading Runtime \(version): \(progress)" + } else { + return "Downloading Runtime \(version)" + } + } + } + + var stepNumber: Int { + switch self { + case .downloading: return 1 + } + } + + static var installStepCount: Int { + return 1 + } + } } extension Array { diff --git a/Sources/XcodesKit/SessionController.swift b/Sources/XcodesKit/SessionController.swift new file mode 100644 index 0000000..d000b4c --- /dev/null +++ b/Sources/XcodesKit/SessionController.swift @@ -0,0 +1,141 @@ +import PromiseKit +import Foundation +import AppleAPI + +public class SessionController { + + private let xcodesUsername = "XCODES_USERNAME" + private let xcodesPassword = "XCODES_PASSWORD" + + var configuration: Configuration + + public init(configuration: Configuration) { + self.configuration = configuration + } + + private func findUsername() -> String? { + if let username = Current.shell.env(xcodesUsername) { + return username + } + else if let username = configuration.defaultUsername { + return username + } + return nil + } + + private func findPassword(withUsername username: String) -> String? { + if let password = Current.shell.env(xcodesPassword) { + return password + } + else if let password = try? Current.keychain.getString(username){ + return password + } + return nil + } + + func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise { + return firstly { () -> Promise in + return Current.network.validateSession() + } + // Don't have a valid session, so we'll need to log in + .recover { error -> Promise in + var possibleUsername = providedUsername ?? self.findUsername() + var hasPromptedForUsername = false + if possibleUsername == nil { + possibleUsername = Current.shell.readLine(prompt: "Apple ID: ") + hasPromptedForUsername = true + } + guard let username = possibleUsername else { throw Error.missingUsernameOrPassword } + + let passwordPrompt: String + if hasPromptedForUsername { + passwordPrompt = "Apple ID Password: " + } else { + // If the user wasn't prompted for their username, also explain which Apple ID password they need to enter + passwordPrompt = "Apple ID Password (\(username)): " + } + var possiblePassword = self.findPassword(withUsername: username) + if possiblePassword == nil || shouldPromptForPassword { + possiblePassword = Current.shell.readSecureLine(prompt: passwordPrompt) + } + guard let password = possiblePassword else { throw Error.missingUsernameOrPassword } + + return firstly { () -> Promise in + self.login(username, password: password) + } + .recover { error -> Promise in + Current.logging.log(error.legibleLocalizedDescription.red) + + if case Client.Error.invalidUsernameOrPassword = error { + Current.logging.log("Try entering your password again") + // Prompt for the password next time to avoid being stuck in a loop of using an incorrect XCODES_PASSWORD environment variable + return self.loginIfNeeded(withUsername: username, shouldPromptForPassword: true) + } + else { + return Promise(error: error) + } + } + } + } + + func login(_ username: String, password: String) -> Promise { + return firstly { () -> Promise in + Current.network.login(accountName: username, password: password) + } + .recover { error -> Promise in + + if let error = error as? Client.Error { + switch error { + case .invalidUsernameOrPassword(_): + // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. + try? Current.keychain.remove(username) + default: + break + } + } + + return Promise(error: error) + } + .done { _ in + try? Current.keychain.set(password, key: username) + + if self.configuration.defaultUsername != username { + self.configuration.defaultUsername = username + try? self.configuration.save() + } + } + } + + public func logout() -> Promise { + guard let username = findUsername() else { return Promise(error: Client.Error.notAuthenticated) } + + return Promise { seal in + // Remove cookies in the shared URLSession + AppleAPI.Current.network.session.reset { + seal.fulfill(()) + } + } + .done { + // Remove all keychain items + try Current.keychain.remove(username) + + // Set `defaultUsername` in Configuration to nil + self.configuration.defaultUsername = nil + try self.configuration.save() + } + } +} + +extension SessionController { + enum Error: LocalizedError, Equatable { + case missingUsernameOrPassword + + public var errorDescription: String? { + switch self { + case .missingUsernameOrPassword: + return "Missing username or a password. Please try again." + } + } + + } +} diff --git a/Sources/XcodesKit/URLSession+Promise.swift b/Sources/XcodesKit/URLSession+Promise.swift index 04ee10e..cd4dfc8 100644 --- a/Sources/XcodesKit/URLSession+Promise.swift +++ b/Sources/XcodesKit/URLSession+Promise.swift @@ -44,4 +44,4 @@ extension URLSession { return (progress, promise) } -} \ No newline at end of file +} diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 7ce5ef3..4f1b728 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -23,7 +23,6 @@ public final class XcodeInstaller { case unavailableVersion(Version) case noNonPrereleaseVersionAvailable case noPrereleaseVersionAvailable - case missingUsernameOrPassword case versionAlreadyInstalled(InstalledXcode) case invalidVersion(String) case versionNotInstalled(Version) @@ -65,8 +64,6 @@ public final class XcodeInstaller { return "No non-prerelease versions available." case .noPrereleaseVersionAvailable: return "No prerelease versions available." - case .missingUsernameOrPassword: - return "Missing username or a password. Please try again." case let .versionAlreadyInstalled(installedXcode): return "\(installedXcode.version.appleDescription) is already installed at \(installedXcode.path)" case let .invalidVersion(version): @@ -140,31 +137,26 @@ public final class XcodeInstaller { static var downloadStepCount: Int { return 1 } - + static var installStepCount: Int { return 6 } } - private var configuration: Configuration + private var sessionController: SessionController private var xcodeList: XcodeList - public init(configuration: Configuration, xcodeList: XcodeList) { - self.configuration = configuration + public init(sessionController: SessionController, xcodeList: XcodeList) { + self.sessionController = sessionController self.xcodeList = xcodeList } - + public enum InstallationType { case version(String) case path(String, Path) case latest case latestPrerelease } - - public enum Downloader { - case urlSession - case aria2(Path) - } public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise { return firstly { () -> Promise in @@ -175,7 +167,7 @@ public final class XcodeInstaller { return xcode } } - + private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise { return firstly { () -> Promise<(Xcode, URL)> in return self.getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true) @@ -206,7 +198,7 @@ public final class XcodeInstaller { } } } - + public func download(_ installation: InstallationType, dataSource: DataSource, downloader: Downloader, destinationDirectory: Path) -> Promise { return firstly { () -> Promise<(Xcode, URL)> in return self.getXcodeArchive(installation, dataSource: dataSource, downloader: downloader, destination: destinationDirectory, willInstall: false) @@ -227,14 +219,14 @@ public final class XcodeInstaller { switch installationType { case .latest: Current.logging.log("Updating...") - + return update(dataSource: dataSource) .then { availableXcodes -> Promise<(Xcode, URL)> in guard let latestNonPrereleaseXcode = availableXcodes.filter(\.version.isNotPrerelease).sorted(\.version).last else { throw Error.noNonPrereleaseVersionAvailable } Current.logging.log("Latest non-prerelease version available is \(latestNonPrereleaseXcode.version.appleDescription)") - + if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEquivalent(to: latestNonPrereleaseXcode.version) }) { throw Error.versionAlreadyInstalled(installedXcode) } @@ -243,7 +235,7 @@ public final class XcodeInstaller { } case .latestPrerelease: Current.logging.log("Updating...") - + return update(dataSource: dataSource) .then { availableXcodes -> Promise<(Xcode, URL)> in guard let latestPrereleaseXcode = availableXcodes @@ -255,11 +247,11 @@ public final class XcodeInstaller { throw Error.noNonPrereleaseVersionAvailable } Current.logging.log("Latest prerelease version available is \(latestPrereleaseXcode.version.appleDescription)") - + if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEquivalent(to: latestPrereleaseXcode.version) }) { throw Error.versionAlreadyInstalled(installedXcode) } - + return self.downloadXcode(version: latestPrereleaseXcode.version, dataSource: dataSource, downloader: downloader, willInstall: willInstall) } case .path(let versionString, let path): @@ -287,7 +279,7 @@ public final class XcodeInstaller { // When using the Apple data source, an authenticated session is required for both // downloading the list of Xcodes as well as to actually download Xcode, so we'll // establish our session right at the start. - return loginIfNeeded() + return sessionController.loginIfNeeded() case .xcodeReleases: // When using the Xcode Releases data source, we only need to establish an anonymous @@ -349,125 +341,10 @@ public final class XcodeInstaller { .map { return (xcode, $0) } } } - + func validateADCSession(path: String) -> Promise { return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid() } - - func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise { - return firstly { () -> Promise in - return Current.network.validateSession() - } - // Don't have a valid session, so we'll need to log in - .recover { error -> Promise in - var possibleUsername = providedUsername ?? self.findUsername() - var hasPromptedForUsername = false - if possibleUsername == nil { - possibleUsername = Current.shell.readLine(prompt: "Apple ID: ") - hasPromptedForUsername = true - } - guard let username = possibleUsername else { throw Error.missingUsernameOrPassword } - - let passwordPrompt: String - if hasPromptedForUsername { - passwordPrompt = "Apple ID Password: " - } else { - // If the user wasn't prompted for their username, also explain which Apple ID password they need to enter - passwordPrompt = "Apple ID Password (\(username)): " - } - var possiblePassword = self.findPassword(withUsername: username) - if possiblePassword == nil || shouldPromptForPassword { - possiblePassword = Current.shell.readSecureLine(prompt: passwordPrompt) - } - guard let password = possiblePassword else { throw Error.missingUsernameOrPassword } - - return firstly { () -> Promise in - self.login(username, password: password) - } - .recover { error -> Promise in - Current.logging.log(error.legibleLocalizedDescription.red) - - if case Client.Error.invalidUsernameOrPassword = error { - Current.logging.log("Try entering your password again") - // Prompt for the password next time to avoid being stuck in a loop of using an incorrect XCODES_PASSWORD environment variable - return self.loginIfNeeded(withUsername: username, shouldPromptForPassword: true) - } - else { - return Promise(error: error) - } - } - } - } - - func login(_ username: String, password: String) -> Promise { - return firstly { () -> Promise in - Current.network.login(accountName: username, password: password) - } - .recover { error -> Promise in - - if let error = error as? Client.Error { - switch error { - case .invalidUsernameOrPassword(_): - // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. - try? Current.keychain.remove(username) - default: - break - } - } - - return Promise(error: error) - } - .done { _ in - try? Current.keychain.set(password, key: username) - - if self.configuration.defaultUsername != username { - self.configuration.defaultUsername = username - try? self.configuration.save() - } - } - } - - public func logout() -> Promise { - guard let username = findUsername() else { return Promise(error: Client.Error.notAuthenticated) } - - return Promise { seal in - // Remove cookies in the shared URLSession - AppleAPI.Current.network.session.reset { - seal.fulfill(()) - } - } - .done { - // Remove all keychain items - try Current.keychain.remove(username) - - // Set `defaultUsername` in Configuration to nil - self.configuration.defaultUsername = nil - try self.configuration.save() - } - } - - let xcodesUsername = "XCODES_USERNAME" - let xcodesPassword = "XCODES_PASSWORD" - - func findUsername() -> String? { - if let username = Current.shell.env(xcodesUsername) { - return username - } - else if let username = configuration.defaultUsername { - return username - } - return nil - } - - func findPassword(withUsername username: String) -> String? { - if let password = Current.shell.env(xcodesPassword) { - return password - } - else if let password = try? Current.keychain.getString(username){ - return password - } - return nil - } public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, willInstall: Bool, progressChanged: @escaping (Progress) -> Void) -> Promise { // Check to see if the archive is in the expected path in case it was downloaded but failed to install @@ -487,63 +364,7 @@ public final class XcodeInstaller { return Promise.value(expectedArchivePath.url) } else { - let destination = Path.xcodesApplicationSupport/"Xcode-\(xcode.version).\(xcode.filename.suffix(fromLast: "."))" - switch downloader { - case .aria2(let aria2Path): - if Current.shell.isatty() { - Current.logging.log("Downloading with aria2".green) - // Add 1 extra line as we are overwriting with download progress - Current.logging.log("") - } - return downloadXcodeWithAria2( - xcode, - to: destination, - aria2Path: aria2Path, - progressChanged: progressChanged - ) - case .urlSession: - if Current.shell.isatty() { - Current.logging.log("Downloading with urlSession - for faster downloads install aria2 (`brew install aria2`)".black.onYellow) - // Add 1 extra line as we are overwriting with download progress - Current.logging.log("") - } - return downloadXcodeWithURLSession( - xcode, - to: destination, - progressChanged: progressChanged - ) - } - } - } - - public func downloadXcodeWithAria2(_ xcode: Xcode, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { - let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: xcode.url) ?? [] - - return attemptRetryableTask(maximumRetryCount: 3) { - let (progress, promise) = Current.shell.downloadWithAria2( - aria2Path, - xcode.url, - destination, - cookies - ) - progressChanged(progress) - return promise.map { _ in destination.url } - } - } - - public func downloadXcodeWithURLSession(_ xcode: Xcode, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { - let resumeDataPath = Path.xcodesApplicationSupport/"Xcode-\(xcode.version).resumedata" - let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string) - - return attemptResumableTask(maximumRetryCount: 3) { resumeData in - let (progress, promise) = Current.network.downloadTask(with: xcode.url, - to: destination.url, - resumingWith: resumeData ?? persistedResumeData) - progressChanged(progress) - return promise.map { $0.saveLocation } - } - .tap { result in - self.persistOrCleanUpResumeData(at: resumeDataPath, for: result) + return downloader.download(url: xcode.url, to: expectedArchivePath, progressChanged: progressChanged) } } @@ -663,7 +484,7 @@ public final class XcodeInstaller { func update(dataSource: DataSource) -> Promise<[Xcode]> { if dataSource == .apple { return firstly { () -> Promise in - loginIfNeeded() + sessionController.loginIfNeeded() } .then { () -> Promise<[Xcode]> in self.xcodeList.update(dataSource: dataSource) @@ -705,7 +526,7 @@ public final class XcodeInstaller { allXcodeVersions[index] = ReleasedVersion(version: installedXcode.version, releaseDate: nil) } } - + return Current.shell.xcodeSelectPrintPath() .done { output in let selectedInstalledXcodeVersion = installedXcodes.first { output.out.hasPrefix($0.path.string) }.map { $0.version } @@ -732,24 +553,24 @@ public final class XcodeInstaller { } } } - + public func printInstalledXcodes(directory: Path) -> Promise { Current.shell.xcodeSelectPrintPath() .done { pathOutput in let installedXcodes = Current.files.installedXcodes(directory) .sorted { $0.version < $1.version } let selectedString = "(Selected)" - + let lines = installedXcodes.map { installedXcode -> String in var line = installedXcode.version.appleDescriptionWithBuildIdentifier - + if pathOutput.out.hasPrefix(installedXcode.path.string) { line += " " + selectedString } - + return line } - + // Add one so there's always at least one space between columns let maxWidthOfFirstColumn = (lines.map(\.count).max() ?? 0) + 1 @@ -757,9 +578,9 @@ public final class XcodeInstaller { var line = lines[index] let widthOfFirstColumnInThisRow = line.count let spaceBetweenFirstAndSecondColumns = maxWidthOfFirstColumn - widthOfFirstColumnInThisRow - + line = line.replacingOccurrences(of: selectedString, with: selectedString.green) - + // If outputting to an interactive terminal, align the columns so they're easier for a human to read // Otherwise, separate columns by a tab character so it's easier for a computer to split up if Current.shell.isatty() { @@ -768,7 +589,7 @@ public final class XcodeInstaller { } else { line += "\t\(installedXcode.path.string)" } - + Current.logging.log(line) } } @@ -792,7 +613,7 @@ public final class XcodeInstaller { func unarchiveAndMoveXIP(at source: URL, to destination: URL, experimentalUnxip: Bool) -> Promise { return firstly { () -> Promise in Current.logging.log(InstallationStep.unarchiving(experimentalUnxip: experimentalUnxip).description) - + if experimentalUnxip, #available(macOS 11, *) { return Promise { seal in Task.detached { diff --git a/Sources/xcodes/App.swift b/Sources/xcodes/App.swift index 54c7c5e..8855c70 100644 --- a/Sources/xcodes/App.swift +++ b/Sources/xcodes/App.swift @@ -60,13 +60,16 @@ struct Xcodes: AsyncParsableCommand { ) static var xcodesConfiguration = Configuration() + static var sessionController: SessionController! static let xcodeList = XcodeList() - static let runtimes = RuntimeList() + static var runtimes: RuntimeList! static var installer: XcodeInstaller! static func main() async { try? xcodesConfiguration.load() - installer = XcodeInstaller(configuration: xcodesConfiguration, xcodeList: xcodeList) + sessionController = SessionController(configuration: xcodesConfiguration) + installer = XcodeInstaller(sessionController: sessionController, xcodeList: xcodeList) + runtimes = RuntimeList(sessionController: sessionController) migrateApplicationSupportFiles() do { var command = try parseAsRoot() @@ -136,8 +139,8 @@ struct Xcodes: AsyncParsableCommand { } else { installation = .version(versionString) } - - var downloader = XcodeInstaller.Downloader.urlSession + + var downloader = Downloader.urlSession if let aria2Path = aria2.flatMap(Path.init) ?? Current.shell.findExecutable("aria2c"), aria2Path.exists, noAria2 == false { @@ -236,8 +239,8 @@ struct Xcodes: AsyncParsableCommand { } else { installation = .version(versionString) } - - var downloader = XcodeInstaller.Downloader.urlSession + + var downloader = Downloader.urlSession if let aria2Path = aria2.flatMap(Path.init) ?? Current.shell.findExecutable("aria2c"), aria2Path.exists, noAria2 == false { @@ -261,7 +264,7 @@ struct Xcodes: AsyncParsableCommand { } private func install(_ installation: XcodeInstaller.InstallationType, - using downloader: XcodeInstaller.Downloader, + using downloader: Downloader, to destination: Path) { firstly { () -> Promise in // update the list before installing only for version type because the other types already update internally @@ -363,7 +366,8 @@ struct Xcodes: AsyncParsableCommand { struct Runtimes: AsyncParsableCommand { static var configuration = CommandConfiguration( - abstract: "List all simulator runtimes that are available to install" + abstract: "List all simulator runtimes that are available to install", + subcommands: [Install.self] ) @Flag(help: "Include beta runtimes available to install") @@ -372,6 +376,38 @@ struct Xcodes: AsyncParsableCommand { func run() async throws { try await runtimes.printAvailableRuntimes(includeBetas: includeBetas) } + + struct Install: AsyncParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Download and install a specific simulator runtime" + ) + + @Argument(help: "The runtime to install") + var version: String + + @Option(help: "The path to an aria2 executable. Searches $PATH by default.", + completion: .file()) + var aria2: String? + + @Flag(help: "Don't use aria2 to download Xcode, even if its available.") + var noAria2: Bool = false + + @OptionGroup + var globalColor: GlobalColorOption + + func run() async throws { + Rainbow.enabled = Rainbow.enabled && globalColor.color + + var downloader = Downloader.urlSession + if let aria2Path = aria2.flatMap(Path.init) ?? Current.shell.findExecutable("aria2c"), + aria2Path.exists, + noAria2 == false { + downloader = .aria2(aria2Path) + } + try await runtimes.downloadAndInstallRuntime(identifier: version, downloader: downloader) + Current.logging.log("Finished") + } + } } struct Select: ParsableCommand { @@ -504,8 +540,8 @@ struct Xcodes: AsyncParsableCommand { func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color - - installer.logout() + + sessionController.logout() .done { Current.logging.log("Successfully signed out".green) Signout.exit() diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 0fd7142..144e8a1 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -12,6 +12,7 @@ final class XcodesKitTests: XCTestCase { var installer: XcodeInstaller! var runtimeList: RuntimeList! + var sessionController: SessionController! override class func setUp() { super.setUp() @@ -23,8 +24,9 @@ final class XcodesKitTests: XCTestCase { Current = .mock Rainbow.outputTarget = .unknown Rainbow.enabled = false - installer = XcodeInstaller(configuration: Configuration(), xcodeList: XcodeList()) - runtimeList = .init() + sessionController = SessionController(configuration: Configuration()) + installer = XcodeInstaller(sessionController: sessionController, xcodeList: XcodeList()) + runtimeList = RuntimeList(sessionController: sessionController) } func test_ParseCertificateInfo_Succeeds() throws { @@ -159,15 +161,15 @@ final class XcodesKitTests: XCTestCase { let progress = Progress(totalUnitCount: 100) return (progress, Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - } - }) + // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. + DispatchQueue.main.async { + for i in 0...100 { + progress.completedUnitCount = Int64(i) + } + resolver.fulfill((saveLocation: saveLocation, + response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + }) } // It's a valid .app Current.shell.codesignVerify = { _ in @@ -219,7 +221,7 @@ final class XcodesKitTests: XCTestCase { waitForExpectations(timeout: 1.0) } - + func test_InstallLogging_FullHappyPath_NoColor() { var log = "" XcodesKit.Current.logging.log = { log.append($0 + "\n") } @@ -252,15 +254,15 @@ final class XcodesKitTests: XCTestCase { let progress = Progress(totalUnitCount: 100) return (progress, Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - } - }) + // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. + DispatchQueue.main.async { + for i in 0...100 { + progress.completedUnitCount = Int64(i) + } + resolver.fulfill((saveLocation: saveLocation, + response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + }) } // It's a valid .app Current.shell.codesignVerify = { _ in @@ -312,7 +314,7 @@ final class XcodesKitTests: XCTestCase { waitForExpectations(timeout: 1.0) } - + func test_InstallLogging_FullHappyPath_NonInteractiveTerminal() { Rainbow.outputTarget = .unknown Rainbow.enabled = false @@ -349,15 +351,15 @@ final class XcodesKitTests: XCTestCase { let progress = Progress(totalUnitCount: 100) return (progress, Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - } - }) + // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. + DispatchQueue.main.async { + for i in 0...100 { + progress.completedUnitCount = Int64(i) + } + resolver.fulfill((saveLocation: saveLocation, + response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + }) } // It's a valid .app Current.shell.codesignVerify = { _ in @@ -409,7 +411,7 @@ final class XcodesKitTests: XCTestCase { waitForExpectations(timeout: 1.0) } - + func test_InstallLogging_AlternativeDirectory() { var log = "" XcodesKit.Current.logging.log = { log.append($0 + "\n") } @@ -442,15 +444,15 @@ final class XcodesKitTests: XCTestCase { let progress = Progress(totalUnitCount: 100) return (progress, Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - } - }) + // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. + DispatchQueue.main.async { + for i in 0...100 { + progress.completedUnitCount = Int64(i) + } + resolver.fulfill((saveLocation: saveLocation, + response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + }) } // It's a valid .app Current.shell.codesignVerify = { _ in @@ -503,7 +505,7 @@ final class XcodesKitTests: XCTestCase { waitForExpectations(timeout: 1.0) } - + func test_InstallLogging_IncorrectSavedPassword() { var log = "" XcodesKit.Current.logging.log = { log.append($0 + "\n") } @@ -515,9 +517,9 @@ final class XcodesKitTests: XCTestCase { XcodesKit.Current.shell.env = { key in if key == "XCODES_PASSWORD" { passwordEnvCallCount += 1 - return "old_password" + return "old_password" } else { - return nil + return nil } } var loginCallCount = 0 @@ -554,15 +556,15 @@ final class XcodesKitTests: XCTestCase { let progress = Progress(totalUnitCount: 100) return (progress, Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - } - }) + // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. + DispatchQueue.main.async { + for i in 0...100 { + progress.completedUnitCount = Int64(i) + } + resolver.fulfill((saveLocation: saveLocation, + response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + }) } // It's a valid .app Current.shell.codesignVerify = { _ in @@ -619,7 +621,7 @@ final class XcodesKitTests: XCTestCase { waitForExpectations(timeout: 1.0) } - + func test_InstallLogging_DamagedXIP() { var log = "" XcodesKit.Current.logging.log = { log.append($0 + "\n") } @@ -628,7 +630,7 @@ final class XcodesKitTests: XCTestCase { var validateSessionCallCount = 0 Current.network.validateSession = { validateSessionCallCount += 1 - + if validateSessionCallCount == 1 { return Promise(error: AppleAPI.Client.Error.invalidSession) } else { @@ -666,15 +668,15 @@ final class XcodesKitTests: XCTestCase { let progress = Progress(totalUnitCount: 100) return (progress, Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - } - }) + // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. + DispatchQueue.main.async { + for i in 0...100 { + progress.completedUnitCount = Int64(i) + } + resolver.fulfill((saveLocation: saveLocation, + response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + }) } // It's a valid .app Current.shell.codesignVerify = { _ in @@ -711,7 +713,7 @@ final class XcodesKitTests: XCTestCase { XcodesKit.Current.logging.log(prompt) return "asdf" } - Current.shell.unxip = { _ in + Current.shell.unxip = { _ in unxipCallCount += 1 if unxipCallCount == 1 { return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: "The file \"Xcode-0.0.0.xip\" is damaged and can’t be expanded.")) @@ -735,7 +737,7 @@ final class XcodesKitTests: XCTestCase { waitForExpectations(timeout: 1.0) } - + func test_UninstallXcode() { // There are installed Xcodes let installedXcodes = [ @@ -789,19 +791,19 @@ final class XcodesKitTests: XCTestCase { } .cauterize() } - + func test_UninstallInteractively() { - + var log = "" XcodesKit.Current.logging.log = { log.append($0 + "\n") } - + // There are installed Xcodes let installedXcodes = [ InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!, version: Version(0, 0, 0)), InstalledXcode(path: Path("/Applications/Xcode-2.0.1.app")!, version: Version(2, 0, 1)), ] Current.files.installedXcodes = { _ in installedXcodes } - + // It prints the expected paths var xcodeSelectPrintPathCallCount = 0 Current.shell.xcodeSelectPrintPath = { @@ -819,7 +821,7 @@ final class XcodesKitTests: XCTestCase { XcodesKit.Current.logging.log(prompt) return "1" } - + // Trashing succeeds var trashedItemAtURL: URL? Current.files.trashItem = { itemURL in @@ -832,7 +834,7 @@ final class XcodesKitTests: XCTestCase { XCTAssertEqual(trashedItemAtURL, installedXcodes[0].path.url) } .cauterize() - + XCTAssertEqual(log, """ 999.0 is not installed. Available Xcode versions: @@ -866,9 +868,9 @@ final class XcodesKitTests: XCTestCase { Current.files.fileExistsAtPath = { _ in return false } var source: URL? var destination: URL? - Current.files.moveItem = { source = $0; destination = $1 } + Current.files.moveItem = { source = $0; destination = $1 } var removedItemAtURL: URL? - Current.files.removeItem = { removedItemAtURL = $0 } + Current.files.removeItem = { removedItemAtURL = $0 } migrateApplicationSupportFiles() @@ -881,9 +883,9 @@ final class XcodesKitTests: XCTestCase { Current.files.fileExistsAtPath = { return $0.contains("ca.brandonevans") } var source: URL? var destination: URL? - Current.files.moveItem = { source = $0; destination = $1 } + Current.files.moveItem = { source = $0; destination = $1 } var removedItemAtURL: URL? - Current.files.removeItem = { removedItemAtURL = $0 } + Current.files.removeItem = { removedItemAtURL = $0 } migrateApplicationSupportFiles() @@ -896,9 +898,9 @@ final class XcodesKitTests: XCTestCase { Current.files.fileExistsAtPath = { _ in return true } var source: URL? var destination: URL? - Current.files.moveItem = { source = $0; destination = $1 } + Current.files.moveItem = { source = $0; destination = $1 } var removedItemAtURL: URL? - Current.files.removeItem = { removedItemAtURL = $0 } + Current.files.removeItem = { removedItemAtURL = $0 } migrateApplicationSupportFiles() @@ -979,9 +981,9 @@ final class XcodesKitTests: XCTestCase { Current.files.fileExistsAtPath = { return $0.contains("com.robotsandpencils") } var source: URL? var destination: URL? - Current.files.moveItem = { source = $0; destination = $1 } + Current.files.moveItem = { source = $0; destination = $1 } var removedItemAtURL: URL? - Current.files.removeItem = { removedItemAtURL = $0 } + Current.files.removeItem = { removedItemAtURL = $0 } migrateApplicationSupportFiles() @@ -1006,7 +1008,7 @@ final class XcodesKitTests: XCTestCase { Current.files.installedXcodes = { _ in [InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!, - InstalledXcode(path: Path("/Applications/Xcode-2.0.0.app")!)!] + InstalledXcode(path: Path("/Applications/Xcode-2.0.0.app")!)!] } Current.shell.xcodeSelectPrintPath = { Promise.value((status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "")) } @@ -1025,9 +1027,9 @@ final class XcodesKitTests: XCTestCase { XcodesKit.Current.logging.log = { log.append($0 + "\n") } // There are installed Xcodes - Current.files.installedXcodes = { _ in + Current.files.installedXcodes = { _ in [InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!, - InstalledXcode(path: Path("/Applications/Xcode-2.0.1.app")!)!] + InstalledXcode(path: Path("/Applications/Xcode-2.0.1.app")!)!] } Current.files.contentsAtPath = { path in if path == "/Applications/Xcode-0.0.0.app/Contents/Info.plist" { @@ -1097,7 +1099,7 @@ final class XcodesKitTests: XCTestCase { // There are installed Xcodes Current.files.installedXcodes = { _ in [InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!, - InstalledXcode(path: Path("/Applications/Xcode-2.0.1.app")!)!] + InstalledXcode(path: Path("/Applications/Xcode-2.0.1.app")!)!] } Current.files.contentsAtPath = { path in if path == "/Applications/Xcode-0.0.0.app/Contents/Info.plist" { @@ -1268,18 +1270,18 @@ final class XcodesKitTests: XCTestCase { InstalledXcode(path: Path("/Applications/Xcode-2.0.1-Release.Candidate.app")!)! ] Current.files.installedXcodes = { _ in installedXcodes } - + // One is selected Current.shell.xcodeSelectPrintPath = { Promise.value((status: 0, out: "/Applications/Xcode-2.0.1-Release.Candidate.app/Contents/Developer", err: "")) } - + // Standard output is an interactive terminal Current.shell.isatty = { true } installer.printInstalledXcodes(directory: Path.root/"Applications") .cauterize() - + XCTAssertEqual( log, """ @@ -1290,7 +1292,7 @@ final class XcodesKitTests: XCTestCase { """ ) } - + func test_Installed_NonInteractiveTerminal() { var log = "" XcodesKit.Current.logging.log = { log.append($0 + "\n") } @@ -1323,18 +1325,18 @@ final class XcodesKitTests: XCTestCase { InstalledXcode(path: Path("/Applications/Xcode-2.0.1-Release.Candidate.app")!)! ] Current.files.installedXcodes = { _ in installedXcodes } - + // One is selected Current.shell.xcodeSelectPrintPath = { Promise.value((status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "")) } - + // Standard output is not an interactive terminal Current.shell.isatty = { false } installer.printInstalledXcodes(directory: Path.root/"Applications") .cauterize() - + XCTAssertEqual( log, """ @@ -1423,46 +1425,46 @@ final class XcodesKitTests: XCTestCase { installer.printXcodePath(ofVersion: "3", searchingIn: Path.root/"Applications") .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.versionNotInstalled(Version(xcodeVersion: "3")!)) } } - + func test_Signout_WithExistingSession() { var keychainDidRemove = false Current.keychain.remove = { _ in keychainDidRemove = true } - + var customConfig = Configuration() customConfig.defaultUsername = "test@example.com" - let customInstaller = XcodeInstaller(configuration: customConfig, xcodeList: XcodeList()) - + let customController = SessionController(configuration: customConfig) + let expectation = self.expectation(description: "Signout complete") - - customInstaller.logout() + + customController.logout() .ensure { expectation.fulfill() } .catch { XCTFail($0.localizedDescription) } - + waitForExpectations(timeout: 1.0) - + XCTAssertTrue(keychainDidRemove) } - + func test_Signout_WithoutExistingSession() { var customConfig = Configuration() customConfig.defaultUsername = nil - let customInstaller = XcodeInstaller(configuration: customConfig, xcodeList: XcodeList()) - + let customController = SessionController(configuration: customConfig) + var capturedError: Error? - + let expectation = self.expectation(description: "Signout complete") - - customInstaller.logout() + + customController.logout() .ensure { expectation.fulfill() } .catch { error in capturedError = error } waitForExpectations(timeout: 1.0) - + XCTAssertEqual(capturedError as? Client.Error, Client.Error.notAuthenticated) } From 07b644fd18d1d146315136ebdc7c26dab28e682b Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Fri, 7 Oct 2022 17:48:34 +0200 Subject: [PATCH 02/12] Remove the need for an Apple account --- Sources/XcodesKit/RuntimeList.swift | 2 +- Sources/XcodesKit/SessionController.swift | 4 ++++ Sources/XcodesKit/XcodeInstaller.swift | 6 +----- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/XcodesKit/RuntimeList.swift b/Sources/XcodesKit/RuntimeList.swift index b4cea19..4d1a8a5 100644 --- a/Sources/XcodesKit/RuntimeList.swift +++ b/Sources/XcodesKit/RuntimeList.swift @@ -81,7 +81,7 @@ public class RuntimeList { return destination.url } if runtime.authentication == .virtual { - try await sessionController.loginIfNeeded().async() + try await sessionController.validateADCSession(path: url.path).async() } let formatter = NumberFormatter(numberStyle: .percent) var observation: NSKeyValueObservation? diff --git a/Sources/XcodesKit/SessionController.swift b/Sources/XcodesKit/SessionController.swift index d000b4c..2542bd2 100644 --- a/Sources/XcodesKit/SessionController.swift +++ b/Sources/XcodesKit/SessionController.swift @@ -33,6 +33,10 @@ public class SessionController { return nil } + func validateADCSession(path: String) -> Promise { + return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid() + } + func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise { return firstly { () -> Promise in return Current.network.validateSession() diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 4f1b728..068e1b3 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -313,7 +313,7 @@ public final class XcodeInstaller { case .xcodeReleases: /// Now that we've used Xcode Releases to determine what URL we should use to /// download Xcode, we can use that to establish an anonymous session with Apple. - return self.validateADCSession(path: xcode.downloadPath).map { xcode } + return self.sessionController.validateADCSession(path: xcode.downloadPath).map { xcode } } } .then { xcode -> Promise<(Xcode, URL)> in @@ -342,10 +342,6 @@ public final class XcodeInstaller { } } - func validateADCSession(path: String) -> Promise { - return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid() - } - public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, willInstall: Bool, progressChanged: @escaping (Progress) -> Void) -> Promise { // Check to see if the archive is in the expected path in case it was downloaded but failed to install let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(xcode.version).\(xcode.filename.suffix(fromLast: "."))" From 4846fded43f2823b4ae7da02f5caf02bd0201f76 Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Sat, 15 Oct 2022 21:52:24 +0200 Subject: [PATCH 03/12] Support installing runtimes --- Sources/XcodesKit/Environment.swift | 6 ++ Sources/XcodesKit/Path+.swift | 1 + Sources/XcodesKit/RuntimeList.swift | 97 +++++++++++++++++++---------- 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index cd84705..c5442fb 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -24,6 +24,12 @@ public var Current = Environment() public struct Shell { public var unxip: (URL) -> Promise = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") } + public var mountDmg: (URL) -> Promise = { Process.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path) } + public var unmountDmg: (URL) -> Promise = { Process.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) } + public var expandPkg: (URL, URL) -> Promise = { Process.run(Path.root.usr.sbin.join("pkgutil"), "--expand", $0.path, $1.path) } + public var createPkg: (URL, URL) -> Promise = { Process.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path) } + public var installPkg: (URL, String, String?) -> Promise = { Process.sudo(password: $2, Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) } + public var installRuntimeImage: (URL) -> Promise = { Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $0.path) } public var spctlAssess: (URL) -> Promise = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") } public var codesignVerify: (URL) -> Promise = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") } public var devToolsSecurityEnable: (String?) -> Promise = { Process.sudo(password: $0, Path.root.usr.sbin.DevToolsSecurity, "-enable") } diff --git a/Sources/XcodesKit/Path+.swift b/Sources/XcodesKit/Path+.swift index 44315a6..24ce322 100644 --- a/Sources/XcodesKit/Path+.swift +++ b/Sources/XcodesKit/Path+.swift @@ -3,6 +3,7 @@ import Path extension Path { static let oldXcodesApplicationSupport = Path.applicationSupport/"ca.brandonevans.xcodes" static let xcodesApplicationSupport = Path.applicationSupport/"com.robotsandpencils.xcodes" + static let xcodesCaches = Path.caches/"com.robotsandpencils.xcodes" static let cacheFile = xcodesApplicationSupport/"available-xcodes.json" static let configurationFile = xcodesApplicationSupport/"configuration.json" } diff --git a/Sources/XcodesKit/RuntimeList.swift b/Sources/XcodesKit/RuntimeList.swift index 4d1a8a5..5130762 100644 --- a/Sources/XcodesKit/RuntimeList.swift +++ b/Sources/XcodesKit/RuntimeList.swift @@ -2,6 +2,7 @@ import PromiseKit import Foundation import Version import Path +import AppleAPI public class RuntimeList { @@ -57,7 +58,63 @@ public class RuntimeList { guard let matchedRuntime = downloadables.first(where: { $0.visibleIdentifier == identifier }) else { throw Error.unavailableRuntime(identifier) } - _ = try await download(runtime: matchedRuntime, downloader: downloader) + let dmgUrl = try await download(runtime: matchedRuntime, downloader: downloader) + switch matchedRuntime.contentType { + case .package: + try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) + case .diskImage: + try await installFromImage(dmgUrl: dmgUrl) + } + } + + private func installFromImage(dmgUrl: URL) async throws { + Current.logging.log("Installing Runtime") + try await Current.shell.installRuntimeImage(dmgUrl).asVoid().async() + } + + private func installFromPackage(dmgUrl: URL, runtime: DownloadableRuntime) async throws { + Current.logging.log("Mounting DMG") + let mountedUrl = try await mountDMG(dmgUrl: dmgUrl) + let pkgPath = try! Path(url: mountedUrl)!.ls().first!.path + try Path.xcodesCaches.mkdir() + let expandedPkgPath = Path.xcodesCaches/runtime.identifier + try expandedPkgPath.delete() + _ = try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url).async() + try await unmountDMG(mountedURL: mountedUrl) + let packageInfoPath = expandedPkgPath/"PackageInfo" + var packageInfoContents = try String(contentsOf: packageInfoPath) + let runtimeFileName = "\(runtime.platform.shortName) \(runtime.simulatorVersion.version).simruntime" + let runtimeDestination = Path("/Library/Developer/CoreSimulator/Profiles/Runtimes/\(runtimeFileName)")! + packageInfoContents = packageInfoContents.replacingOccurrences(of: " { seal in + Current.logging.log("xcodes requires superuser privileges in order to finish installation.") + guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(XcodeInstaller.Error.missingSudoerPassword); return } + seal.fulfill(password + "\n") + } + } + let possiblePassword = try await Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput).async() + _ = try await Current.shell.installPkg(newPkgPath.url, "/", possiblePassword).async() + try newPkgPath.delete() + } + + private func mountDMG(dmgUrl: URL) async throws -> URL { + let resultPlist = try await Current.shell.mountDmg(dmgUrl).async() + 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) + } + + private func unmountDMG(mountedURL: URL) async throws { + _ = try await Current.shell.unmountDmg(mountedURL).async() } private func download(runtime: DownloadableRuntime, downloader: Downloader) async throws -> URL { @@ -73,11 +130,11 @@ public class RuntimeList { // Move to the next line so that the escape codes below can move up a line and overwrite it with download progress Current.logging.log("") } else { - Current.logging.log("\(InstallationStep.downloading(identifier: runtime.visibleIdentifier, progress: nil))") + Current.logging.log("Downloading Runtime \(runtime.visibleIdentifier)") } if Current.files.fileExistsAtPath(destination.string), aria2DownloadIsIncomplete == false { - Current.logging.log("(1/1) Found existing Runtime that will be used for installation at \(destination).") + Current.logging.log("Found existing Runtime that will be used for installation at \(destination).") return destination.url } if runtime.authentication == .virtual { @@ -90,7 +147,7 @@ public class RuntimeList { observation = progress.observe(\.fractionCompleted) { progress, _ in guard Current.shell.isatty() else { return } // These escape codes move up a line and then clear to the end - Current.logging.log("\u{1B}[1A\u{1B}[K\(InstallationStep.downloading(identifier: runtime.visibleIdentifier, progress: formatter.string(from: progress.fractionCompleted)!))") + Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)))") } }).async() observation?.invalidate() @@ -101,43 +158,17 @@ public class RuntimeList { extension RuntimeList { public enum Error: LocalizedError, Equatable { case unavailableRuntime(String) + case failedMountingDMG public var errorDescription: String? { switch self { case let .unavailableRuntime(version): return "Could not find runtime \(version)." + case .failedMountingDMG: + return "Failed to mount image." } } } - - enum InstallationStep: CustomStringConvertible { - case downloading(identifier: String, progress: String?) - - var description: String { - return "(\(stepNumber)/\(InstallationStep.installStepCount)) \(message)" - } - - var message: String { - switch self { - case .downloading(let version, let progress): - if let progress = progress { - return "Downloading Runtime \(version): \(progress)" - } else { - return "Downloading Runtime \(version)" - } - } - } - - var stepNumber: Int { - switch self { - case .downloading: return 1 - } - } - - static var installStepCount: Int { - return 1 - } - } } extension Array { From d7451825ee59c28173a66e796aa8ef33b250ee69 Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Sat, 15 Oct 2022 23:17:25 +0200 Subject: [PATCH 04/12] Rename `SessionController` to `AppleSessionService` --- ...roller.swift => AppleSessionService.swift} | 4 +- Sources/XcodesKit/RuntimeList.swift | 8 ++-- Sources/XcodesKit/XcodeInstaller.swift | 38 +++++++++---------- Sources/xcodes/App.swift | 10 ++--- Tests/XcodesKitTests/XcodesKitTests.swift | 16 ++++---- 5 files changed, 38 insertions(+), 38 deletions(-) rename Sources/XcodesKit/{SessionController.swift => AppleSessionService.swift} (98%) diff --git a/Sources/XcodesKit/SessionController.swift b/Sources/XcodesKit/AppleSessionService.swift similarity index 98% rename from Sources/XcodesKit/SessionController.swift rename to Sources/XcodesKit/AppleSessionService.swift index 2542bd2..3a7ca5e 100644 --- a/Sources/XcodesKit/SessionController.swift +++ b/Sources/XcodesKit/AppleSessionService.swift @@ -2,7 +2,7 @@ import PromiseKit import Foundation import AppleAPI -public class SessionController { +public class AppleSessionService { private let xcodesUsername = "XCODES_USERNAME" private let xcodesPassword = "XCODES_PASSWORD" @@ -130,7 +130,7 @@ public class SessionController { } } -extension SessionController { +extension AppleSessionService { enum Error: LocalizedError, Equatable { case missingUsernameOrPassword diff --git a/Sources/XcodesKit/RuntimeList.swift b/Sources/XcodesKit/RuntimeList.swift index 5130762..382db2b 100644 --- a/Sources/XcodesKit/RuntimeList.swift +++ b/Sources/XcodesKit/RuntimeList.swift @@ -6,10 +6,10 @@ import AppleAPI public class RuntimeList { - private var sessionController: SessionController + private var sessionService: AppleSessionService - public init(sessionController: SessionController) { - self.sessionController = sessionController + public init(sessionService: AppleSessionService) { + self.sessionService = sessionService } public func printAvailableRuntimes(includeBetas: Bool) async throws { @@ -138,7 +138,7 @@ public class RuntimeList { return destination.url } if runtime.authentication == .virtual { - try await sessionController.validateADCSession(path: url.path).async() + try await sessionService.validateADCSession(path: url.path).async() } let formatter = NumberFormatter(numberStyle: .percent) var observation: NSKeyValueObservation? diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 068e1b3..5ca8543 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -143,11 +143,11 @@ public final class XcodeInstaller { } } - private var sessionController: SessionController + private var sessionService: AppleSessionService private var xcodeList: XcodeList - public init(sessionController: SessionController, xcodeList: XcodeList) { - self.sessionController = sessionController + public init(sessionService: AppleSessionService, xcodeList: XcodeList) { + self.sessionService = sessionService self.xcodeList = xcodeList } @@ -276,17 +276,17 @@ public final class XcodeInstaller { return firstly { () -> Promise in switch dataSource { case .apple: - // When using the Apple data source, an authenticated session is required for both - // downloading the list of Xcodes as well as to actually download Xcode, so we'll - // establish our session right at the start. - return sessionController.loginIfNeeded() + // When using the Apple data source, an authenticated session is required for both + // downloading the list of Xcodes as well as to actually download Xcode, so we'll + // establish our session right at the start. + return sessionService.loginIfNeeded() case .xcodeReleases: - // When using the Xcode Releases data source, we only need to establish an anonymous - // session once we're ready to download Xcode. Doing that requires us to know the - // URL we want to download though (and we may not know that yet), so we don't need - // to do anything session-related quite yet. - return Promise() + // When using the Xcode Releases data source, we only need to establish an anonymous + // session once we're ready to download Xcode. Doing that requires us to know the + // URL we want to download though (and we may not know that yet), so we don't need + // to do anything session-related quite yet. + return Promise() } } .then { () -> Promise in @@ -306,14 +306,14 @@ public final class XcodeInstaller { .then { xcode -> Promise in switch dataSource { case .apple: - /// We already established a session for the Apple data source at the beginning of - /// this download, so we don't need to do anything session-related at this point. - return Promise.value(xcode) + /// We already established a session for the Apple data source at the beginning of + /// this download, so we don't need to do anything session-related at this point. + return Promise.value(xcode) case .xcodeReleases: - /// Now that we've used Xcode Releases to determine what URL we should use to - /// download Xcode, we can use that to establish an anonymous session with Apple. - return self.sessionController.validateADCSession(path: xcode.downloadPath).map { xcode } + /// Now that we've used Xcode Releases to determine what URL we should use to + /// download Xcode, we can use that to establish an anonymous session with Apple. + return self.sessionService.validateADCSession(path: xcode.downloadPath).map { xcode } } } .then { xcode -> Promise<(Xcode, URL)> in @@ -480,7 +480,7 @@ public final class XcodeInstaller { func update(dataSource: DataSource) -> Promise<[Xcode]> { if dataSource == .apple { return firstly { () -> Promise in - sessionController.loginIfNeeded() + sessionService.loginIfNeeded() } .then { () -> Promise<[Xcode]> in self.xcodeList.update(dataSource: dataSource) diff --git a/Sources/xcodes/App.swift b/Sources/xcodes/App.swift index 8855c70..788064a 100644 --- a/Sources/xcodes/App.swift +++ b/Sources/xcodes/App.swift @@ -60,16 +60,16 @@ struct Xcodes: AsyncParsableCommand { ) static var xcodesConfiguration = Configuration() - static var sessionController: SessionController! + static var sessionService: AppleSessionService! static let xcodeList = XcodeList() static var runtimes: RuntimeList! static var installer: XcodeInstaller! static func main() async { try? xcodesConfiguration.load() - sessionController = SessionController(configuration: xcodesConfiguration) - installer = XcodeInstaller(sessionController: sessionController, xcodeList: xcodeList) - runtimes = RuntimeList(sessionController: sessionController) + sessionService = AppleSessionService(configuration: xcodesConfiguration) + installer = XcodeInstaller(sessionService: sessionService, xcodeList: xcodeList) + runtimes = RuntimeList(sessionService: sessionService) migrateApplicationSupportFiles() do { var command = try parseAsRoot() @@ -541,7 +541,7 @@ struct Xcodes: AsyncParsableCommand { func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color - sessionController.logout() + sessionService.logout() .done { Current.logging.log("Successfully signed out".green) Signout.exit() diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 144e8a1..5bc1078 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -12,7 +12,7 @@ final class XcodesKitTests: XCTestCase { var installer: XcodeInstaller! var runtimeList: RuntimeList! - var sessionController: SessionController! + var sessionService: AppleSessionService! override class func setUp() { super.setUp() @@ -24,9 +24,9 @@ final class XcodesKitTests: XCTestCase { Current = .mock Rainbow.outputTarget = .unknown Rainbow.enabled = false - sessionController = SessionController(configuration: Configuration()) - installer = XcodeInstaller(sessionController: sessionController, xcodeList: XcodeList()) - runtimeList = RuntimeList(sessionController: sessionController) + sessionService = AppleSessionService(configuration: Configuration()) + installer = XcodeInstaller(sessionService: sessionService, xcodeList: XcodeList()) + runtimeList = RuntimeList(sessionService: sessionService) } func test_ParseCertificateInfo_Succeeds() throws { @@ -1434,11 +1434,11 @@ final class XcodesKitTests: XCTestCase { var customConfig = Configuration() customConfig.defaultUsername = "test@example.com" - let customController = SessionController(configuration: customConfig) + let customService = AppleSessionService(configuration: customConfig) let expectation = self.expectation(description: "Signout complete") - customController.logout() + customService.logout() .ensure { expectation.fulfill() } .catch { XCTFail($0.localizedDescription) @@ -1452,13 +1452,13 @@ final class XcodesKitTests: XCTestCase { func test_Signout_WithoutExistingSession() { var customConfig = Configuration() customConfig.defaultUsername = nil - let customController = SessionController(configuration: customConfig) + let customService = AppleSessionService(configuration: customConfig) var capturedError: Error? let expectation = self.expectation(description: "Signout complete") - customController.logout() + customService.logout() .ensure { expectation.fulfill() } .catch { error in capturedError = error From 2b7d5db445dc14399077ce4b8ada4d43f9e67815 Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Wed, 19 Oct 2022 11:24:19 +0200 Subject: [PATCH 05/12] Fix Aria bug --- Sources/XcodesKit/AppleSessionService.swift | 16 ++++++++-------- Sources/XcodesKit/Downloader.swift | 2 +- Sources/XcodesKit/Environment.swift | 1 + Sources/XcodesKit/RuntimeList.swift | 3 ++- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Sources/XcodesKit/AppleSessionService.swift b/Sources/XcodesKit/AppleSessionService.swift index 3a7ca5e..6fe5766 100644 --- a/Sources/XcodesKit/AppleSessionService.swift +++ b/Sources/XcodesKit/AppleSessionService.swift @@ -34,7 +34,7 @@ public class AppleSessionService { } func validateADCSession(path: String) -> Promise { - return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid() + return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid() } func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise { @@ -89,13 +89,13 @@ public class AppleSessionService { .recover { error -> Promise in if let error = error as? Client.Error { - switch error { - case .invalidUsernameOrPassword(_): - // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. - try? Current.keychain.remove(username) - default: - break - } + switch error { + case .invalidUsernameOrPassword(_): + // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. + try? Current.keychain.remove(username) + default: + break + } } return Promise(error: error) diff --git a/Sources/XcodesKit/Downloader.swift b/Sources/XcodesKit/Downloader.swift index ed75128..fd33759 100644 --- a/Sources/XcodesKit/Downloader.swift +++ b/Sources/XcodesKit/Downloader.swift @@ -19,7 +19,7 @@ public enum Downloader { return withUrlSession(url: url, to: destination, progressChanged: progressChanged) case .aria2(let aria2Path): if Current.shell.isatty() { - Current.logging.log("Downloading with aria2 at \(aria2Path)".green) + Current.logging.log("Downloading with aria2 (\(aria2Path))".green) // Add 1 extra line as we are overwriting with download progress Current.logging.log("") } diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index c5442fb..85ab5ce 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -75,6 +75,7 @@ public struct Shell { } public var downloadWithAria2: (Path, URL, Path, [HTTPCookie]) -> (Progress, Promise) = { aria2Path, url, destination, cookies in + precondition(Thread.isMainThread, "Aria must be called on the main queue") let process = Process() process.executableURL = aria2Path.url process.arguments = [ diff --git a/Sources/XcodesKit/RuntimeList.swift b/Sources/XcodesKit/RuntimeList.swift index 382db2b..013d81c 100644 --- a/Sources/XcodesKit/RuntimeList.swift +++ b/Sources/XcodesKit/RuntimeList.swift @@ -117,6 +117,7 @@ public class RuntimeList { _ = try await Current.shell.unmountDmg(mountedURL).async() } + @MainActor private func download(runtime: DownloadableRuntime, downloader: Downloader) async throws -> URL { let url = URL(string: runtime.source)! let destination = Path.xcodesApplicationSupport/url.lastPathComponent @@ -147,7 +148,7 @@ public class RuntimeList { observation = progress.observe(\.fractionCompleted) { progress, _ in guard Current.shell.isatty() else { return } // These escape codes move up a line and then clear to the end - Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)))") + Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)") } }).async() observation?.invalidate() From 1163217cca1108b4bb4376acb5ef655e801c3ee6 Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Tue, 25 Oct 2022 02:10:35 +0200 Subject: [PATCH 06/12] Some refactoring --- Sources/XcodesKit/RuntimeInstaller.swift | 174 ++++++++++++++++++++++ Sources/XcodesKit/RuntimeList.swift | 168 +-------------------- Sources/XcodesKit/XcodeInstaller.swift | 4 +- Sources/xcodes/App.swift | 31 ++-- Tests/XcodesKitTests/XcodesKitTests.swift | 54 +++---- 5 files changed, 222 insertions(+), 209 deletions(-) create mode 100644 Sources/XcodesKit/RuntimeInstaller.swift diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift new file mode 100644 index 0000000..25dcea6 --- /dev/null +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -0,0 +1,174 @@ +import PromiseKit +import Foundation +import Version +import Path +import AppleAPI + +public class RuntimeInstaller { + + private var sessionService: AppleSessionService + private var runtimeList: RuntimeList + + public init(runtimeList: RuntimeList, sessionService: AppleSessionService) { + self.runtimeList = runtimeList + self.sessionService = sessionService + } + + public func printAvailableRuntimes(includeBetas: Bool) async throws { + let downloadables = try await runtimeList.downloadableRuntimes(includeBetas: includeBetas) + var installed = try await runtimeList.installedRuntimes() + for (platform, downloadables) in Dictionary(grouping: downloadables, by: \.platform).sorted(\.key.order) { + Current.logging.log("-- \(platform.shortName) --") + for downloadable in downloadables { + let matchingInstalledRuntimes = installed.remove { $0.build == downloadable.simulatorVersion.buildUpdate } + let name = downloadable.visibleIdentifier + if !matchingInstalledRuntimes.isEmpty { + for matchingInstalledRuntime in matchingInstalledRuntimes { + switch matchingInstalledRuntime.kind { + case .bundled: + Current.logging.log(name + " (Bundled with selected Xcode)") + case .diskImage, .legacyDownload: + Current.logging.log(name + " (Downloaded)") + } + } + } else { + Current.logging.log(name) + } + } + } + Current.logging.log("\nNote: Bundled runtimes are indicated for the currently selected Xcode, more bundled runtimes may exist in other Xcode(s)") + } + + public func downloadAndInstallRuntime(identifier: String, downloader: Downloader) async throws { + let downloadables = try await runtimeList.downloadableRuntimes(includeBetas: true) + guard let matchedRuntime = downloadables.first(where: { $0.visibleIdentifier == identifier }) else { + throw Error.unavailableRuntime(identifier) + } + let dmgUrl = try await download(runtime: matchedRuntime, downloader: downloader) + switch matchedRuntime.contentType { + case .package: + try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) + case .diskImage: + try await installFromImage(dmgUrl: dmgUrl) + } + } + + private func installFromImage(dmgUrl: URL) async throws { + Current.logging.log("Installing Runtime") + try await Current.shell.installRuntimeImage(dmgUrl).asVoid().async() + } + + private func installFromPackage(dmgUrl: URL, runtime: DownloadableRuntime) async throws { + Current.logging.log("Mounting DMG") + let mountedUrl = try await mountDMG(dmgUrl: dmgUrl) + let pkgPath = try! Path(url: mountedUrl)!.ls().first!.path + try Path.xcodesCaches.mkdir() + let expandedPkgPath = Path.xcodesCaches/runtime.identifier + try expandedPkgPath.delete() + _ = try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url).async() + try await unmountDMG(mountedURL: mountedUrl) + let packageInfoPath = expandedPkgPath/"PackageInfo" + var packageInfoContents = try String(contentsOf: packageInfoPath) + let runtimeFileName = "\(runtime.platform.shortName) \(runtime.simulatorVersion.version).simruntime" + let runtimeDestination = Path("/Library/Developer/CoreSimulator/Profiles/Runtimes/\(runtimeFileName)")! + packageInfoContents = packageInfoContents.replacingOccurrences(of: " { seal in + Current.logging.log("xcodes requires superuser privileges in order to finish installation.") + guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(XcodeInstaller.Error.missingSudoerPassword); return } + seal.fulfill(password + "\n") + } + } + let possiblePassword = try await Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput).async() + _ = try await Current.shell.installPkg(newPkgPath.url, "/", possiblePassword).async() + try newPkgPath.delete() + } + + private func mountDMG(dmgUrl: URL) async throws -> URL { + let resultPlist = try await Current.shell.mountDmg(dmgUrl).async() + 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) + } + + private func unmountDMG(mountedURL: URL) async throws { + _ = try await Current.shell.unmountDmg(mountedURL).async() + } + + @MainActor + private func download(runtime: DownloadableRuntime, downloader: Downloader) async throws -> URL { + let url = URL(string: runtime.source)! + let destination = Path.xcodesApplicationSupport/url.lastPathComponent + let aria2DownloadMetadataPath = destination.parent/(destination.basename() + ".aria2") + var aria2DownloadIsIncomplete = false + if case .aria2 = downloader, aria2DownloadMetadataPath.exists { + aria2DownloadIsIncomplete = true + } + + if Current.shell.isatty() { + // Move to the next line so that the escape codes below can move up a line and overwrite it with download progress + Current.logging.log("") + } else { + Current.logging.log("Downloading Runtime \(runtime.visibleIdentifier)") + } + + if Current.files.fileExistsAtPath(destination.string), aria2DownloadIsIncomplete == false { + Current.logging.log("Found existing Runtime that will be used for installation at \(destination).") + return destination.url + } + if runtime.authentication == .virtual { + try await sessionService.validateADCSession(path: url.path).async() + } + let formatter = NumberFormatter(numberStyle: .percent) + var observation: NSKeyValueObservation? + let result = try await downloader.download(url: url, to: destination, progressChanged: { progress in + observation?.invalidate() + observation = progress.observe(\.fractionCompleted) { progress, _ in + guard Current.shell.isatty() else { return } + // These escape codes move up a line and then clear to the end + Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)") + } + }).async() + observation?.invalidate() + return result + } +} + +extension RuntimeInstaller { + public enum Error: LocalizedError, Equatable { + case unavailableRuntime(String) + case failedMountingDMG + + public var errorDescription: String? { + switch self { + case let .unavailableRuntime(version): + return "Could not find runtime \(version)." + case .failedMountingDMG: + return "Failed to mount image." + } + } + } +} + +extension Array { + fileprivate mutating func remove(where predicate: ((Element) -> Bool)) -> [Element] { + guard !isEmpty else { return [] } + var removed: [Element] = [] + self = filter { current in + let satisfy = predicate(current) + if satisfy { + removed.append(current) + } + return !satisfy + } + return removed + } +} diff --git a/Sources/XcodesKit/RuntimeList.swift b/Sources/XcodesKit/RuntimeList.swift index 013d81c..fff8d3d 100644 --- a/Sources/XcodesKit/RuntimeList.swift +++ b/Sources/XcodesKit/RuntimeList.swift @@ -1,41 +1,8 @@ -import PromiseKit import Foundation -import Version -import Path -import AppleAPI public class RuntimeList { - private var sessionService: AppleSessionService - - public init(sessionService: AppleSessionService) { - self.sessionService = sessionService - } - - public func printAvailableRuntimes(includeBetas: Bool) async throws { - let downloadables = try await downloadableRuntimes(includeBetas: includeBetas) - var installed = try await installedRuntimes() - for (platform, downloadables) in Dictionary(grouping: downloadables, by: \.platform).sorted(\.key.order) { - Current.logging.log("-- \(platform.shortName) --") - for downloadable in downloadables { - let matchingInstalledRuntimes = installed.remove { $0.build == downloadable.simulatorVersion.buildUpdate } - let name = downloadable.visibleIdentifier - if !matchingInstalledRuntimes.isEmpty { - for matchingInstalledRuntime in matchingInstalledRuntimes { - switch matchingInstalledRuntime.kind { - case .bundled: - Current.logging.log(name + " (Bundled with selected Xcode)") - case .diskImage, .legacyDownload: - Current.logging.log(name + " (Downloaded)") - } - } - } else { - Current.logging.log(name) - } - } - } - Current.logging.log("\nNote: Bundled runtimes are indicated for the currently selected Xcode, more bundled runtimes may exist in other Xcode(s)") - } + public init() {} func downloadableRuntimes(includeBetas: Bool) async throws -> [DownloadableRuntime] { let (data, _) = try await Current.network.dataTask(with: URLRequest.runtimes).async() @@ -52,137 +19,4 @@ public class RuntimeList { return first.identifier.uuidString.compare(second.identifier.uuidString, options: .numeric) == .orderedAscending } } - - public func downloadAndInstallRuntime(identifier: String, downloader: Downloader) async throws { - let downloadables = try await downloadableRuntimes(includeBetas: true) - guard let matchedRuntime = downloadables.first(where: { $0.visibleIdentifier == identifier }) else { - throw Error.unavailableRuntime(identifier) - } - let dmgUrl = try await download(runtime: matchedRuntime, downloader: downloader) - switch matchedRuntime.contentType { - case .package: - try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) - case .diskImage: - try await installFromImage(dmgUrl: dmgUrl) - } - } - - private func installFromImage(dmgUrl: URL) async throws { - Current.logging.log("Installing Runtime") - try await Current.shell.installRuntimeImage(dmgUrl).asVoid().async() - } - - private func installFromPackage(dmgUrl: URL, runtime: DownloadableRuntime) async throws { - Current.logging.log("Mounting DMG") - let mountedUrl = try await mountDMG(dmgUrl: dmgUrl) - let pkgPath = try! Path(url: mountedUrl)!.ls().first!.path - try Path.xcodesCaches.mkdir() - let expandedPkgPath = Path.xcodesCaches/runtime.identifier - try expandedPkgPath.delete() - _ = try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url).async() - try await unmountDMG(mountedURL: mountedUrl) - let packageInfoPath = expandedPkgPath/"PackageInfo" - var packageInfoContents = try String(contentsOf: packageInfoPath) - let runtimeFileName = "\(runtime.platform.shortName) \(runtime.simulatorVersion.version).simruntime" - let runtimeDestination = Path("/Library/Developer/CoreSimulator/Profiles/Runtimes/\(runtimeFileName)")! - packageInfoContents = packageInfoContents.replacingOccurrences(of: " { seal in - Current.logging.log("xcodes requires superuser privileges in order to finish installation.") - guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(XcodeInstaller.Error.missingSudoerPassword); return } - seal.fulfill(password + "\n") - } - } - let possiblePassword = try await Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput).async() - _ = try await Current.shell.installPkg(newPkgPath.url, "/", possiblePassword).async() - try newPkgPath.delete() - } - - private func mountDMG(dmgUrl: URL) async throws -> URL { - let resultPlist = try await Current.shell.mountDmg(dmgUrl).async() - 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) - } - - private func unmountDMG(mountedURL: URL) async throws { - _ = try await Current.shell.unmountDmg(mountedURL).async() - } - - @MainActor - private func download(runtime: DownloadableRuntime, downloader: Downloader) async throws -> URL { - let url = URL(string: runtime.source)! - let destination = Path.xcodesApplicationSupport/url.lastPathComponent - let aria2DownloadMetadataPath = destination.parent/(destination.basename() + ".aria2") - var aria2DownloadIsIncomplete = false - if case .aria2 = downloader, aria2DownloadMetadataPath.exists { - aria2DownloadIsIncomplete = true - } - - if Current.shell.isatty() { - // Move to the next line so that the escape codes below can move up a line and overwrite it with download progress - Current.logging.log("") - } else { - Current.logging.log("Downloading Runtime \(runtime.visibleIdentifier)") - } - - if Current.files.fileExistsAtPath(destination.string), aria2DownloadIsIncomplete == false { - Current.logging.log("Found existing Runtime that will be used for installation at \(destination).") - return destination.url - } - if runtime.authentication == .virtual { - try await sessionService.validateADCSession(path: url.path).async() - } - let formatter = NumberFormatter(numberStyle: .percent) - var observation: NSKeyValueObservation? - let result = try await downloader.download(url: url, to: destination, progressChanged: { progress in - observation?.invalidate() - observation = progress.observe(\.fractionCompleted) { progress, _ in - guard Current.shell.isatty() else { return } - // These escape codes move up a line and then clear to the end - Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)") - } - }).async() - observation?.invalidate() - return result - } -} - -extension RuntimeList { - public enum Error: LocalizedError, Equatable { - case unavailableRuntime(String) - case failedMountingDMG - - public var errorDescription: String? { - switch self { - case let .unavailableRuntime(version): - return "Could not find runtime \(version)." - case .failedMountingDMG: - return "Failed to mount image." - } - } - } -} - -extension Array { - fileprivate mutating func remove(where predicate: ((Element) -> Bool)) -> [Element] { - guard !isEmpty else { return [] } - var removed: [Element] = [] - self = filter { current in - let satisfy = predicate(current) - if satisfy { - removed.append(current) - } - return !satisfy - } - return removed - } } diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 5ca8543..9522dd7 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -146,9 +146,9 @@ public final class XcodeInstaller { private var sessionService: AppleSessionService private var xcodeList: XcodeList - public init(sessionService: AppleSessionService, xcodeList: XcodeList) { - self.sessionService = sessionService + public init(xcodeList: XcodeList, sessionService: AppleSessionService) { self.xcodeList = xcodeList + self.sessionService = sessionService } public enum InstallationType { diff --git a/Sources/xcodes/App.swift b/Sources/xcodes/App.swift index 788064a..4cb0c71 100644 --- a/Sources/xcodes/App.swift +++ b/Sources/xcodes/App.swift @@ -62,14 +62,15 @@ struct Xcodes: AsyncParsableCommand { static var xcodesConfiguration = Configuration() static var sessionService: AppleSessionService! static let xcodeList = XcodeList() - static var runtimes: RuntimeList! - static var installer: XcodeInstaller! + static let runtimeList = RuntimeList() + static var runtimeInstaller: RuntimeInstaller! + static var xcodeInstaller: XcodeInstaller! static func main() async { try? xcodesConfiguration.load() sessionService = AppleSessionService(configuration: xcodesConfiguration) - installer = XcodeInstaller(sessionService: sessionService, xcodeList: xcodeList) - runtimes = RuntimeList(sessionService: sessionService) + xcodeInstaller = XcodeInstaller(xcodeList: xcodeList, sessionService: sessionService) + runtimeInstaller = RuntimeInstaller(runtimeList: runtimeList, sessionService: sessionService) migrateApplicationSupportFiles() do { var command = try parseAsRoot() @@ -149,7 +150,7 @@ struct Xcodes: AsyncParsableCommand { let destination = getDirectory(possibleDirectory: directory, default: Path.home.join("Downloads")) - installer.download(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destinationDirectory: destination) + xcodeInstaller.download(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destinationDirectory: destination) .catch { error in Install.processDownloadOrInstall(error: error) } @@ -272,10 +273,10 @@ struct Xcodes: AsyncParsableCommand { Current.logging.log("Updating...") return xcodeList.update(dataSource: globalDataSource.dataSource) .then { _ -> Promise in - installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) + xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) } } else { - return installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) + return xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) } } .then { xcode -> Promise in @@ -314,11 +315,11 @@ struct Xcodes: AsyncParsableCommand { let directory = getDirectory(possibleDirectory: globalDirectory.directory) - installer.printXcodePath(ofVersion: version.joined(separator: " "), searchingIn: directory) + xcodeInstaller.printXcodePath(ofVersion: version.joined(separator: " "), searchingIn: directory) .recover { error -> Promise in switch error { case XcodeInstaller.Error.invalidVersion: - return installer.printInstalledXcodes(directory: directory) + return xcodeInstaller.printInstalledXcodes(directory: directory) default: throw error } @@ -351,10 +352,10 @@ struct Xcodes: AsyncParsableCommand { firstly { () -> Promise in if xcodeList.shouldUpdateBeforeListingVersions { - return installer.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) + return xcodeInstaller.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) } else { - return installer.printAvailableXcodes(xcodeList.availableXcodes, installed: Current.files.installedXcodes(directory)) + return xcodeInstaller.printAvailableXcodes(xcodeList.availableXcodes, installed: Current.files.installedXcodes(directory)) } } .done { List.exit() } @@ -374,7 +375,7 @@ struct Xcodes: AsyncParsableCommand { var includeBetas: Bool = false func run() async throws { - try await runtimes.printAvailableRuntimes(includeBetas: includeBetas) + try await runtimeInstaller.printAvailableRuntimes(includeBetas: includeBetas) } struct Install: AsyncParsableCommand { @@ -404,7 +405,7 @@ struct Xcodes: AsyncParsableCommand { noAria2 == false { downloader = .aria2(aria2Path) } - try await runtimes.downloadAndInstallRuntime(identifier: version, downloader: downloader) + try await runtimeInstaller.downloadAndInstallRuntime(identifier: version, downloader: downloader) Current.logging.log("Finished") } } @@ -480,7 +481,7 @@ struct Xcodes: AsyncParsableCommand { let directory = getDirectory(possibleDirectory: globalDirectory.directory) - installer.uninstallXcode(version.joined(separator: " "), directory: directory, emptyTrash: emptyTrash) + xcodeInstaller.uninstallXcode(version.joined(separator: " "), directory: directory, emptyTrash: emptyTrash) .done { Uninstall.exit() } .catch { error in Uninstall.exit(withLegibleError: error) } @@ -507,7 +508,7 @@ struct Xcodes: AsyncParsableCommand { let directory = getDirectory(possibleDirectory: globalDirectory.directory) - installer.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) + xcodeInstaller.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) .done { Update.exit() } .catch { error in Update.exit(withLegibleError: error) } diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 5bc1078..3b3c92c 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -10,8 +10,10 @@ import Rainbow final class XcodesKitTests: XCTestCase { static let mockXcode = Xcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - var installer: XcodeInstaller! + var xcodeList: XcodeList! + var xcodeInstaller: XcodeInstaller! var runtimeList: RuntimeList! + var runtimeInstaller: RuntimeInstaller! var sessionService: AppleSessionService! override class func setUp() { @@ -25,8 +27,10 @@ final class XcodesKitTests: XCTestCase { Rainbow.outputTarget = .unknown Rainbow.enabled = false sessionService = AppleSessionService(configuration: Configuration()) - installer = XcodeInstaller(sessionService: sessionService, xcodeList: XcodeList()) - runtimeList = RuntimeList(sessionService: sessionService) + xcodeList = XcodeList() + xcodeInstaller = XcodeInstaller(xcodeList: xcodeList, sessionService: sessionService) + runtimeList = RuntimeList() + runtimeInstaller = RuntimeInstaller(runtimeList: runtimeList, sessionService: sessionService) } func test_ParseCertificateInfo_Succeeds() throws { @@ -44,7 +48,7 @@ final class XcodesKitTests: XCTestCase { Sealed Resources version=2 rules=13 files=253327 Internal requirements count=1 size=68 """ - let info = installer.parseCertificateInfo(sampleRawInfo) + let info = xcodeInstaller.parseCertificateInfo(sampleRawInfo) XCTAssertEqual(info.authority, ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"]) XCTAssertEqual(info.teamIdentifier, "59GAB85EFG") @@ -60,7 +64,7 @@ final class XcodesKitTests: XCTestCase { } let xcode = Xcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - installer.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) + xcodeInstaller.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) .tap { result in guard case .fulfilled(let value) = result else { XCTFail("downloadOrUseExistingArchive rejected."); return } XCTAssertEqual(value, Path.applicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) @@ -78,7 +82,7 @@ final class XcodesKitTests: XCTestCase { } let xcode = Xcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - installer.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) + xcodeInstaller.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) .tap { result in guard case .fulfilled(let value) = result else { XCTFail("downloadOrUseExistingArchive rejected."); return } XCTAssertEqual(value, Path.applicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) @@ -92,7 +96,7 @@ final class XcodesKitTests: XCTestCase { let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.failedSecurityAssessment(xcode: installedXcode, output: "")) } } @@ -100,7 +104,7 @@ final class XcodesKitTests: XCTestCase { Current.shell.codesignVerify = { _ in return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) } let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) - installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed(output: "")) } } @@ -108,7 +112,7 @@ final class XcodesKitTests: XCTestCase { Current.shell.codesignVerify = { _ in return Promise.value((0, "", "")) } let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) - installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.unexpectedCodeSigningIdentity(identifier: "", certificateAuthority: [])) } } @@ -121,7 +125,7 @@ final class XcodesKitTests: XCTestCase { let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) let xipURL = URL(fileURLWithPath: "/Xcode-0.0.0.xip") - installer.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) .ensure { XCTAssertEqual(trashedItemAtURL, xipURL) } .cauterize() } @@ -209,7 +213,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) .ensure { let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -302,7 +306,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) .ensure { let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NoColor", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -399,7 +403,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) .ensure { let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NonInteractiveTerminal", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -492,7 +496,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), emptyTrash: false, noSuperuser: false) .ensure { let url = Bundle.module.url(forResource: "LogOutput-AlternativeDirectory", withExtension: "txt", subdirectory: "Fixtures")! let expectedText = try! String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string) @@ -606,7 +610,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) .ensure { let url = Bundle.module.url(forResource: "LogOutput-IncorrectSavedPassword", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -724,7 +728,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) .ensure { let url = Bundle.module.url(forResource: "LogOutput-DamagedXIP", withExtension: "txt", subdirectory: "Fixtures")! let expectedText = try! String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string) @@ -784,7 +788,7 @@ final class XcodesKitTests: XCTestCase { return Promise.value((status: 0, out: "", err: "")) } - installer.uninstallXcode("0.0.0", directory: Path.root.join("Applications"), emptyTrash: false) + xcodeInstaller.uninstallXcode("0.0.0", directory: Path.root.join("Applications"), emptyTrash: false) .ensure { XCTAssertEqual(selectedPaths, ["/Applications/Xcode-2.0.1.app"]) XCTAssertEqual(trashedItemAtURL, installedXcodes[0].path.url) @@ -829,7 +833,7 @@ final class XcodesKitTests: XCTestCase { return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash/\(itemURL.lastPathComponent)") } - installer.uninstallXcode("999", directory: Path.root.join("Applications"), emptyTrash: false) + xcodeInstaller.uninstallXcode("999", directory: Path.root.join("Applications"), emptyTrash: false) .ensure { XCTAssertEqual(trashedItemAtURL, installedXcodes[0].path.url) } @@ -850,7 +854,7 @@ final class XcodesKitTests: XCTestCase { Current.shell.spctlAssess = { _ in return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) } let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - installer.verifySecurityAssessment(of: installedXcode) + xcodeInstaller.verifySecurityAssessment(of: installedXcode) .tap { result in XCTAssertFalse(result.isFulfilled) } .cauterize() } @@ -859,7 +863,7 @@ final class XcodesKitTests: XCTestCase { Current.shell.spctlAssess = { _ in return Promise.value((0, "", "")) } let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - installer.verifySecurityAssessment(of: installedXcode) + xcodeInstaller.verifySecurityAssessment(of: installedXcode) .tap { result in XCTAssertTrue(result.isFulfilled) } .cauterize() } @@ -971,7 +975,7 @@ final class XcodesKitTests: XCTestCase { } fatalError("wrong url") } - try await runtimeList.printAvailableRuntimes(includeBetas: true) + try await runtimeInstaller.printAvailableRuntimes(includeBetas: true) let outputUrl = Bundle.module.url(forResource: "LogOutput-Runtimes", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try String(contentsOf: outputUrl)) @@ -1279,7 +1283,7 @@ final class XcodesKitTests: XCTestCase { // Standard output is an interactive terminal Current.shell.isatty = { true } - installer.printInstalledXcodes(directory: Path.root/"Applications") + xcodeInstaller.printInstalledXcodes(directory: Path.root/"Applications") .cauterize() XCTAssertEqual( @@ -1334,7 +1338,7 @@ final class XcodesKitTests: XCTestCase { // Standard output is not an interactive terminal Current.shell.isatty = { false } - installer.printInstalledXcodes(directory: Path.root/"Applications") + xcodeInstaller.printInstalledXcodes(directory: Path.root/"Applications") .cauterize() XCTAssertEqual( @@ -1379,7 +1383,7 @@ final class XcodesKitTests: XCTestCase { // Standard output is not an interactive terminal Current.shell.isatty = { false } - installer.printXcodePath(ofVersion: "2", searchingIn: Path.root/"Applications") + xcodeInstaller.printXcodePath(ofVersion: "2", searchingIn: Path.root/"Applications") .cauterize() XCTAssertEqual( @@ -1422,7 +1426,7 @@ final class XcodesKitTests: XCTestCase { // Standard output is not an interactive terminal Current.shell.isatty = { false } - installer.printXcodePath(ofVersion: "3", searchingIn: Path.root/"Applications") + xcodeInstaller.printXcodePath(ofVersion: "3", searchingIn: Path.root/"Applications") .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.versionNotInstalled(Version(xcodeVersion: "3")!)) } } From 03c949105c75c8a2266ae854c8639351fe0126cb Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Sat, 29 Oct 2022 09:12:20 +0200 Subject: [PATCH 07/12] `sudo` refactor --- Sources/XcodesKit/Downloader.swift | 1 - Sources/XcodesKit/Environment.swift | 3 +- Sources/XcodesKit/Path+.swift | 19 +++- Sources/XcodesKit/RuntimeInstaller.swift | 43 ++++---- Sources/XcodesKit/RuntimeList.swift | 2 +- Sources/xcodes/App.swift | 16 ++- Tests/XcodesKitTests/RuntimeTests.swift | 116 ++++++++++++++++++++++ Tests/XcodesKitTests/XcodesKitTests.swift | 82 +-------------- 8 files changed, 177 insertions(+), 105 deletions(-) create mode 100644 Tests/XcodesKitTests/RuntimeTests.swift diff --git a/Sources/XcodesKit/Downloader.swift b/Sources/XcodesKit/Downloader.swift index fd33759..b4199fa 100644 --- a/Sources/XcodesKit/Downloader.swift +++ b/Sources/XcodesKit/Downloader.swift @@ -7,7 +7,6 @@ public enum Downloader { case urlSession case aria2(Path) - func download(url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { switch self { case .urlSession: diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 85ab5ce..d29dd81 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -28,7 +28,7 @@ public struct Shell { public var unmountDmg: (URL) -> Promise = { Process.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) } public var expandPkg: (URL, URL) -> Promise = { Process.run(Path.root.usr.sbin.join("pkgutil"), "--expand", $0.path, $1.path) } public var createPkg: (URL, URL) -> Promise = { Process.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path) } - public var installPkg: (URL, String, String?) -> Promise = { Process.sudo(password: $2, Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) } + public var installPkg: (URL, String) -> Promise = { Process.run(Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) } public var installRuntimeImage: (URL) -> Promise = { Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $0.path) } public var spctlAssess: (URL) -> Promise = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") } public var codesignVerify: (URL) -> Promise = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") } @@ -55,6 +55,7 @@ public struct Shell { authenticateSudoerIfNecessary(passwordInput) } + public var isRoot: () -> Bool = { NSUserName() == "root" } public var xcodeSelectPrintPath: () -> Promise = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } public var xcodeSelectSwitch: (String?, String) -> Promise = { Process.sudo(password: $0, Path.root.usr.bin.join("xcode-select"), "-s", $1) } public func xcodeSelectSwitch(password: String?, path: String) -> Promise { diff --git a/Sources/XcodesKit/Path+.swift b/Sources/XcodesKit/Path+.swift index 24ce322..7be03fd 100644 --- a/Sources/XcodesKit/Path+.swift +++ b/Sources/XcodesKit/Path+.swift @@ -1,9 +1,22 @@ import Path +import Foundation extension Path { - static let oldXcodesApplicationSupport = Path.applicationSupport/"ca.brandonevans.xcodes" - static let xcodesApplicationSupport = Path.applicationSupport/"com.robotsandpencils.xcodes" - static let xcodesCaches = Path.caches/"com.robotsandpencils.xcodes" + // Get Home even if we are running as root + public static let environmentHome = ProcessInfo.processInfo.environment["HOME"].flatMap(Path.init) ?? .home + static let environmentApplicationSupport = environmentHome/"Library/Application Support" + static let environmentCaches = environmentHome/"Library/Caches" + + static let oldXcodesApplicationSupport = environmentApplicationSupport/"ca.brandonevans.xcodes" + static let xcodesApplicationSupport = environmentApplicationSupport/"com.robotsandpencils.xcodes" + static let xcodesCaches = environmentCaches/"com.robotsandpencils.xcodes" static let cacheFile = xcodesApplicationSupport/"available-xcodes.json" static let configurationFile = xcodesApplicationSupport/"configuration.json" + + @discardableResult + func setCurrentUserAsOwner() -> Path { + let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName() + try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string) + return self + } } diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index 25dcea6..3b4d948 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -6,8 +6,8 @@ import AppleAPI public class RuntimeInstaller { - private var sessionService: AppleSessionService - private var runtimeList: RuntimeList + public let sessionService: AppleSessionService + public let runtimeList: RuntimeList public init(runtimeList: RuntimeList, sessionService: AppleSessionService) { self.runtimeList = runtimeList @@ -39,18 +39,28 @@ public class RuntimeInstaller { Current.logging.log("\nNote: Bundled runtimes are indicated for the currently selected Xcode, more bundled runtimes may exist in other Xcode(s)") } - public func downloadAndInstallRuntime(identifier: String, downloader: Downloader) async throws { - let downloadables = try await runtimeList.downloadableRuntimes(includeBetas: true) + public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool) async throws { + let downloadables = try await runtimeList.downloadableRuntimes() guard let matchedRuntime = downloadables.first(where: { $0.visibleIdentifier == identifier }) else { throw Error.unavailableRuntime(identifier) } - let dmgUrl = try await download(runtime: matchedRuntime, downloader: downloader) + + if matchedRuntime.contentType == .package && !Current.shell.isRoot() { + Current.logging.log("Must be run as root to install the specified runtime") + exit(1) + } + + let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) switch matchedRuntime.contentType { case .package: try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) case .diskImage: try await installFromImage(dmgUrl: dmgUrl) } + if shouldDelete { + Current.logging.log("Deleting Archive") + try? Current.files.removeItem(at: dmgUrl) + } } private func installFromImage(dmgUrl: URL) async throws { @@ -62,10 +72,10 @@ public class RuntimeInstaller { Current.logging.log("Mounting DMG") let mountedUrl = try await mountDMG(dmgUrl: dmgUrl) let pkgPath = try! Path(url: mountedUrl)!.ls().first!.path - try Path.xcodesCaches.mkdir() + try Path.xcodesCaches.mkdir().setCurrentUserAsOwner() let expandedPkgPath = Path.xcodesCaches/runtime.identifier try expandedPkgPath.delete() - _ = try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url).async() + try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url).asVoid().async() try await unmountDMG(mountedURL: mountedUrl) let packageInfoPath = expandedPkgPath/"PackageInfo" var packageInfoContents = try String(contentsOf: packageInfoPath) @@ -74,18 +84,12 @@ public class RuntimeInstaller { packageInfoContents = packageInfoContents.replacingOccurrences(of: " { seal in - Current.logging.log("xcodes requires superuser privileges in order to finish installation.") - guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(XcodeInstaller.Error.missingSudoerPassword); return } - seal.fulfill(password + "\n") - } - } - let possiblePassword = try await Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput).async() - _ = try await Current.shell.installPkg(newPkgPath.url, "/", possiblePassword).async() + // TODO: Report progress + try await Current.shell.installPkg(newPkgPath.url, "/").asVoid().async() try newPkgPath.delete() } @@ -100,13 +104,13 @@ public class RuntimeInstaller { } private func unmountDMG(mountedURL: URL) async throws { - _ = try await Current.shell.unmountDmg(mountedURL).async() + try await Current.shell.unmountDmg(mountedURL).asVoid().async() } @MainActor - private func download(runtime: DownloadableRuntime, downloader: Downloader) async throws -> URL { + public func downloadOrUseExistingArchive(runtime: DownloadableRuntime, to destinationDirectory: Path, downloader: Downloader) async throws -> URL { let url = URL(string: runtime.source)! - let destination = Path.xcodesApplicationSupport/url.lastPathComponent + let destination = destinationDirectory/url.lastPathComponent let aria2DownloadMetadataPath = destination.parent/(destination.basename() + ".aria2") var aria2DownloadIsIncomplete = false if case .aria2 = downloader, aria2DownloadMetadataPath.exists { @@ -138,6 +142,7 @@ public class RuntimeInstaller { } }).async() observation?.invalidate() + destination.setCurrentUserAsOwner() return result } } diff --git a/Sources/XcodesKit/RuntimeList.swift b/Sources/XcodesKit/RuntimeList.swift index fff8d3d..75ce61c 100644 --- a/Sources/XcodesKit/RuntimeList.swift +++ b/Sources/XcodesKit/RuntimeList.swift @@ -4,7 +4,7 @@ public class RuntimeList { public init() {} - func downloadableRuntimes(includeBetas: Bool) async throws -> [DownloadableRuntime] { + func downloadableRuntimes(includeBetas: Bool = true) async throws -> [DownloadableRuntime] { let (data, _) = try await Current.network.dataTask(with: URLRequest.runtimes).async() let decodedResponse = try PropertyListDecoder().decode(DownloadableRuntimesResponse.self, from: data) return includeBetas ? decodedResponse.downloadables : decodedResponse.downloadables.filter { $0.betaVersion == nil } diff --git a/Sources/xcodes/App.swift b/Sources/xcodes/App.swift index 4cb0c71..603bdeb 100644 --- a/Sources/xcodes/App.swift +++ b/Sources/xcodes/App.swift @@ -148,7 +148,7 @@ struct Xcodes: AsyncParsableCommand { downloader = .aria2(aria2Path) } - let destination = getDirectory(possibleDirectory: directory, default: Path.home.join("Downloads")) + let destination = getDirectory(possibleDirectory: directory, default: Path.environmentHome.join("Downloads")) xcodeInstaller.download(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destinationDirectory: destination) .catch { error in @@ -390,9 +390,16 @@ struct Xcodes: AsyncParsableCommand { completion: .file()) var aria2: String? - @Flag(help: "Don't use aria2 to download Xcode, even if its available.") + @Flag(help: "Don't use aria2 to download the runtime, even if its available.") var noAria2: Bool = false + @Option(help: "The directory to download the runtime archive to. Defaults to ~/Downloads.", + completion: .directory) + var directory: String? + + @Flag(help: "Do not delete the runtime archive after the installation is finished.") + var keepArchive = false + @OptionGroup var globalColor: GlobalColorOption @@ -405,7 +412,10 @@ struct Xcodes: AsyncParsableCommand { noAria2 == false { downloader = .aria2(aria2Path) } - try await runtimeInstaller.downloadAndInstallRuntime(identifier: version, downloader: downloader) + + let destination = getDirectory(possibleDirectory: directory, default: Path.environmentHome.join("Downloads")) + + try await runtimeInstaller.downloadAndInstallRuntime(identifier: version, to: destination, with: downloader, shouldDelete: !keepArchive) Current.logging.log("Finished") } } diff --git a/Tests/XcodesKitTests/RuntimeTests.swift b/Tests/XcodesKitTests/RuntimeTests.swift new file mode 100644 index 0000000..88d7449 --- /dev/null +++ b/Tests/XcodesKitTests/RuntimeTests.swift @@ -0,0 +1,116 @@ +import XCTest +import Version +import PromiseKit +import PMKFoundation +import Path +import AppleAPI +import Rainbow +@testable import XcodesKit + +final class RuntimeTests: XCTestCase { + + var runtimeList: RuntimeList! + var runtimeInstaller: RuntimeInstaller! + + override class func setUp() { + super.setUp() + PromiseKit.conf.Q.map = nil + PromiseKit.conf.Q.return = nil + } + + override func setUp() { + Current = .mock +// Rainbow.outputTarget = .unknown +// Rainbow.enabled = false + let sessionService = AppleSessionService(configuration: Configuration()) + runtimeList = RuntimeList() + runtimeInstaller = RuntimeInstaller(runtimeList: runtimeList, sessionService: sessionService) + } + + func mockDownloadables() { + XcodesKit.Current.network.dataTask = { url in + if url.pmkRequest.url! == .downloadableRuntimes { + let url = Bundle.module.url(forResource: "DownloadableRuntimes", withExtension: "plist", subdirectory: "Fixtures")! + let downloadsData = try! Data(contentsOf: url) + return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + fatalError("wrong url") + } + } + + func test_installedRuntimes() async throws { + Current.shell.installedRuntimes = { + let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! + return Promise.value((0, try! String(contentsOf: url), "")) + } + let values = try await runtimeList.installedRuntimes() + let givenIDs = [ + UUID(uuidString: "2A6068A0-7FCF-4DB9-964D-21145EB98498")!, + UUID(uuidString: "6DE6B631-9439-4737-A65B-73F675EB77D1")!, + UUID(uuidString: "7A032D54-0D93-4E04-80B9-4CB207136C3F")!, + UUID(uuidString: "91B92361-CD02-4AF7-8DFE-DE8764AA949F")!, + UUID(uuidString: "630146EA-A027-42B1-AC25-BE4EA018DE90")!, + UUID(uuidString: "AAD753FE-A798-479C-B6D6-41259B063DD6")!, + UUID(uuidString: "BE68168B-7AC8-4A1F-A344-15DFCC375457")!, + UUID(uuidString: "F8D81829-354C-4EB0-828D-83DC765B27E1")!, + ] + XCTAssertEqual(givenIDs, values.map(\.identifier)) + } + + func test_downloadableRuntimes() async throws { + mockDownloadables() + let values = try await runtimeList.downloadableRuntimes() + XCTAssertEqual(values.count, 57) + } + + func test_downloadableRuntimesNoBetas() async throws { + mockDownloadables() + let values = try await runtimeList.downloadableRuntimes(includeBetas: false) + XCTAssertFalse(values.contains { $0.name.lowercased().contains("beta") }) + XCTAssertEqual(values.count, 45) + } + + func test_printAvailableRuntimes() async throws { + var log = "" + XcodesKit.Current.logging.log = { log.append($0 + "\n") } + mockDownloadables() + Current.shell.installedRuntimes = { + let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! + return Promise.value((0, try! String(contentsOf: url), "")) + } + try await runtimeInstaller.printAvailableRuntimes(includeBetas: true) + let outputUrl = Bundle.module.url(forResource: "LogOutput-Runtimes", withExtension: "txt", subdirectory: "Fixtures")! + XCTAssertEqual(log, try String(contentsOf: outputUrl)) + } + + func test_DownloadOrUseExistingArchive_ReturnsExistingArchive() async throws { + Current.files.fileExistsAtPath = { _ in return true } + mockDownloadables() + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 14.5" }! + var xcodeDownloadURL: URL? + Current.network.downloadTask = { url, _, _ in + xcodeDownloadURL = url.pmkRequest.url + return (Progress(), Promise(error: PMKError.invalidCallingConvention)) + } + + let url = try await runtimeInstaller.downloadOrUseExistingArchive(runtime: runtime, to: .xcodesApplicationSupport, downloader: .urlSession) + let fileName = URL(string: runtime.source)!.lastPathComponent + XCTAssertEqual(url, Path.xcodesApplicationSupport.join(fileName).url) + XCTAssertNil(xcodeDownloadURL) + } + + func test_DownloadOrUseExistingArchive_DownloadsArchive() async throws { + Current.files.fileExistsAtPath = { _ in return false } + mockDownloadables() + var xcodeDownloadURL: URL? + Current.network.downloadTask = { url, destination, _ in + xcodeDownloadURL = url.pmkRequest.url + return (Progress(), Promise.value((destination, HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))) + } + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 14.5" }! + let fileName = URL(string: runtime.source)!.lastPathComponent + let url = try await runtimeInstaller.downloadOrUseExistingArchive(runtime: runtime, to: .xcodesApplicationSupport, downloader: .urlSession) + XCTAssertEqual(url, Path.xcodesApplicationSupport.join(fileName).url) + XCTAssertEqual(xcodeDownloadURL, URL(string: runtime.source)!) + } +} diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 3b3c92c..f2d5f01 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -12,8 +12,6 @@ final class XcodesKitTests: XCTestCase { var xcodeList: XcodeList! var xcodeInstaller: XcodeInstaller! - var runtimeList: RuntimeList! - var runtimeInstaller: RuntimeInstaller! var sessionService: AppleSessionService! override class func setUp() { @@ -29,8 +27,6 @@ final class XcodesKitTests: XCTestCase { sessionService = AppleSessionService(configuration: Configuration()) xcodeList = XcodeList() xcodeInstaller = XcodeInstaller(xcodeList: xcodeList, sessionService: sessionService) - runtimeList = RuntimeList() - runtimeInstaller = RuntimeInstaller(runtimeList: runtimeList, sessionService: sessionService) } func test_ParseCertificateInfo_Succeeds() throws { @@ -67,7 +63,7 @@ final class XcodesKitTests: XCTestCase { xcodeInstaller.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) .tap { result in guard case .fulfilled(let value) = result else { XCTFail("downloadOrUseExistingArchive rejected."); return } - XCTAssertEqual(value, Path.applicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) + XCTAssertEqual(value, Path.environmentApplicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) XCTAssertNil(xcodeDownloadURL) } .cauterize() @@ -85,7 +81,7 @@ final class XcodesKitTests: XCTestCase { xcodeInstaller.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) .tap { result in guard case .fulfilled(let value) = result else { XCTFail("downloadOrUseExistingArchive rejected."); return } - XCTAssertEqual(value, Path.applicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) + XCTAssertEqual(value, Path.environmentApplicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) XCTAssertEqual(xcodeDownloadURL, URL(string: "https://apple.com/xcode.xip")!) } .cauterize() @@ -893,8 +889,8 @@ final class XcodesKitTests: XCTestCase { migrateApplicationSupportFiles() - XCTAssertEqual(source, Path.applicationSupport.join("ca.brandonevans.xcodes").url) - XCTAssertEqual(destination, Path.applicationSupport.join("com.robotsandpencils.xcodes").url) + XCTAssertEqual(source, Path.environmentApplicationSupport.join("ca.brandonevans.xcodes").url) + XCTAssertEqual(destination, Path.environmentApplicationSupport.join("com.robotsandpencils.xcodes").url) XCTAssertNil(removedItemAtURL) } @@ -910,75 +906,7 @@ final class XcodesKitTests: XCTestCase { XCTAssertNil(source) XCTAssertNil(destination) - XCTAssertEqual(removedItemAtURL, Path.applicationSupport.join("ca.brandonevans.xcodes").url) - } - - func test_installedRuntimes() async throws { - Current.shell.installedRuntimes = { - let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! - return Promise.value((0, try! String(contentsOf: url), "")) - } - let values = try await runtimeList.installedRuntimes() - let givenIDs = [ - UUID(uuidString: "2A6068A0-7FCF-4DB9-964D-21145EB98498")!, - UUID(uuidString: "6DE6B631-9439-4737-A65B-73F675EB77D1")!, - UUID(uuidString: "7A032D54-0D93-4E04-80B9-4CB207136C3F")!, - UUID(uuidString: "91B92361-CD02-4AF7-8DFE-DE8764AA949F")!, - UUID(uuidString: "630146EA-A027-42B1-AC25-BE4EA018DE90")!, - UUID(uuidString: "AAD753FE-A798-479C-B6D6-41259B063DD6")!, - UUID(uuidString: "BE68168B-7AC8-4A1F-A344-15DFCC375457")!, - UUID(uuidString: "F8D81829-354C-4EB0-828D-83DC765B27E1")!, - ] - XCTAssertEqual(givenIDs, values.map(\.identifier)) - } - - func test_downloadableRuntimes() async throws { - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == .downloadableRuntimes { - let url = Bundle.module.url(forResource: "DownloadableRuntimes", withExtension: "plist", subdirectory: "Fixtures")! - let downloadsData = try! Data(contentsOf: url) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - } - fatalError("wrong url") - } - let values = try await runtimeList.downloadableRuntimes(includeBetas: true) - - XCTAssertEqual(values.count, 57) - } - - func test_downloadableRuntimesNoBetas() async throws { - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == .downloadableRuntimes { - let url = Bundle.module.url(forResource: "DownloadableRuntimes", withExtension: "plist", subdirectory: "Fixtures")! - let downloadsData = try! Data(contentsOf: url) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - } - fatalError("wrong url") - } - let values = try await runtimeList.downloadableRuntimes(includeBetas: false) - XCTAssertFalse(values.contains { $0.name.lowercased().contains("beta") }) - XCTAssertEqual(values.count, 45) - } - - func test_printAvailableRuntimes() async throws { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } - Current.shell.installedRuntimes = { - let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! - return Promise.value((0, try! String(contentsOf: url), "")) - } - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == .downloadableRuntimes { - let url = Bundle.module.url(forResource: "DownloadableRuntimes", withExtension: "plist", subdirectory: "Fixtures")! - let downloadsData = try! Data(contentsOf: url) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - } - fatalError("wrong url") - } - try await runtimeInstaller.printAvailableRuntimes(includeBetas: true) - - let outputUrl = Bundle.module.url(forResource: "LogOutput-Runtimes", withExtension: "txt", subdirectory: "Fixtures")! - XCTAssertEqual(log, try String(contentsOf: outputUrl)) + XCTAssertEqual(removedItemAtURL, Path.environmentApplicationSupport.join("ca.brandonevans.xcodes").url) } func test_MigrateApplicationSupport_OnlyNewSupportFiles() { From ca37613ad16ca928e291d1bbfc3bc94928e0b80a Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Mon, 31 Oct 2022 09:38:22 +0100 Subject: [PATCH 08/12] Adding tests --- Sources/XcodesKit/Environment.swift | 41 ++-- Sources/XcodesKit/Path+.swift | 3 +- Sources/XcodesKit/RuntimeInstaller.swift | 19 +- Sources/xcodes/App.swift | 4 +- Tests/XcodesKitTests/Environment+Mock.swift | 8 + .../Fixtures/LogOutput-Runtime_NoBetas.txt | 51 +++++ .../XcodesKitTests/Fixtures/PackageInfo_after | 6 + .../Fixtures/PackageInfo_before | 6 + Tests/XcodesKitTests/RuntimeTests.swift | 182 +++++++++++++++++- 9 files changed, 279 insertions(+), 41 deletions(-) create mode 100644 Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt create mode 100644 Tests/XcodesKitTests/Fixtures/PackageInfo_after create mode 100644 Tests/XcodesKitTests/Fixtures/PackageInfo_before diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index d29dd81..28b4379 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -55,13 +55,13 @@ public struct Shell { authenticateSudoerIfNecessary(passwordInput) } - public var isRoot: () -> Bool = { NSUserName() == "root" } public var xcodeSelectPrintPath: () -> Promise = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } public var xcodeSelectSwitch: (String?, String) -> Promise = { Process.sudo(password: $0, Path.root.usr.bin.join("xcode-select"), "-s", $1) } public func xcodeSelectSwitch(password: String?, path: String) -> Promise { xcodeSelectSwitch(password, path) } - + public var isRoot: () -> Bool = { NSUserName() == "root" } + /// Returns the path of an executable within the directories in the PATH environment variable. public var findExecutable: (_ executableName: String) -> Path? = { executableName in guard let path = ProcessInfo.processInfo.environment["PATH"] else { return nil } @@ -71,10 +71,10 @@ public struct Shell { return executable } } - + return nil } - + public var downloadWithAria2: (Path, URL, Path, [HTTPCookie]) -> (Progress, Promise) = { aria2Path, url, destination, cookies in precondition(Thread.isMainThread, "Aria must be called on the main queue") let process = Process() @@ -93,12 +93,12 @@ public struct Shell { process.standardOutput = stdOutPipe let stdErrPipe = Pipe() process.standardError = stdErrPipe - + var progress = Progress(totalUnitCount: 100) let observer = NotificationCenter.default.addObserver( - forName: .NSFileHandleDataAvailable, - object: nil, + forName: .NSFileHandleDataAvailable, + object: nil, queue: OperationQueue.main ) { note in guard @@ -124,7 +124,7 @@ public struct Shell { stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - + do { try process.run() } catch { @@ -134,7 +134,7 @@ public struct Shell { let promise = Promise { seal in DispatchQueue.global(qos: .default).async { process.waitUntilExit() - + NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) guard process.terminationReason == .exit, process.terminationStatus == 0 else { @@ -147,7 +147,7 @@ public struct Shell { seal.fulfill(()) } } - + return (progress, promise) } @@ -199,7 +199,7 @@ public struct Shell { } public var exit: (Int32) -> Void = { Darwin.exit($0) } - + public var isatty: () -> Bool = { Foundation.isatty(fileno(stdout)) != 0 } } @@ -246,9 +246,9 @@ public struct Files { public func trashItem(at URL: URL) throws -> URL { return try trashItem(URL) } - + public var createFile: (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) } - + @discardableResult public func createFile(atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey : Any]? = nil) -> Bool { return createFile(path, data, attr) @@ -259,13 +259,14 @@ public struct Files { try createDirectory(url, createIntermediates, attributes) } - public var installedXcodes = XcodesKit.installedXcodes -} -private func installedXcodes(directory: Path) -> [InstalledXcode] { - ((try? directory.ls()) ?? []) - .filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" } - .map { $0.path } - .compactMap(InstalledXcode.init) + public var contentsOfDirectory: (URL) throws -> [URL] = { try FileManager.default.contentsOfDirectory(at: $0, includingPropertiesForKeys: nil, options: []) } + + public var installedXcodes: (Path) -> [InstalledXcode] = { directory in + return ((try? directory.ls()) ?? []) + .filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" } + .map { $0.path } + .compactMap(InstalledXcode.init) + } } public struct Network { diff --git a/Sources/XcodesKit/Path+.swift b/Sources/XcodesKit/Path+.swift index 7be03fd..eba6dd7 100644 --- a/Sources/XcodesKit/Path+.swift +++ b/Sources/XcodesKit/Path+.swift @@ -3,9 +3,10 @@ import Foundation extension Path { // Get Home even if we are running as root - public static let environmentHome = ProcessInfo.processInfo.environment["HOME"].flatMap(Path.init) ?? .home + static let environmentHome = ProcessInfo.processInfo.environment["HOME"].flatMap(Path.init) ?? .home static let environmentApplicationSupport = environmentHome/"Library/Application Support" static let environmentCaches = environmentHome/"Library/Caches" + public static let environmentDownloads = environmentHome/"Downloads" static let oldXcodesApplicationSupport = environmentApplicationSupport/"ca.brandonevans.xcodes" static let xcodesApplicationSupport = environmentApplicationSupport/"com.robotsandpencils.xcodes" diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index 3b4d948..b4b865c 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -46,8 +46,7 @@ public class RuntimeInstaller { } if matchedRuntime.contentType == .package && !Current.shell.isRoot() { - Current.logging.log("Must be run as root to install the specified runtime") - exit(1) + throw Error.rootNeeded } let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) @@ -74,23 +73,24 @@ public class RuntimeInstaller { let pkgPath = try! Path(url: mountedUrl)!.ls().first!.path try Path.xcodesCaches.mkdir().setCurrentUserAsOwner() let expandedPkgPath = Path.xcodesCaches/runtime.identifier - try expandedPkgPath.delete() + try? Current.files.removeItem(at: expandedPkgPath.url) try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url).asVoid().async() try await unmountDMG(mountedURL: mountedUrl) let packageInfoPath = expandedPkgPath/"PackageInfo" - var packageInfoContents = try String(contentsOf: packageInfoPath) + let packageInfoContentsData = Current.files.contents(atPath: packageInfoPath.string)! + var packageInfoContents = String(data: packageInfoContentsData, encoding: .utf8)! let runtimeFileName = "\(runtime.platform.shortName) \(runtime.simulatorVersion.version).simruntime" let runtimeDestination = Path("/Library/Developer/CoreSimulator/Profiles/Runtimes/\(runtimeFileName)")! packageInfoContents = packageInfoContents.replacingOccurrences(of: " URL { @@ -151,6 +151,7 @@ extension RuntimeInstaller { public enum Error: LocalizedError, Equatable { case unavailableRuntime(String) case failedMountingDMG + case rootNeeded public var errorDescription: String? { switch self { @@ -158,6 +159,8 @@ extension RuntimeInstaller { return "Could not find runtime \(version)." case .failedMountingDMG: return "Failed to mount image." + case .rootNeeded: + return "Must be run as root to install the specified runtime" } } } diff --git a/Sources/xcodes/App.swift b/Sources/xcodes/App.swift index 603bdeb..88d21c9 100644 --- a/Sources/xcodes/App.swift +++ b/Sources/xcodes/App.swift @@ -148,7 +148,7 @@ struct Xcodes: AsyncParsableCommand { downloader = .aria2(aria2Path) } - let destination = getDirectory(possibleDirectory: directory, default: Path.environmentHome.join("Downloads")) + let destination = getDirectory(possibleDirectory: directory, default: .environmentDownloads) xcodeInstaller.download(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destinationDirectory: destination) .catch { error in @@ -413,7 +413,7 @@ struct Xcodes: AsyncParsableCommand { downloader = .aria2(aria2Path) } - let destination = getDirectory(possibleDirectory: directory, default: Path.environmentHome.join("Downloads")) + let destination = getDirectory(possibleDirectory: directory, default: .environmentDownloads) try await runtimeInstaller.downloadAndInstallRuntime(identifier: version, to: destination, with: downloader, shouldDelete: !keepArchive) Current.logging.log("Finished") diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index dfb41e5..067f9ce 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -17,6 +17,12 @@ extension Shell { static var mock = Shell( unxip: { _ in return Promise.value(Shell.processOutputMock) }, + mountDmg: { _ in return Promise.value(Shell.processOutputMock) }, + unmountDmg: { _ in return Promise.value(Shell.processOutputMock) }, + expandPkg: { _, _ in return Promise.value(Shell.processOutputMock) }, + createPkg: { _, _ in return Promise.value(Shell.processOutputMock) }, + installPkg: { _, _ in return Promise.value(Shell.processOutputMock) }, + installRuntimeImage: { _ in return Promise.value(Shell.processOutputMock) }, spctlAssess: { _ in return Promise.value(Shell.processOutputMock) }, codesignVerify: { _ in return Promise.value(Shell.processOutputMock) }, devToolsSecurityEnable: { _ in return Promise.value(Shell.processOutputMock) }, @@ -32,6 +38,7 @@ extension Shell { // Deliberately using real implementation of authenticateSudoerIfNecessary since it depends on others that can be mocked xcodeSelectPrintPath: { return Promise.value(Shell.processOutputMock) }, xcodeSelectSwitch: { _, _ in return Promise.value(Shell.processOutputMock) }, + isRoot: { true }, readLine: { _ in return nil }, readSecureLine: { _, _ in return nil }, env: { _ in nil }, @@ -63,6 +70,7 @@ extension Files { trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") }, createFile: { _, _, _ in return true }, createDirectory: { _, _, _ in }, + contentsOfDirectory: { _ in [] }, installedXcodes: { _ in [] } ) } diff --git a/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt new file mode 100644 index 0000000..0a08b07 --- /dev/null +++ b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt @@ -0,0 +1,51 @@ +-- iOS -- +iOS 12.4 (Downloaded) +iOS 13.0 (Downloaded) +iOS 13.1 (Downloaded) +iOS 13.2.2 +iOS 13.3 +iOS 13.4 +iOS 13.5 +iOS 13.6 +iOS 13.7 +iOS 14.0.1 +iOS 14.1 +iOS 14.2 +iOS 14.3 +iOS 14.4 +iOS 14.5 +iOS 15.0 +iOS 15.2 +iOS 15.4 +iOS 15.5 (Bundled with selected Xcode) +iOS 15.5 (Downloaded) +iOS 16.0 +-- watchOS -- +watchOS 6.0 +watchOS 6.1.1 +watchOS 6.2.1 +watchOS 7.0 +watchOS 7.1 +watchOS 7.2 +watchOS 7.4 +watchOS 8.0 +watchOS 8.3 +watchOS 8.5 (Bundled with selected Xcode) +watchOS 9.0 (Downloaded) +-- tvOS -- +tvOS 12.4 +tvOS 13.0 +tvOS 13.2 +tvOS 13.3 +tvOS 13.4 +tvOS 14.0 +tvOS 14.2 +tvOS 14.3 +tvOS 14.4 +tvOS 14.5 +tvOS 15.0 +tvOS 15.2 +tvOS 15.4 (Bundled with selected Xcode) +tvOS 16.0 + +Note: Bundled runtimes are indicated for the currently selected Xcode, more bundled runtimes may exist in other Xcode(s) diff --git a/Tests/XcodesKitTests/Fixtures/PackageInfo_after b/Tests/XcodesKitTests/Fixtures/PackageInfo_after new file mode 100644 index 0000000..f8edd86 --- /dev/null +++ b/Tests/XcodesKitTests/Fixtures/PackageInfo_after @@ -0,0 +1,6 @@ + + + + + + diff --git a/Tests/XcodesKitTests/Fixtures/PackageInfo_before b/Tests/XcodesKitTests/Fixtures/PackageInfo_before new file mode 100644 index 0000000..cf22f96 --- /dev/null +++ b/Tests/XcodesKitTests/Fixtures/PackageInfo_before @@ -0,0 +1,6 @@ + + + + + + diff --git a/Tests/XcodesKitTests/RuntimeTests.swift b/Tests/XcodesKitTests/RuntimeTests.swift index 88d7449..d13fe52 100644 --- a/Tests/XcodesKitTests/RuntimeTests.swift +++ b/Tests/XcodesKitTests/RuntimeTests.swift @@ -20,8 +20,6 @@ final class RuntimeTests: XCTestCase { override func setUp() { Current = .mock -// Rainbow.outputTarget = .unknown -// Rainbow.enabled = false let sessionService = AppleSessionService(configuration: Configuration()) runtimeList = RuntimeList() runtimeInstaller = RuntimeInstaller(runtimeList: runtimeList, sessionService: sessionService) @@ -38,6 +36,27 @@ final class RuntimeTests: XCTestCase { } } + func mockMountedDMG() { + Current.shell.mountDmg = { _ in + let plist = """ + + system-entities + + + + + mount-point + \(NSHomeDirectory()) + + + + + + """ + return Promise.value((0, plist, "")) + } + } + func test_installedRuntimes() async throws { Current.shell.installedRuntimes = { let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! @@ -83,23 +102,80 @@ final class RuntimeTests: XCTestCase { XCTAssertEqual(log, try String(contentsOf: outputUrl)) } - func test_DownloadOrUseExistingArchive_ReturnsExistingArchive() async throws { + func test_printAvailableRuntimes_NoBetas() async throws { + var log = "" + XcodesKit.Current.logging.log = { log.append($0 + "\n") } + mockDownloadables() + Current.shell.installedRuntimes = { + let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! + return Promise.value((0, try! String(contentsOf: url), "")) + } + try await runtimeInstaller.printAvailableRuntimes(includeBetas: false) + let outputUrl = Bundle.module.url(forResource: "LogOutput-Runtime_NoBetas", withExtension: "txt", subdirectory: "Fixtures")! + XCTAssertEqual(log, try String(contentsOf: outputUrl)) + } + + func test_wrongIdentifier() async throws { + mockDownloadables() + var resultError: RuntimeInstaller.Error? = nil + let identifier = "iOS 99.0" + do { + try await runtimeInstaller.downloadAndInstallRuntime(identifier: identifier, to: .xcodesCaches, with: .urlSession, shouldDelete: true) + } catch { + resultError = error as? RuntimeInstaller.Error + } + XCTAssertEqual(resultError, .unavailableRuntime(identifier)) + } + + func test_rootNeededIfPackage() async throws { + mockDownloadables() + XcodesKit.Current.shell.isRoot = { false } + let identifier = "iOS 15.5" + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == identifier }! + var resultError: RuntimeInstaller.Error? = nil + do { + try await runtimeInstaller.downloadAndInstallRuntime(identifier: identifier, to: .xcodesCaches, with: .urlSession, shouldDelete: true) + } catch { + resultError = error as? RuntimeInstaller.Error + } + XCTAssertEqual(runtime.visibleIdentifier, identifier) + XCTAssertEqual(runtime.contentType, .package) + XCTAssertEqual(resultError, .rootNeeded) + } + + func test_rootNotNeededIfDiskImage() async throws { + mockDownloadables() + XcodesKit.Current.shell.isRoot = { false } + let identifier = "iOS 16.0" + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == identifier }! + var resultError: RuntimeInstaller.Error? = nil + do { + try await runtimeInstaller.downloadAndInstallRuntime(identifier: identifier, to: .xcodesCaches, with: .urlSession, shouldDelete: true) + } catch { + resultError = error as? RuntimeInstaller.Error + } + XCTAssertEqual(runtime.visibleIdentifier, identifier) + XCTAssertEqual(runtime.contentType, .diskImage) + XCTAssertEqual(resultError, nil) + } + + func test_downloadOrUseExistingArchive_ReturnsExistingArchive() async throws { Current.files.fileExistsAtPath = { _ in return true } mockDownloadables() - let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 14.5" }! + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 15.5" }! var xcodeDownloadURL: URL? Current.network.downloadTask = { url, _, _ in xcodeDownloadURL = url.pmkRequest.url return (Progress(), Promise(error: PMKError.invalidCallingConvention)) } - let url = try await runtimeInstaller.downloadOrUseExistingArchive(runtime: runtime, to: .xcodesApplicationSupport, downloader: .urlSession) + let url = try await runtimeInstaller.downloadOrUseExistingArchive(runtime: runtime, to: .xcodesCaches, downloader: .urlSession) let fileName = URL(string: runtime.source)!.lastPathComponent - XCTAssertEqual(url, Path.xcodesApplicationSupport.join(fileName).url) + XCTAssertEqual(url, Path.xcodesCaches.join(fileName).url) XCTAssertNil(xcodeDownloadURL) } - func test_DownloadOrUseExistingArchive_DownloadsArchive() async throws { + func test_downloadOrUseExistingArchive_DownloadsArchive() async throws { Current.files.fileExistsAtPath = { _ in return false } mockDownloadables() var xcodeDownloadURL: URL? @@ -107,10 +183,96 @@ final class RuntimeTests: XCTestCase { xcodeDownloadURL = url.pmkRequest.url return (Progress(), Promise.value((destination, HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))) } - let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 14.5" }! + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 15.5" }! let fileName = URL(string: runtime.source)!.lastPathComponent - let url = try await runtimeInstaller.downloadOrUseExistingArchive(runtime: runtime, to: .xcodesApplicationSupport, downloader: .urlSession) - XCTAssertEqual(url, Path.xcodesApplicationSupport.join(fileName).url) + let url = try await runtimeInstaller.downloadOrUseExistingArchive(runtime: runtime, to: .xcodesCaches, downloader: .urlSession) + XCTAssertEqual(url, Path.xcodesCaches.join(fileName).url) XCTAssertEqual(xcodeDownloadURL, URL(string: runtime.source)!) } + + func test_installStepsForPackage() async throws { + mockDownloadables() + let expectedSteps = [ + "mounting", + "expanding_pkg", + "unmounting", + "gettingInfo", + "wrtitingInfo", + "creating_pkg", + "installing_pkg" + ] + var doneSteps: [String] = [] + Current.shell.mountDmg = { _ in doneSteps.append("mounting"); return .value((0, mockDMGPathPlist(), "")) } + Current.shell.expandPkg = { _, _ in doneSteps.append("expanding_pkg"); return .value(Shell.processOutputMock) } + Current.shell.unmountDmg = { _ in doneSteps.append("unmounting"); return .value(Shell.processOutputMock) } + Current.files.contentsAtPath = { path in + guard path.contains("PackageInfo") else { return nil } + doneSteps.append("gettingInfo") + let url = Bundle.module.url(forResource: "PackageInfo_before", withExtension: nil, subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + Current.files.write = { data, path in + guard path.path.contains("PackageInfo") else { fatalError() } + doneSteps.append("wrtitingInfo") + let url = Bundle.module.url(forResource: "PackageInfo_after", withExtension: nil, subdirectory: "Fixtures")! + let newString = String(data: data, encoding: .utf8) + XCTAssertEqual(try? String(contentsOf: url, encoding: .utf8), String(data: data, encoding: .utf8)) + XCTAssertTrue(newString?.contains("/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 15.5.simruntime") == true) + } + Current.shell.createPkg = { _, _ in doneSteps.append("creating_pkg"); return .value(Shell.processOutputMock) } + Current.shell.installPkg = { _, _ in doneSteps.append("installing_pkg"); return .value(Shell.processOutputMock) } + try await runtimeInstaller.downloadAndInstallRuntime(identifier: "iOS 15.5", to: .xcodesCaches, with: .urlSession, shouldDelete: true) + + XCTAssertEqual(expectedSteps, doneSteps) + } + + func test_installStepsForImage() async throws { + mockDownloadables() + var didInstall = false + Current.shell.installRuntimeImage = { _ in + didInstall = true + return .value(Shell.processOutputMock) + } + try await runtimeInstaller.downloadAndInstallRuntime(identifier: "iOS 16.0", to: .xcodesCaches, with: .urlSession, shouldDelete: true) + XCTAssertTrue(didInstall) + } + + + func test_deletesArchiveWhenFinished() async throws { + mockDownloadables() + var removed = false + Current.files.removeItem = { itemURL in + removed = true + } + try await runtimeInstaller.downloadAndInstallRuntime(identifier: "iOS 16.0", to: .xcodesCaches, with: .urlSession, shouldDelete: true) + XCTAssertTrue(removed) + } + + func test_KeepArchiveWhenFinished() async throws { + mockDownloadables() + var removed = false + Current.files.removeItem = { itemURL in + removed = true + } + try await runtimeInstaller.downloadAndInstallRuntime(identifier: "iOS 16.0", to: .xcodesCaches, with: .urlSession, shouldDelete: false) + XCTAssertFalse(removed) + } +} + +private func mockDMGPathPlist(path: String = NSHomeDirectory()) -> String { + return """ + + system-entities + + + + + mount-point + \(path) + + + + + + """ } From 1c952ecc8227fd53c5433a27f9e340f43a53e672 Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Sat, 12 Nov 2022 20:25:50 +0100 Subject: [PATCH 09/12] Document runtime installing steps --- Sources/XcodesKit/RuntimeInstaller.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index b4b865c..a07abb5 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -69,26 +69,35 @@ public class RuntimeInstaller { private func installFromPackage(dmgUrl: URL, runtime: DownloadableRuntime) async throws { Current.logging.log("Mounting DMG") + // 1-Mount DMG and get the mounted path let mountedUrl = try await mountDMG(dmgUrl: dmgUrl) + // 2-Get the first path under the mounted path, should be a .pkg let pkgPath = try! Path(url: mountedUrl)!.ls().first!.path + // 3-Create a caches directory (if it doesn't exist), and + // 4-Set its ownership to the current user (important because under sudo it would be owned by root) try Path.xcodesCaches.mkdir().setCurrentUserAsOwner() let expandedPkgPath = Path.xcodesCaches/runtime.identifier try? Current.files.removeItem(at: expandedPkgPath.url) + // 5-Expand (not install) the pkg to temporary path try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url).asVoid().async() try await unmountDMG(mountedURL: mountedUrl) let packageInfoPath = expandedPkgPath/"PackageInfo" + // 6-Get the `PackageInfo` file contents from the expanded pkg let packageInfoContentsData = Current.files.contents(atPath: packageInfoPath.string)! var packageInfoContents = String(data: packageInfoContentsData, encoding: .utf8)! let runtimeFileName = "\(runtime.platform.shortName) \(runtime.simulatorVersion.version).simruntime" let runtimeDestination = Path("/Library/Developer/CoreSimulator/Profiles/Runtimes/\(runtimeFileName)")! packageInfoContents = packageInfoContents.replacingOccurrences(of: " Date: Sun, 13 Nov 2022 03:16:34 +0100 Subject: [PATCH 10/12] Show new non-downloadable runtimes --- Sources/XcodesKit/Models+Runtimes.swift | 22 +++- Sources/XcodesKit/RuntimeInstaller.swift | 118 +++++++++++++++--- Sources/XcodesKit/RuntimeList.swift | 4 +- .../Fixtures/LogOutput-Runtime_NoBetas.txt | 4 +- .../Fixtures/LogOutput-Runtimes.txt | 5 +- .../ShellOutput-InstalledRuntimes.json | 17 ++- Tests/XcodesKitTests/RuntimeTests.swift | 13 +- 7 files changed, 150 insertions(+), 33 deletions(-) diff --git a/Sources/XcodesKit/Models+Runtimes.swift b/Sources/XcodesKit/Models+Runtimes.swift index 6edefd5..7c3966a 100644 --- a/Sources/XcodesKit/Models+Runtimes.swift +++ b/Sources/XcodesKit/Models+Runtimes.swift @@ -22,19 +22,27 @@ public struct DownloadableRuntime: Decodable { let name: String let authentication: Authentication? - var betaVersion: Int? { + var betaNumber: Int? { enum Regex { static let shared = try! NSRegularExpression(pattern: "b[0-9]+$") } guard var foundString = Regex.shared.firstString(in: identifier) else { return nil } foundString.removeFirst() return Int(foundString)! } + var completeVersion: String { + makeVersion(for: simulatorVersion.version, betaNumber: betaNumber) + } + var visibleIdentifier: String { - let betaSuffix = betaVersion.flatMap { "-beta\($0)" } ?? "" - return platform.shortName + " " + simulatorVersion.version + betaSuffix + return platform.shortName + " " + completeVersion } } +func makeVersion(for osVersion: String, betaNumber: Int?) -> String { + let betaSuffix = betaNumber.flatMap { "-beta\($0)" } ?? "" + return osVersion + betaSuffix +} + struct SDKToSeedMapping: Decodable { let buildUpdate: String let platform: DownloadableRuntime.Platform @@ -126,5 +134,13 @@ extension InstalledRuntime { case tvOS = "com.apple.platform.appletvsimulator" case iOS = "com.apple.platform.iphonesimulator" case watchOS = "com.apple.platform.watchsimulator" + + var asPlatformOS: DownloadableRuntime.Platform { + switch self { + case .watchOS: return .watchOS + case .iOS: return .iOS + case .tvOS: return .tvOS + } + } } } diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index a07abb5..f049c21 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -15,32 +15,85 @@ public class RuntimeInstaller { } public func printAvailableRuntimes(includeBetas: Bool) async throws { - let downloadables = try await runtimeList.downloadableRuntimes(includeBetas: includeBetas) + let downloadablesResponse = try await runtimeList.downloadableRuntimes() var installed = try await runtimeList.installedRuntimes() - for (platform, downloadables) in Dictionary(grouping: downloadables, by: \.platform).sorted(\.key.order) { + + var mappedRuntimes: [PrintableRuntime] = [] + + downloadablesResponse.downloadables.forEach { downloadable in + let matchingInstalledRuntimes = installed.removeAll { $0.build == downloadable.simulatorVersion.buildUpdate } + if !matchingInstalledRuntimes.isEmpty { + matchingInstalledRuntimes.forEach { + mappedRuntimes.append(PrintableRuntime(platform: downloadable.platform, + betaNumber: downloadable.betaNumber, + version: downloadable.simulatorVersion.version, + build: downloadable.simulatorVersion.buildUpdate, + state: $0.kind)) + } + } else { + mappedRuntimes.append(PrintableRuntime(platform: downloadable.platform, + betaNumber: downloadable.betaNumber, + version: downloadable.simulatorVersion.version, + build: downloadable.simulatorVersion.buildUpdate)) + } + } + + + + + installed.forEach { runtime in + let resolvedBetaNumber = downloadablesResponse.sdkToSeedMappings.first { + $0.buildUpdate == runtime.build + }?.seedNumber + + var result = PrintableRuntime(platform: runtime.platformIdentifier.asPlatformOS, + betaNumber: resolvedBetaNumber, + version: runtime.version, + build: runtime.build, + state: runtime.kind) + + mappedRuntimes.indices { + result.visibleIdentifier == $0.visibleIdentifier + }.forEach { index in + result.hasDuplicateVersion = true + mappedRuntimes[index].hasDuplicateVersion = true + } + + mappedRuntimes.append(result) + } + + for (platform, runtimes) in Dictionary(grouping: mappedRuntimes, by: \.platform).sorted(\.key.order) { Current.logging.log("-- \(platform.shortName) --") - for downloadable in downloadables { - let matchingInstalledRuntimes = installed.remove { $0.build == downloadable.simulatorVersion.buildUpdate } - let name = downloadable.visibleIdentifier - if !matchingInstalledRuntimes.isEmpty { - for matchingInstalledRuntime in matchingInstalledRuntimes { - switch matchingInstalledRuntime.kind { - case .bundled: - Current.logging.log(name + " (Bundled with selected Xcode)") - case .diskImage, .legacyDownload: - Current.logging.log(name + " (Downloaded)") - } - } - } else { - Current.logging.log(name) + let sortedRuntimes = runtimes.sorted { first, second in + let firstVersion = Version(tolerant: first.completeVersion)! + let secondVersion = Version(tolerant: second.completeVersion)! + if firstVersion == secondVersion { + return first.build.compare(second.build, options: .numeric) == .orderedAscending } + return firstVersion < secondVersion + } + + for runtime in sortedRuntimes { + if !includeBetas && runtime.betaNumber != nil && runtime.state == nil { + continue + } + var str = runtime.visibleIdentifier + if runtime.hasDuplicateVersion { + str += " (\(runtime.build))" + } + if runtime.state == .legacyDownload || runtime.state == .diskImage { + str += " (Downloaded)" + } else if runtime.state == .bundled { + str += " (Bundled with selected Xcode)" + } + Current.logging.log(str) } } Current.logging.log("\nNote: Bundled runtimes are indicated for the currently selected Xcode, more bundled runtimes may exist in other Xcode(s)") } public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool) async throws { - let downloadables = try await runtimeList.downloadableRuntimes() + let downloadables = try await runtimeList.downloadableRuntimes().downloadables guard let matchedRuntime = downloadables.first(where: { $0.visibleIdentifier == identifier }) else { throw Error.unavailableRuntime(identifier) } @@ -175,8 +228,25 @@ extension RuntimeInstaller { } } +fileprivate struct PrintableRuntime { + let platform: DownloadableRuntime.Platform + let betaNumber: Int? + let version: String + let build: String + var state: InstalledRuntime.Kind? = nil + var hasDuplicateVersion = false + + var completeVersion: String { + makeVersion(for: version, betaNumber: betaNumber) + } + + var visibleIdentifier: String { + return platform.shortName + " " + completeVersion + } +} + extension Array { - fileprivate mutating func remove(where predicate: ((Element) -> Bool)) -> [Element] { + fileprivate mutating func removeAll(where predicate: ((Element) -> Bool)) -> [Element] { guard !isEmpty else { return [] } var removed: [Element] = [] self = filter { current in @@ -188,4 +258,16 @@ extension Array { } return removed } + + fileprivate func indices(where predicate: ((Element) -> Bool)) -> [Index] { + var result: [Index] = [] + + for index in indices { + if predicate(self[index]) { + result.append(index) + } + } + + return result + } } diff --git a/Sources/XcodesKit/RuntimeList.swift b/Sources/XcodesKit/RuntimeList.swift index 75ce61c..9b19a94 100644 --- a/Sources/XcodesKit/RuntimeList.swift +++ b/Sources/XcodesKit/RuntimeList.swift @@ -4,10 +4,10 @@ public class RuntimeList { public init() {} - func downloadableRuntimes(includeBetas: Bool = true) async throws -> [DownloadableRuntime] { + func downloadableRuntimes() async throws -> DownloadableRuntimesResponse { let (data, _) = try await Current.network.dataTask(with: URLRequest.runtimes).async() let decodedResponse = try PropertyListDecoder().decode(DownloadableRuntimesResponse.self, from: data) - return includeBetas ? decodedResponse.downloadables : decodedResponse.downloadables.filter { $0.betaVersion == nil } + return decodedResponse } func installedRuntimes() async throws -> [InstalledRuntime] { diff --git a/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt index 0a08b07..102f320 100644 --- a/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt +++ b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt @@ -31,7 +31,9 @@ watchOS 7.4 watchOS 8.0 watchOS 8.3 watchOS 8.5 (Bundled with selected Xcode) -watchOS 9.0 (Downloaded) +watchOS 9.0-beta4 (Downloaded) +watchOS 9.0 (20R362) +watchOS 9.0 (UnknownBuildNumber) (Downloaded) -- tvOS -- tvOS 12.4 tvOS 13.0 diff --git a/Tests/XcodesKitTests/Fixtures/LogOutput-Runtimes.txt b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtimes.txt index c6792ce..785b58b 100644 --- a/Tests/XcodesKitTests/Fixtures/LogOutput-Runtimes.txt +++ b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtimes.txt @@ -34,9 +34,10 @@ watchOS 8.5 (Bundled with selected Xcode) watchOS 9.0-beta1 watchOS 9.0-beta2 watchOS 9.0-beta3 -watchOS 9.0-beta4 +watchOS 9.0-beta4 (Downloaded) watchOS 9.0-beta5 -watchOS 9.0 (Downloaded) +watchOS 9.0 (20R362) +watchOS 9.0 (UnknownBuildNumber) (Downloaded) watchOS 9.1-beta1 -- tvOS -- tvOS 12.4 diff --git a/Tests/XcodesKitTests/Fixtures/ShellOutput-InstalledRuntimes.json b/Tests/XcodesKitTests/Fixtures/ShellOutput-InstalledRuntimes.json index 282e9ee..e3a7990 100644 --- a/Tests/XcodesKitTests/Fixtures/ShellOutput-InstalledRuntimes.json +++ b/Tests/XcodesKitTests/Fixtures/ShellOutput-InstalledRuntimes.json @@ -15,7 +15,7 @@ "version" : "13.1" }, "6DE6B631-9439-4737-A65B-73F675EB77D1" : { - "build" : "20R362", + "build" : "20R5332f", "deletable" : true, "identifier" : "6DE6B631-9439-4737-A65B-73F675EB77D1", "kind" : "Disk Image", @@ -29,6 +29,21 @@ "state" : "Ready", "version" : "9.0" }, + "6DE6B631-9439-4737-A65B-73F675EB77D2" : { + "build" : "UnknownBuildNumber", + "deletable" : true, + "identifier" : "6DE6B631-9439-4737-A65B-73F675EB77D2", + "kind" : "Disk Image", + "mountPath" : "\/Library\/Developer\/CoreSimulator\/Volumes\/watchOS_20R362", + "path" : "\/Library\/Developer\/CoreSimulator\/Images\/6DE6B631-9439-4737-A65B-73F675EB77D1.dmg", + "platformIdentifier" : "com.apple.platform.watchsimulator", + "runtimeBundlePath" : "\/Library\/Developer\/CoreSimulator\/Volumes\/watchOS_20R362\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/watchOS 9.0.simruntime", + "runtimeIdentifier" : "com.apple.CoreSimulator.SimRuntime.watchOS-9-0", + "signatureState" : "Verified", + "sizeBytes" : 3604135917, + "state" : "Ready", + "version" : "9.0" + }, "7A032D54-0D93-4E04-80B9-4CB207136C3F" : { "build" : "16G73", "deletable" : true, diff --git a/Tests/XcodesKitTests/RuntimeTests.swift b/Tests/XcodesKitTests/RuntimeTests.swift index d13fe52..2519138 100644 --- a/Tests/XcodesKitTests/RuntimeTests.swift +++ b/Tests/XcodesKitTests/RuntimeTests.swift @@ -66,6 +66,7 @@ final class RuntimeTests: XCTestCase { let givenIDs = [ UUID(uuidString: "2A6068A0-7FCF-4DB9-964D-21145EB98498")!, UUID(uuidString: "6DE6B631-9439-4737-A65B-73F675EB77D1")!, + UUID(uuidString: "6DE6B631-9439-4737-A65B-73F675EB77D2")!, UUID(uuidString: "7A032D54-0D93-4E04-80B9-4CB207136C3F")!, UUID(uuidString: "91B92361-CD02-4AF7-8DFE-DE8764AA949F")!, UUID(uuidString: "630146EA-A027-42B1-AC25-BE4EA018DE90")!, @@ -78,13 +79,13 @@ final class RuntimeTests: XCTestCase { func test_downloadableRuntimes() async throws { mockDownloadables() - let values = try await runtimeList.downloadableRuntimes() + let values = try await runtimeList.downloadableRuntimes().downloadables XCTAssertEqual(values.count, 57) } func test_downloadableRuntimesNoBetas() async throws { mockDownloadables() - let values = try await runtimeList.downloadableRuntimes(includeBetas: false) + let values = try await runtimeList.downloadableRuntimes().downloadables.filter { $0.betaNumber == nil } XCTAssertFalse(values.contains { $0.name.lowercased().contains("beta") }) XCTAssertEqual(values.count, 45) } @@ -131,7 +132,7 @@ final class RuntimeTests: XCTestCase { mockDownloadables() XcodesKit.Current.shell.isRoot = { false } let identifier = "iOS 15.5" - let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == identifier }! + let runtime = try await runtimeList.downloadableRuntimes().downloadables.first { $0.visibleIdentifier == identifier }! var resultError: RuntimeInstaller.Error? = nil do { try await runtimeInstaller.downloadAndInstallRuntime(identifier: identifier, to: .xcodesCaches, with: .urlSession, shouldDelete: true) @@ -147,7 +148,7 @@ final class RuntimeTests: XCTestCase { mockDownloadables() XcodesKit.Current.shell.isRoot = { false } let identifier = "iOS 16.0" - let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == identifier }! + let runtime = try await runtimeList.downloadableRuntimes().downloadables.first { $0.visibleIdentifier == identifier }! var resultError: RuntimeInstaller.Error? = nil do { try await runtimeInstaller.downloadAndInstallRuntime(identifier: identifier, to: .xcodesCaches, with: .urlSession, shouldDelete: true) @@ -162,7 +163,7 @@ final class RuntimeTests: XCTestCase { func test_downloadOrUseExistingArchive_ReturnsExistingArchive() async throws { Current.files.fileExistsAtPath = { _ in return true } mockDownloadables() - let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 15.5" }! + let runtime = try await runtimeList.downloadableRuntimes().downloadables.first { $0.visibleIdentifier == "iOS 15.5" }! var xcodeDownloadURL: URL? Current.network.downloadTask = { url, _, _ in xcodeDownloadURL = url.pmkRequest.url @@ -183,7 +184,7 @@ final class RuntimeTests: XCTestCase { xcodeDownloadURL = url.pmkRequest.url return (Progress(), Promise.value((destination, HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))) } - let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 15.5" }! + let runtime = try await runtimeList.downloadableRuntimes().downloadables.first { $0.visibleIdentifier == "iOS 15.5" }! let fileName = URL(string: runtime.source)!.lastPathComponent let url = try await runtimeInstaller.downloadOrUseExistingArchive(runtime: runtime, to: .xcodesCaches, downloader: .urlSession) XCTAssertEqual(url, Path.xcodesCaches.join(fileName).url) From 87f0c5cba4a368ec0f6e17802878edc474b8ee48 Mon Sep 17 00:00:00 2001 From: Steven Magdy Date: Sun, 13 Nov 2022 21:16:35 +0100 Subject: [PATCH 11/12] Change runtime filename --- Sources/XcodesKit/RuntimeInstaller.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index f049c21..7a9b914 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -138,7 +138,7 @@ public class RuntimeInstaller { // 6-Get the `PackageInfo` file contents from the expanded pkg let packageInfoContentsData = Current.files.contents(atPath: packageInfoPath.string)! var packageInfoContents = String(data: packageInfoContentsData, encoding: .utf8)! - let runtimeFileName = "\(runtime.platform.shortName) \(runtime.simulatorVersion.version).simruntime" + let runtimeFileName = "\(runtime.visibleIdentifier).simruntime" let runtimeDestination = Path("/Library/Developer/CoreSimulator/Profiles/Runtimes/\(runtimeFileName)")! packageInfoContents = packageInfoContents.replacingOccurrences(of: " Date: Wed, 16 Nov 2022 01:03:30 +0100 Subject: [PATCH 12/12] Enable installing by build number --- Sources/XcodesKit/RuntimeInstaller.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index 7a9b914..dd91799 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -94,7 +94,7 @@ public class RuntimeInstaller { public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool) async throws { let downloadables = try await runtimeList.downloadableRuntimes().downloadables - guard let matchedRuntime = downloadables.first(where: { $0.visibleIdentifier == identifier }) else { + guard let matchedRuntime = downloadables.first(where: { $0.visibleIdentifier == identifier || $0.simulatorVersion.buildUpdate == identifier }) else { throw Error.unavailableRuntime(identifier) } @@ -218,7 +218,7 @@ extension RuntimeInstaller { public var errorDescription: String? { switch self { case let .unavailableRuntime(version): - return "Could not find runtime \(version)." + return "Runtime \(version) is invalid or not downloadable" case .failedMountingDMG: return "Failed to mount image." case .rootNeeded: