From adbae2c9cda82a27c5c4c36ae6c815ed87b537f0 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sun, 24 Jan 2021 10:56:25 -0700 Subject: [PATCH 1/6] Add support for and default to Xcode Releases data source --- Package.resolved | 9 ++ Package.swift | 28 +++-- Sources/XcodesKit/DataSource.swift | 6 ++ Sources/XcodesKit/Version+XcodeReleases.swift | 54 ++++++++++ Sources/XcodesKit/XcodeInstaller.swift | 38 +++---- Sources/XcodesKit/XcodeList.swift | 100 +++++++++++++++--- .../DataSource+ExpressibleByArgument.swift | 4 + Sources/xcodes/main.swift | 42 ++++++-- Tests/XcodesKitTests/XcodesKitTests.swift | 8 +- 9 files changed, 238 insertions(+), 51 deletions(-) create mode 100644 Sources/XcodesKit/DataSource.swift create mode 100644 Sources/XcodesKit/Version+XcodeReleases.swift create mode 100644 Sources/xcodes/DataSource+ExpressibleByArgument.swift diff --git a/Package.resolved b/Package.resolved index a8ee643..84e6dd5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "XcodeReleases", + "repositoryURL": "https://github.com/xcodereleases/data", + "state": { + "branch": null, + "revision": "b47228c688b608e34b3b84079ab6052a24c7a981", + "version": null + } + }, { "package": "PMKFoundation", "repositoryURL": "https://github.com/PromiseKit/Foundation.git", diff --git a/Package.swift b/Package.swift index d7b446c..a64749b 100644 --- a/Package.swift +++ b/Package.swift @@ -20,25 +20,38 @@ let package = Package( .package(url: "https://github.com/scinfu/SwiftSoup.git", .upToNextMinor(from: "2.0.0")), .package(url: "https://github.com/mxcl/LegibleError.git", .upToNextMinor(from: "1.0.1")), .package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", .upToNextMinor(from: "3.2.0")), + .package(name: "XcodeReleases", url: "https://github.com/xcodereleases/data", .revision("b47228c688b608e34b3b84079ab6052a24c7a981")), ], targets: [ .target( name: "xcodes", dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), "XcodesKit" + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "XcodesKit" ]), .testTarget( name: "xcodesTests", - dependencies: ["xcodes"]), + dependencies: [ + "xcodes" + ]), .target( name: "XcodesKit", dependencies: [ - "AppleAPI", .product(name: "Path", package: "Path.swift"), "Version", "PromiseKit", "PMKFoundation", "SwiftSoup", "LegibleError", "KeychainAccess" + "AppleAPI", + "KeychainAccess", + "LegibleError", + .product(name: "Path", package: "Path.swift"), + "PromiseKit", + "PMKFoundation", + "SwiftSoup", + "Version", + .product(name: "XCModel", package: "XcodeReleases"), ]), .testTarget( name: "XcodesKitTests", dependencies: [ - "XcodesKit", "Version" + "XcodesKit", + "Version" ], resources: [ .copy("Fixtures"), @@ -46,11 +59,14 @@ let package = Package( .target( name: "AppleAPI", dependencies: [ - "PromiseKit", "PMKFoundation" + "PromiseKit", + "PMKFoundation" ]), .testTarget( name: "AppleAPITests", - dependencies: ["AppleAPI"], + dependencies: [ + "AppleAPI" + ], resources: [ .copy("Fixtures"), ]), diff --git a/Sources/XcodesKit/DataSource.swift b/Sources/XcodesKit/DataSource.swift new file mode 100644 index 0000000..4f01ccc --- /dev/null +++ b/Sources/XcodesKit/DataSource.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum DataSource: String, CaseIterable { + case apple + case xcodeReleases +} diff --git a/Sources/XcodesKit/Version+XcodeReleases.swift b/Sources/XcodesKit/Version+XcodeReleases.swift new file mode 100644 index 0000000..ef49d50 --- /dev/null +++ b/Sources/XcodesKit/Version+XcodeReleases.swift @@ -0,0 +1,54 @@ +import Version +import struct XCModel.Xcode + +extension Version { + /// Initialize a Version from an XcodeReleases' XCModel.Xcode + /// + /// This is kinda quick-and-dirty, and it would probably be better for us to adopt something closer to XCModel.Xcode under the hood and map the scraped data to it instead. + init?(xcReleasesXcode: XCModel.Xcode) { + var versionString = xcReleasesXcode.version.number ?? "" + + // Append trailing ".0" in order to get a fully-specified version string + let components = versionString.components(separatedBy: ".") + versionString += Array(repeating: ".0", count: 3 - components.count).joined() + + // Append prerelease identifier + switch xcReleasesXcode.version.release { + case let .beta(beta): + versionString += "-Beta" + if beta > 1 { + versionString += ".\(beta)" + } + case let .dp(dp): + versionString += "-DP" + if dp > 1 { + versionString += ".\(dp)" + } + case .gm: + versionString += "-GM" + case let .gmSeed(gmSeed): + versionString += "-GM.Seed" + if gmSeed > 1 { + versionString += ".\(gmSeed)" + } + case let .rc(rc): + versionString += "-Release.Candidate" + if rc > 1 { + versionString += ".\(rc)" + } + case .release: + break + } + + // Append build identifier + if let buildNumber = xcReleasesXcode.version.build { + versionString += "+\(buildNumber)" + } + + self.init(versionString) + } + + var buildMetadataIdentifiersDisplay: String { + return !buildMetadataIdentifiers.isEmpty ? "(\(buildMetadataIdentifiers.joined(separator: " ")))" : "" + } +} diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index f3b541c..57d88a9 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -150,9 +150,9 @@ public final class XcodeInstaller { case aria2(Path) } - public func install(_ installationType: InstallationType, downloader: Downloader, destination: Path) -> Promise { + public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path) -> Promise { return firstly { () -> Promise in - return self.install(installationType, downloader: downloader, destination: destination, attemptNumber: 0) + return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0) } .done { xcode in Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)") @@ -160,9 +160,9 @@ public final class XcodeInstaller { } } - private func install(_ installationType: InstallationType, downloader: Downloader, destination: Path, attemptNumber: Int) -> Promise { + private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int) -> Promise { return firstly { () -> Promise<(Xcode, URL)> in - return self.getXcodeArchive(installationType, downloader: downloader, destination: destination, willInstall: true) + return self.getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true) } .then { xcode, url -> Promise in return self.installArchivedXcode(xcode, at: url, to: destination) @@ -182,7 +182,7 @@ public final class XcodeInstaller { Current.logging.log(error.legibleLocalizedDescription) Current.logging.log("Removing damaged XIP and re-attempting installation.\n") try Current.files.removeItem(at: damagedXIPURL) - return self.install(installationType, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1) + return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1) } } default: @@ -191,9 +191,9 @@ public final class XcodeInstaller { } } - public func download(_ installation: InstallationType, downloader: Downloader, destinationDirectory: Path) -> Promise { + public func download(_ installation: InstallationType, dataSource: DataSource, downloader: Downloader, destinationDirectory: Path) -> Promise { return firstly { () -> Promise<(Xcode, URL)> in - return self.getXcodeArchive(installation, downloader: downloader, destination: destinationDirectory, willInstall: false) + return self.getXcodeArchive(installation, dataSource: dataSource, downloader: downloader, destination: destinationDirectory, willInstall: false) } .map { (xcode, url) -> (Xcode, URL) in let destination = destinationDirectory.url.appendingPathComponent(url.lastPathComponent) @@ -206,13 +206,13 @@ public final class XcodeInstaller { } } - private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader, destination: Path, willInstall: Bool) -> Promise<(Xcode, URL)> { + private func getXcodeArchive(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, willInstall: Bool) -> Promise<(Xcode, URL)> { return firstly { () -> Promise<(Xcode, URL)> in switch installationType { case .latest: Current.logging.log("Updating...") - return update() + return update(dataSource: dataSource) .then { availableXcodes -> Promise<(Xcode, URL)> in guard let latestNonPrereleaseXcode = availableXcodes.filter(\.version.isNotPrerelease).sorted(\.version).last else { throw Error.noNonPrereleaseVersionAvailable @@ -223,12 +223,12 @@ public final class XcodeInstaller { throw Error.versionAlreadyInstalled(installedXcode) } - return self.downloadXcode(version: latestNonPrereleaseXcode.version, downloader: downloader, willInstall: willInstall) + return self.downloadXcode(version: latestNonPrereleaseXcode.version, dataSource: dataSource, downloader: downloader, willInstall: willInstall) } case .latestPrerelease: Current.logging.log("Updating...") - return update() + return update(dataSource: dataSource) .then { availableXcodes -> Promise<(Xcode, URL)> in guard let latestPrereleaseXcode = availableXcodes .filter({ $0.version.isPrerelease }) @@ -244,7 +244,7 @@ public final class XcodeInstaller { throw Error.versionAlreadyInstalled(installedXcode) } - return self.downloadXcode(version: latestPrereleaseXcode.version, downloader: downloader, willInstall: willInstall) + return self.downloadXcode(version: latestPrereleaseXcode.version, dataSource: dataSource, downloader: downloader, willInstall: willInstall) } case .path(let versionString, let path): guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else { @@ -259,7 +259,7 @@ public final class XcodeInstaller { if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) { throw Error.versionAlreadyInstalled(installedXcode) } - return self.downloadXcode(version: version, downloader: downloader, willInstall: willInstall) + return self.downloadXcode(version: version, dataSource: dataSource, downloader: downloader, willInstall: willInstall) } } } @@ -272,13 +272,13 @@ public final class XcodeInstaller { return version } - private func downloadXcode(version: Version, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> { + private func downloadXcode(version: Version, dataSource: DataSource, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> { return firstly { () -> Promise in loginIfNeeded().map { version } } .then { version -> Promise in if self.xcodeList.shouldUpdate { - return self.xcodeList.update().map { _ in version } + return self.xcodeList.update(dataSource: dataSource).map { _ in version } } else { return Promise.value(version) @@ -559,17 +559,17 @@ public final class XcodeInstaller { } } - func update() -> Promise<[Xcode]> { + func update(dataSource: DataSource) -> Promise<[Xcode]> { return firstly { () -> Promise in loginIfNeeded() } .then { () -> Promise<[Xcode]> in - self.xcodeList.update() + self.xcodeList.update(dataSource: dataSource) } } - public func updateAndPrint(directory: Path) -> Promise { - update() + public func updateAndPrint(dataSource: DataSource, directory: Path) -> Promise { + update(dataSource: dataSource) .then { xcodes -> Promise in self.printAvailableXcodes(xcodes, installed: Current.files.installedXcodes(directory)) } diff --git a/Sources/XcodesKit/XcodeList.swift b/Sources/XcodesKit/XcodeList.swift index dae4046..12fc3c9 100644 --- a/Sources/XcodesKit/XcodeList.swift +++ b/Sources/XcodesKit/XcodeList.swift @@ -3,6 +3,7 @@ import Path import Version import PromiseKit import SwiftSoup +import XCModel /// Provides lists of available and installed Xcodes public final class XcodeList { @@ -16,20 +17,30 @@ public final class XcodeList { return availableXcodes.isEmpty } - public func update() -> Promise<[Xcode]> { - return when(fulfilled: releasedXcodes(), prereleaseXcodes()) - .map { releasedXcodes, prereleaseXcodes in - // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. - // Previously pre-release versions only appeared on developer.apple.com/download. - // /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build. - // If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes. - let xcodes = releasedXcodes.filter { releasedXcode in - prereleaseXcodes.contains { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: releasedXcode.version) } == false - } + prereleaseXcodes - self.availableXcodes = xcodes - try? self.cacheAvailableXcodes(xcodes) - return xcodes - } + public func update(dataSource: DataSource) -> Promise<[Xcode]> { + switch dataSource { + case .apple: + return when(fulfilled: releasedXcodes(), prereleaseXcodes()) + .map { releasedXcodes, prereleaseXcodes in + // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. + // Previously pre-release versions only appeared on developer.apple.com/download. + // /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build. + // If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes. + let xcodes = releasedXcodes.filter { releasedXcode in + prereleaseXcodes.contains { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: releasedXcode.version) } == false + } + prereleaseXcodes + self.availableXcodes = xcodes + try? self.cacheAvailableXcodes(xcodes) + return xcodes + } + case .xcodeReleases: + return xcodeReleases() + .map { xcodes in + self.availableXcodes = xcodes + try? self.cacheAvailableXcodes(xcodes) + return xcodes + } + } } } @@ -49,6 +60,8 @@ extension XcodeList { } extension XcodeList { + // MARK: - Apple + private func releasedXcodes() -> Promise<[Xcode]> { return firstly { () -> Promise<(data: Data, response: URLResponse)> in Current.network.dataTask(with: URLRequest.downloads) @@ -101,3 +114,62 @@ extension XcodeList { return [Xcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))] } } + +extension XcodeList { + // MARK: - XcodeReleases + + private func xcodeReleases() -> Promise<[Xcode]> { + return firstly { () -> Promise<(data: Data, response: URLResponse)> in + Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!)) + } + .map { (data, response) in + let decoder = JSONDecoder() + let xcReleasesXcodes = try decoder.decode([XCModel.Xcode].self, from: data) + let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> Xcode? in + guard + let downloadURL = xcReleasesXcode.links?.download?.url, + let version = Version(xcReleasesXcode: xcReleasesXcode) + else { return nil } + + let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents( + year: xcReleasesXcode.date.year, + month: xcReleasesXcode.date.month, + day: xcReleasesXcode.date.day + )) + + return Xcode( + version: version, + url: downloadURL, + filename: String(downloadURL.path.suffix(fromLast: "/")), + releaseDate: releaseDate + ) + } + return xcodes + } + .map(filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers) + } + + /// Xcode Releases may have multiple releases with the same build metadata when a build doesn't change between candidate and final releases. + /// For example, 12.3 RC and 12.3 are both build 12C33 + /// We don't care about that difference, so only keep the final release (GM or Release, in XCModel terms). + /// The downside of this is that a user could technically have both releases installed, and so they won't both be shown in the list, but I think most users wouldn't do this. + func filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers(_ xcodes: [Xcode]) -> [Xcode] { + var filteredXcodes: [Xcode] = [] + for xcode in xcodes { + if xcode.version.buildMetadataIdentifiers.isEmpty { + filteredXcodes.append(xcode) + continue + } + + let xcodesWithSameBuildMetadataIdentifiers = xcodes + .filter({ $0.version.buildMetadataIdentifiers == xcode.version.buildMetadataIdentifiers }) + if xcodesWithSameBuildMetadataIdentifiers.count > 1, + xcode.version.prereleaseIdentifiers.isEmpty || xcode.version.prereleaseIdentifiers == ["GM"] { + filteredXcodes.append(xcode) + } else if xcodesWithSameBuildMetadataIdentifiers.count == 1 { + filteredXcodes.append(xcode) + } + } + return filteredXcodes + } +} diff --git a/Sources/xcodes/DataSource+ExpressibleByArgument.swift b/Sources/xcodes/DataSource+ExpressibleByArgument.swift new file mode 100644 index 0000000..0eb58e5 --- /dev/null +++ b/Sources/xcodes/DataSource+ExpressibleByArgument.swift @@ -0,0 +1,4 @@ +import ArgumentParser +import XcodesKit + +extension DataSource: ExpressibleByArgument {} diff --git a/Sources/xcodes/main.swift b/Sources/xcodes/main.swift index 287f8ff..a8b714d 100644 --- a/Sources/xcodes/main.swift +++ b/Sources/xcodes/main.swift @@ -23,6 +23,20 @@ struct GlobalDirectoryOption: ParsableArguments { var directory: String? } +struct GlobalDataSourceOption: ParsableArguments { + @Option( + help: ArgumentParser.ArgumentHelp( + "The data source for available Xcode versions.", + discussion: """ + The Apple data source ("apple") scrapes the Apple Developer website. It will always show the latest releases that are available, but is more fragile. + + Xcode Releases ("xcodeReleases") is an unofficial list of Xcode releases. It's provided as well-formed data, contains extra information that is not readily available from Apple, and is less likely to break if Apple redesigns their developer website. + """ + ) + ) + var dataSource: DataSource = .xcodeReleases +} + struct Xcodes: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Manage the Xcodes installed on your Mac", @@ -58,7 +72,7 @@ struct Xcodes: ParsableCommand { ) @Argument(help: "The version to download", - completion: .custom { args in xcodeList.availableXcodes.sorted { $0.version < $1.version }.map { $0.version.xcodeDescription } }) + completion: .custom { args in xcodeList.availableXcodes.sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) var version: [String] = [] @Flag(help: "Update and then download the latest non-prerelease version available.") @@ -78,6 +92,9 @@ struct Xcodes: ParsableCommand { completion: .directory) var directory: String? + @OptionGroup + var globalDataSource: GlobalDataSourceOption + func run() { let versionString = version.joined(separator: " ") @@ -100,7 +117,7 @@ struct Xcodes: ParsableCommand { let destination = getDirectory(possibleDirectory: directory, default: Path.home.join("Downloads")) - installer.download(installation, downloader: downloader, destinationDirectory: destination) + installer.download(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destinationDirectory: destination) .catch { error in switch error { case Process.PMKError.execution(let process, let standardOutput, let standardError): @@ -136,7 +153,7 @@ struct Xcodes: ParsableCommand { ) @Argument(help: "The version to install", - completion: .custom { args in xcodeList.availableXcodes.sorted { $0.version < $1.version }.map { $0.version.xcodeDescription } }) + completion: .custom { args in xcodeList.availableXcodes.sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) var version: [String] = [] @Option(name: .customLong("path"), @@ -161,6 +178,9 @@ struct Xcodes: ParsableCommand { completion: .directory) var directory: String? + @OptionGroup + var globalDataSource: GlobalDataSourceOption + func run() { let versionString = version.joined(separator: " ") @@ -184,7 +204,7 @@ struct Xcodes: ParsableCommand { let destination = getDirectory(possibleDirectory: directory) - installer.install(installation, downloader: downloader, destination: destination) + installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination) .done { Install.exit() } .catch { error in switch error { @@ -231,12 +251,15 @@ struct Xcodes: ParsableCommand { @OptionGroup var globalDirectory: GlobalDirectoryOption + @OptionGroup + var globalDataSource: GlobalDataSourceOption + func run() { let directory = getDirectory(possibleDirectory: globalDirectory.directory) firstly { () -> Promise in if xcodeList.shouldUpdate { - return installer.updateAndPrint(directory: directory) + return installer.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) } else { return installer.printAvailableXcodes(xcodeList.availableXcodes, installed: Current.files.installedXcodes(directory)) @@ -267,7 +290,7 @@ struct Xcodes: ParsableCommand { var print: Bool = false @Argument(help: "Version or path", - completion: .custom { _ in Current.files.installedXcodes(getDirectory(possibleDirectory: nil)).sorted { $0.version < $1.version }.map { $0.version.xcodeDescription } }) + completion: .custom { _ in Current.files.installedXcodes(getDirectory(possibleDirectory: nil)).sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) var versionOrPath: [String] = [] @OptionGroup @@ -297,7 +320,7 @@ struct Xcodes: ParsableCommand { ) @Argument(help: "The version to uninstall", - completion: .custom { _ in Current.files.installedXcodes(getDirectory(possibleDirectory: nil)).sorted { $0.version < $1.version }.map { $0.version.xcodeDescription } }) + completion: .custom { _ in Current.files.installedXcodes(getDirectory(possibleDirectory: nil)).sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) var version: [String] = [] @OptionGroup @@ -322,10 +345,13 @@ struct Xcodes: ParsableCommand { @OptionGroup var globalDirectory: GlobalDirectoryOption + @OptionGroup + var globalDataSource: GlobalDataSourceOption + func run() { let directory = getDirectory(possibleDirectory: globalDirectory.directory) - installer.updateAndPrint(directory: directory) + installer.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 0f35146..d87f3e2 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -197,7 +197,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), downloader: .urlSession, destination: Path.root.join("Applications")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications")) .ensure { let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -290,7 +290,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), downloader: .urlSession, destination: Path.home.join("Xcode")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode")) .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) @@ -404,7 +404,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), downloader: .urlSession, destination: Path.root.join("Applications")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications")) .ensure { let url = Bundle.module.url(forResource: "LogOutput-IncorrectSavedPassword", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -522,7 +522,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), downloader: .urlSession, destination: Path.root.join("Applications")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications")) .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) From b43154fa848449523d55a4c35e053808e2ee4ee6 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sun, 24 Jan 2021 11:32:46 -0700 Subject: [PATCH 2/6] Always display build identifier in lists --- Sources/XcodesKit/Version+Xcode.swift | 22 ++++++++++++++----- Sources/XcodesKit/XcodeInstaller.swift | 16 +++++++------- Sources/XcodesKit/XcodeSelect.swift | 2 +- .../Fixtures/Stub.version.plist | 2 +- Tests/XcodesKitTests/Version+XcodeTests.swift | 16 +++++++------- Tests/XcodesKitTests/XcodesKitTests.swift | 4 ++-- 6 files changed, 36 insertions(+), 26 deletions(-) diff --git a/Sources/XcodesKit/Version+Xcode.swift b/Sources/XcodesKit/Version+Xcode.swift index 02e254c..9aa4c92 100644 --- a/Sources/XcodesKit/Version+Xcode.swift +++ b/Sources/XcodesKit/Version+Xcode.swift @@ -38,22 +38,32 @@ public extension Version { self = Version(major: major, minor: minor, patch: patch, prereleaseIdentifiers: prereleaseIdentifiers, buildMetadataIdentifiers: [buildMetadataIdentifier].compactMap { $0 }) } - var xcodeDescription: String { + /// The intent here is to match Apple's marketing version + /// + /// Only show the patch number if it's not 0 + /// Format prerelease identifiers + /// Don't include build identifiers + var appleDescription: String { var base = "\(major).\(minor)" if patch != 0 { base += ".\(patch)" } if !prereleaseIdentifiers.isEmpty { base += " " + prereleaseIdentifiers - .map { $0.replacingOccurrences(of: "-", with: " ").capitalized.replacingOccurrences(of: "Gm", with: "GM") } + .map { identifier in + identifier + .replacingOccurrences(of: "-", with: " ") + .capitalized + .replacingOccurrences(of: "Gm", with: "GM") + .replacingOccurrences(of: "Rc", with: "RC") + } .joined(separator: " ") - - if !buildMetadataIdentifiers.isEmpty { - base += " (\(buildMetadataIdentifiers.joined(separator: " ")))" - } } return base } + var appleDescriptionWithBuildIdentifier: String { + [appleDescription, buildMetadataIdentifiersDisplay].filter { !$0.isEmpty }.joined(separator: " ") + } } extension NSTextCheckingResult { diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 57d88a9..977b117 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -58,7 +58,7 @@ public final class XcodeInstaller { case .missingSudoerPassword: return "Missing password. Please try again." case let .unavailableVersion(version): - return "Could not find version \(version.xcodeDescription)." + return "Could not find version \(version.appleDescription)." case .noNonPrereleaseVersionAvailable: return "No non-prerelease versions available." case .noPrereleaseVersionAvailable: @@ -66,11 +66,11 @@ public final class XcodeInstaller { case .missingUsernameOrPassword: return "Missing username or a password. Please try again." case let .versionAlreadyInstalled(installedXcode): - return "\(installedXcode.version.xcodeDescription) is already installed at \(installedXcode.path)" + return "\(installedXcode.version.appleDescription) is already installed at \(installedXcode.path)" case let .invalidVersion(version): return "\(version) is not a valid version number." case let .versionNotInstalled(version): - return "\(version.xcodeDescription) is not installed." + return "\(version.appleDescription) is not installed." } } } @@ -217,7 +217,7 @@ public final class XcodeInstaller { 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.xcodeDescription)") + Current.logging.log("Latest non-prerelease version available is \(latestNonPrereleaseXcode.version.appleDescription)") if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestNonPrereleaseXcode.version) }) { throw Error.versionAlreadyInstalled(installedXcode) @@ -238,7 +238,7 @@ public final class XcodeInstaller { else { throw Error.noNonPrereleaseVersionAvailable } - Current.logging.log("Latest prerelease version available is \(latestPrereleaseXcode.version.xcodeDescription)") + Current.logging.log("Latest prerelease version available is \(latestPrereleaseXcode.version.appleDescription)") if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestPrereleaseXcode.version) }) { throw Error.versionAlreadyInstalled(installedXcode) @@ -554,7 +554,7 @@ public final class XcodeInstaller { } } .done { (installedXcode, trashURL) in - Current.logging.log("Xcode \(installedXcode.version.xcodeDescription) moved to Trash: \(trashURL.path)") + Current.logging.log("Xcode \(installedXcode.version.appleDescription) moved to Trash: \(trashURL.path)") Current.shell.exit(0) } } @@ -614,7 +614,7 @@ public final class XcodeInstaller { return first.version < second.version } .forEach { releasedVersion in - var output = releasedVersion.version.xcodeDescription + var output = releasedVersion.version.appleDescriptionWithBuildIdentifier if installedXcodes.contains(where: { releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) { if releasedVersion.version == selectedInstalledXcodeVersion { output += " (Installed, Selected)" @@ -634,7 +634,7 @@ public final class XcodeInstaller { Current.files.installedXcodes(directory) .sorted { $0.version < $1.version } .forEach { installedXcode in - var output = installedXcode.version.xcodeDescription + var output = installedXcode.version.appleDescriptionWithBuildIdentifier if pathOutput.out.hasPrefix(installedXcode.path.string) { output += " (Selected)" } diff --git a/Sources/XcodesKit/XcodeSelect.swift b/Sources/XcodesKit/XcodeSelect.swift index 2fe8250..4932a1f 100644 --- a/Sources/XcodesKit/XcodeSelect.swift +++ b/Sources/XcodesKit/XcodeSelect.swift @@ -76,7 +76,7 @@ public func chooseFromInstalledXcodesInteractively(currentPath: String, director sortedInstalledXcodes .enumerated() .forEach { index, installedXcode in - var output = "\(index + 1)) \(installedXcode.version.xcodeDescription)" + var output = "\(index + 1)) \(installedXcode.version.appleDescriptionWithBuildIdentifier)" if currentPath.hasPrefix(installedXcode.path.string) { output += " (Selected)" } diff --git a/Tests/XcodesKitTests/Fixtures/Stub.version.plist b/Tests/XcodesKitTests/Fixtures/Stub.version.plist index 317a3ef..8070792 100644 --- a/Tests/XcodesKitTests/Fixtures/Stub.version.plist +++ b/Tests/XcodesKitTests/Fixtures/Stub.version.plist @@ -3,6 +3,6 @@ ProductBuildVersion - 0.0.0 + ABC123 diff --git a/Tests/XcodesKitTests/Version+XcodeTests.swift b/Tests/XcodesKitTests/Version+XcodeTests.swift index 0a7776b..37853f4 100644 --- a/Tests/XcodesKitTests/Version+XcodeTests.swift +++ b/Tests/XcodesKitTests/Version+XcodeTests.swift @@ -18,14 +18,14 @@ class VersionXcodeTests: XCTestCase { XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 GM seed 2"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed", "2"])) } - func test_XcodeDescription() { - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0).xcodeDescription, "10.2") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 1).xcodeDescription, "10.2.1") - XCTAssertEqual(Version(major: 11, minor: 0, patch: 0, prereleaseIdentifiers: ["beta"]).xcodeDescription, "11.0 Beta") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["beta", "4"]).xcodeDescription, "10.2 Beta 4") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm"]).xcodeDescription, "10.2 GM") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed"]).xcodeDescription, "10.2 GM Seed") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed", "1"]).xcodeDescription, "10.2 GM Seed 1") + func test_AppleDescription() { + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0).appleDescription, "10.2") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 1).appleDescription, "10.2.1") + XCTAssertEqual(Version(major: 11, minor: 0, patch: 0, prereleaseIdentifiers: ["beta"]).appleDescription, "11.0 Beta") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["beta", "4"]).appleDescription, "10.2 Beta 4") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm"]).appleDescription, "10.2 GM") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed"]).appleDescription, "10.2 GM Seed") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed", "1"]).appleDescription, "10.2 GM Seed 1") } func test_Equivalence() { diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index d87f3e2..13d742e 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -897,8 +897,8 @@ final class XcodesKitTests: XCTestCase { XCTAssertEqual(log, """ Available Xcode versions: - 1) 0.0 - 2) 2.0.1 (Selected) + 1) 0.0 (ABC123) + 2) 2.0.1 (ABC123) (Selected) Enter the number of the Xcode to select: xcodes requires superuser privileges to select an Xcode macOS User Password: From 2fad2ec0e1f07b00a4bb75c7e6ab47d036dc0e72 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sun, 24 Jan 2021 11:43:10 -0700 Subject: [PATCH 3/6] Simplify version comparison --- .../XcodesKit/Models+FirstWithVersion.swift | 24 +++++--- Sources/XcodesKit/Version+.swift | 57 ++++++------------- Sources/XcodesKit/Version+Xcode.swift | 4 +- Sources/XcodesKit/XcodeInstaller.swift | 12 ++-- Sources/XcodesKit/XcodeList.swift | 2 +- .../Models+FirstWithVersionTests.swift | 45 +++++++++++++-- Tests/XcodesKitTests/Version+XcodeTests.swift | 15 +---- 7 files changed, 86 insertions(+), 73 deletions(-) diff --git a/Sources/XcodesKit/Models+FirstWithVersion.swift b/Sources/XcodesKit/Models+FirstWithVersion.swift index e7566c3..e18bb39 100644 --- a/Sources/XcodesKit/Models+FirstWithVersion.swift +++ b/Sources/XcodesKit/Models+FirstWithVersion.swift @@ -3,20 +3,20 @@ import Version /// Returns the first XcodeType that unambiguously has the same version as `version`. /// -/// If there's an exact match that takes prerelease identifiers into account, that's returned. +/// If there's an equivalent match that takes prerelease identifiers into account, that's returned. /// Otherwise, if a version without prerelease or build metadata identifiers is provided, and there's a single match based on only the major, minor and patch numbers, that's returned. /// If there are multiple matches, or no matches, nil is returned. public func findXcode(version: Version, in xcodes: [XcodeType], versionKeyPath: KeyPath) -> XcodeType? { - // Look for the exact provided version first - if let installedXcode = xcodes.first(where: { $0[keyPath: versionKeyPath].isEqualWithoutBuildMetadataIdentifiers(to: version) }) { - return installedXcode + // Look for the equivalent provided version first + if let equivalentXcode = xcodes.first(where: { $0[keyPath: versionKeyPath].isEquivalent(to: version) }) { + return equivalentXcode } - // If a short version is provided, look again for a match, ignore all - // identifiers this time. Ignore if there are more than one match. + // If a version without prerelease or build identifiers is provided, then ignore all identifiers this time. + // There must be exactly one match. else if version.prereleaseIdentifiers.isEmpty && version.buildMetadataIdentifiers.isEmpty, xcodes.filter({ $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) }).count == 1 { - let installedXcode = xcodes.first(where: { $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) })! - return installedXcode + let matchedXcode = xcodes.first(where: { $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) })! + return matchedXcode } else { return nil } @@ -43,3 +43,11 @@ public extension Array where Element == InstalledXcode { findXcode(version: version, in: self, versionKeyPath: \.version) } } + +extension Version { + func isEqualWithoutAllIdentifiers(to other: Version) -> Bool { + return major == other.major && + minor == other.minor && + patch == other.patch + } +} diff --git a/Sources/XcodesKit/Version+.swift b/Sources/XcodesKit/Version+.swift index 271afeb..e86d4a5 100644 --- a/Sources/XcodesKit/Version+.swift +++ b/Sources/XcodesKit/Version+.swift @@ -1,47 +1,24 @@ import Version public extension Version { - func isEqualWithoutBuildMetadataIdentifiers(to other: Version) -> Bool { - return major == other.major && - minor == other.minor && - patch == other.patch && - prereleaseIdentifiers == other.prereleaseIdentifiers - } - - func isEqualWithoutAllIdentifiers(to other: Version) -> Bool { - return major == other.major && - minor == other.minor && - patch == other.patch - } - - /// If release versions, don't compare build metadata because that's not provided in the /downloads/more list - /// if beta versions, compare build metadata because it's available in versions.plist - func isEquivalentForDeterminingIfInstalled(toInstalled installed: Version) -> Bool { - let isBeta = !prereleaseIdentifiers.isEmpty - let otherIsBeta = !installed.prereleaseIdentifiers.isEmpty - - if isBeta && otherIsBeta { - if buildMetadataIdentifiers.isEmpty { - return major == installed.major && - minor == installed.minor && - patch == installed.patch && - prereleaseIdentifiers == installed.prereleaseIdentifiers - } - else { - return major == installed.major && - minor == installed.minor && - patch == installed.patch && - prereleaseIdentifiers == installed.prereleaseIdentifiers && - buildMetadataIdentifiers.map { $0.lowercased() } == installed.buildMetadataIdentifiers.map { $0.lowercased() } - } - } - else if !isBeta && !otherIsBeta { - return major == installed.major && - minor == installed.minor && - patch == installed.patch + /// Determines if two Xcode versions should be treated equivalently. This is not the same as equality. + /// + /// We need a way to determine if two Xcode versions are the same without always having full information, and supporting different data sources. + /// For example, the Apple data source often doesn't have build metadata identifiers. + func isEquivalent(to other: Version) -> Bool { + // If we don't have build metadata identifiers for both Versions, compare major, minor, patch and prerelease identifiers. + if buildMetadataIdentifiers.isEmpty || other.buildMetadataIdentifiers.isEmpty { + return major == other.major && + minor == other.minor && + patch == other.patch && + prereleaseIdentifiers.map { $0.lowercased() } == other.prereleaseIdentifiers.map { $0.lowercased() } + // If we have build metadata identifiers for both, we can ignore the prerelease identifiers. + } else { + return major == other.major && + minor == other.minor && + patch == other.patch && + buildMetadataIdentifiers.map { $0.lowercased() } == other.buildMetadataIdentifiers.map { $0.lowercased() } } - - return false } var descriptionWithoutBuildMetadata: String { diff --git a/Sources/XcodesKit/Version+Xcode.swift b/Sources/XcodesKit/Version+Xcode.swift index 9aa4c92..a73723e 100644 --- a/Sources/XcodesKit/Version+Xcode.swift +++ b/Sources/XcodesKit/Version+Xcode.swift @@ -16,8 +16,8 @@ public extension Version { */ init?(xcodeVersion: String, buildMetadataIdentifier: String? = nil) { let nsrange = NSRange(xcodeVersion.startIndex.. Date: Sun, 24 Jan 2021 11:44:20 -0700 Subject: [PATCH 4/6] Bump swift-argument-parser to 0.3.2 --- Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.resolved b/Package.resolved index 84e6dd5..836e6a8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -60,8 +60,8 @@ "repositoryURL": "https://github.com/apple/swift-argument-parser", "state": { "branch": null, - "revision": "92646c0cdbaca076c8d3d0207891785b3379cbff", - "version": "0.3.1" + "revision": "9564d61b08a5335ae0a36f789a7d71493eacadfc", + "version": "0.3.2" } }, { From 400aa0de32c73f55280514f36ff166440a736892 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Wed, 27 Jan 2021 21:31:22 -0700 Subject: [PATCH 5/6] Make sure we have the download cookie when using Xcode Releases --- Sources/XcodesKit/XcodeInstaller.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 857952e..d6f6167 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -284,6 +284,14 @@ public final class XcodeInstaller { return Promise.value(version) } } + .then { version -> Promise in + // This request would've already been made if the Apple data source were being used. + // That's not the case for the Xcode Releases data source. + // We need the cookies from its response in order to download Xcodes though, + // so perform it here first just to be sure. + Current.network.dataTask(with: URLRequest.downloads) + .map { _ in version } + } .then { version -> Promise<(Xcode, URL)> in guard let xcode = self.xcodeList.availableXcodes.first(withVersion: version) else { throw Error.unavailableVersion(version) From 1fe1b46491f8bb6714fba78b14907ca63aec09f3 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Mon, 1 Feb 2021 20:23:12 -0700 Subject: [PATCH 6/6] Apply suggestions from code review Co-authored-by: Sam Lu --- Sources/XcodesKit/Version+.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Sources/XcodesKit/Version+.swift b/Sources/XcodesKit/Version+.swift index e86d4a5..74170db 100644 --- a/Sources/XcodesKit/Version+.swift +++ b/Sources/XcodesKit/Version+.swift @@ -8,15 +8,11 @@ public extension Version { func isEquivalent(to other: Version) -> Bool { // If we don't have build metadata identifiers for both Versions, compare major, minor, patch and prerelease identifiers. if buildMetadataIdentifiers.isEmpty || other.buildMetadataIdentifiers.isEmpty { - return major == other.major && - minor == other.minor && - patch == other.patch && + return isEqualWithoutAllIdentifiers(to: other) && prereleaseIdentifiers.map { $0.lowercased() } == other.prereleaseIdentifiers.map { $0.lowercased() } // If we have build metadata identifiers for both, we can ignore the prerelease identifiers. } else { - return major == other.major && - minor == other.minor && - patch == other.patch && + return isEqualWithoutAllIdentifiers(to: other) && buildMetadataIdentifiers.map { $0.lowercased() } == other.buildMetadataIdentifiers.map { $0.lowercased() } } }