diff --git a/Package.resolved b/Package.resolved index f5bd336..6908220 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "d378874644005a42fe5c5448269610ae930ce2b443ba5a5e596c651b0d0b8563", + "originHash" : "a3667ba6371a1f9d2ef80136ea7d0d87fca3a6b13bd95ce0479d96a0fb0c09de", "pins" : [ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "41982a3656a71c768319979febd796c6fd111d5c", - "version" : "1.5.0" + "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", + "version" : "1.6.1" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/saagarjha/unxip", "state" : { - "branch" : "main", - "revision" : "0348f523cf633a92dc9e5b23c6a1fe701f6f8098" + "revision" : "03c32d6b4e7d8a9fb58747e5f8b83397da8bf5ba", + "version" : "3.2.0" } } ], diff --git a/Package.swift b/Package.swift index b79e2f8..3ed3e04 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ let package = Package( ), .package( url: "https://github.com/saagarjha/unxip", - branch: "main" + from: "3.2.0" ), ], targets: [ diff --git a/Sources/XcodeVersionManager/Commands/Arguments/InstalledXcodeVersion.swift b/Sources/XcodeVersionManager/Commands/Arguments/InstalledXcodeVersion.swift index 8f788b7..088632c 100644 --- a/Sources/XcodeVersionManager/Commands/Arguments/InstalledXcodeVersion.swift +++ b/Sources/XcodeVersionManager/Commands/Arguments/InstalledXcodeVersion.swift @@ -10,16 +10,14 @@ struct InstalledXcodeVersion: ParsableArguments { help: ArgumentHelp( "The version number of Xcode to uninstall." ), - completion: .custom { words in + completion: .custom { _, _, _ in do { - return try _unsafeWait { - var formatter = XcodeVersionFormatter() - formatter.separator = "-" - - return try await XcodeApplication - .all() - .map(formatter.string) - } + var formatter = XcodeVersionFormatter() + formatter.separator = "-" + + return try await XcodeApplication + .all() + .map(formatter.string) } catch { return [] } diff --git a/Sources/XcodeVersionManager/Commands/DownloadCommand.swift b/Sources/XcodeVersionManager/Commands/DownloadCommand.swift index 82b9c45..646936a 100644 --- a/Sources/XcodeVersionManager/Commands/DownloadCommand.swift +++ b/Sources/XcodeVersionManager/Commands/DownloadCommand.swift @@ -8,6 +8,12 @@ struct DownloadCommand: AsyncParsableCommand { abstract: "Open the browser to download a version of Xcode." ) + @Flag( + name: .shortAndLong, + help: .init("Prefer downloading a Universal version, if available.") + ) + var preferUniversal: Bool = false + @Flag( name: .shortAndLong, help: .init("Download the matched version of Xcode without prompting for confirmation.") @@ -19,17 +25,12 @@ struct DownloadCommand: AsyncParsableCommand { "The version number to download.", discussion: "Matches Xcode versions that start with the entered string. In the case of multiple matches, the newest matching version of Xcode is used." ), - completion: .custom { _ in + completion: .custom { _, _, _ in do { - struct EmptyResponse: Error {} - - let versionNumbers = try _unsafeWait { - let releases = try await XcodeReleases().releases - guard !releases.isEmpty else { throw EmptyResponse() } - return releases.map { $0.version.formatted(style: .option) } - } - - return ["latest"] + Set(versionNumbers).sorted(by: >) + let releases = try await XcodeReleasesAPI.releases() + let versionNumbers = releases.map { $0.version.formatted(style: .option) } + let versions = ["latest"] + versionNumbers + return versions } catch { return [] } @@ -38,13 +39,28 @@ struct DownloadCommand: AsyncParsableCommand { var version: String = "latest" func run() async throws { - let releases = try await XcodeReleases() + let currentArchitecture = Architecture.current + + let releases = try await XcodeReleasesAPI.releases() + .filter { release in + guard let currentArchitecture else { return true } + + return release.download.architectures.isEmpty + || release.download.architectures.contains(currentArchitecture) + } + .sorted { lhs, rhs in + if preferUniversal { + lhs.download.architectures.count > rhs.download.architectures.count + } else { + lhs.download.architectures.count < rhs.download.architectures.count + } + } let matchingRelease: XcodeRelease? if version == "latest" { - matchingRelease = releases.releases.first + matchingRelease = releases.first } else { - matchingRelease = releases.releases + matchingRelease = releases .first { $0.version.formatted(style: .option).hasPrefix(version) } } @@ -62,7 +78,7 @@ struct DownloadCommand: AsyncParsableCommand { var downloadComponents = URLComponents(string: "https://developer.apple.com/services-account/download")! downloadComponents.queryItems = [ - .init(name: "path", value: matchingRelease.downloadURL.path) + .init(name: "path", value: matchingRelease.download.url.path) ] NSWorkspace.shared.open(downloadComponents.url!) diff --git a/Sources/XcodeVersionManager/Commands/ListCommand.swift b/Sources/XcodeVersionManager/Commands/ListCommand.swift index 8cd6036..e92ff88 100644 --- a/Sources/XcodeVersionManager/Commands/ListCommand.swift +++ b/Sources/XcodeVersionManager/Commands/ListCommand.swift @@ -5,44 +5,99 @@ import TableKit struct ListCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "list", - abstract: "Lists the installed versions of Xcode." + abstract: "Lists versions of Xcode.", + subcommands: [ + InstalledCommand.self, + KnownCommand.self + ], + defaultSubcommand: InstalledCommand.self ) - - func run() async throws { - let formatter = XcodeVersionFormatter() +} + +extension ListCommand { + struct InstalledCommand: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "installed", + abstract: "Lists the installed versions of Xcode." + ) - let current = try XcodeApplication.current() + func run() async throws { + let formatter = XcodeVersionFormatter() + + let current = try XcodeApplication.current() + + let rows = try await XcodeApplication + .all() + .sorted(by: >) + .map { + Row( + current: $0 == current ? "*" : " ", + formattedVersion: formatter.string(from: $0), + buildNumber: $0.buildNumber, + name: $0.url.lastPathComponent + ) + } + + let style = TableStyle(header: .inside, body: .inside, paddingSize: 1) + let encoder = TableEncoder(style: style) + print(try encoder.encode(rows)) + } - let formattedValues = try await XcodeApplication - .all() - .sorted(by: <) - .map { - FormattedXcodeApplication( - current: $0 == current ? "*" : " ", - formattedVersion: formatter.string(from: $0), - buildNumber: $0.buildNumber, - name: $0.url.lastPathComponent - ) + private struct Row: Encodable { + let current: String + let formattedVersion: String + let buildNumber: String + let name: String + + enum CodingKeys: String, CodingKey { + case current = "" + case formattedVersion = "Version" + case buildNumber = "Build" + case name = "Name" } - - let style = TableStyle(header: .inside, body: .inside, paddingSize: 1) - let encoder = TableEncoder(style: style) - print(try encoder.encode(formattedValues)) + } } } -// MARK: - FormattedXcodeApplication -private struct FormattedXcodeApplication: Encodable { - let current: String - let formattedVersion: String - let buildNumber: String - let name: String - - enum CodingKeys: String, CodingKey { - case current = "" - case formattedVersion = "Version" - case buildNumber = "Build" - case name = "Name" +extension ListCommand { + struct KnownCommand: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "known", + abstract: "Lists the known versions of Xcode." + ) + + func run() async throws { + let rows = try await XcodeReleasesAPI + .releases() + .map { + Row( + formattedVersion: $0.version.formatted(style: .pretty), + buildNumber: $0.version.build, + requires: $0.requires.formatted(), + architectures: $0.download.architectures + .map(\.rawValue) + .joined(separator: ", ") + ) + } + + let style = TableStyle(header: .inside, body: .inside, paddingSize: 1) + let encoder = TableEncoder(style: style) + print(try encoder.encode(rows)) + } + + private struct Row: Encodable { + let formattedVersion: String + let buildNumber: String + let requires: String + let architectures: String + + enum CodingKeys: String, CodingKey { + case formattedVersion = "Version" + case buildNumber = "Build" + case requires = "Requires" + case architectures = "Architectures" + } + } } } @@ -61,3 +116,15 @@ private extension TableStyle.Body { .init(inner: "│") } } + +extension OperatingSystemVersion { + func formatted() -> String { + var parts: [Int] = [ + majorVersion, minorVersion + ] + + if patchVersion != 0 { parts.append(patchVersion) } + + return parts.map(String.init).joined(separator: ".") + } +} diff --git a/Sources/XcodeVersionManager/Models/Architecture.swift b/Sources/XcodeVersionManager/Models/Architecture.swift new file mode 100644 index 0000000..0d5bd3e --- /dev/null +++ b/Sources/XcodeVersionManager/Models/Architecture.swift @@ -0,0 +1,22 @@ +import Darwin + +enum Architecture: String { + case arm64 + case x86_64 + + static var current: Self? { + var name: utsname = .init() + uname(&name) + + let machine = withUnsafePointer(to: name.machine) { pointer in + pointer.withMemoryRebound( + to: UInt8.self, + capacity: MemoryLayout.size(ofValue: name.machine) + ) { pointer in + String(cString: pointer) + } + } + + return .init(rawValue: machine) + } +} diff --git a/Sources/XcodeVersionManager/Models/CalendarDate.swift b/Sources/XcodeVersionManager/Models/CalendarDate.swift new file mode 100644 index 0000000..e8c3555 --- /dev/null +++ b/Sources/XcodeVersionManager/Models/CalendarDate.swift @@ -0,0 +1,13 @@ +struct CalendarDate: Decodable { + let year: Int + let month: Int + let day: Int +} + +extension CalendarDate: Comparable { + static func < (lhs: CalendarDate, rhs: CalendarDate) -> Bool { + guard lhs.year == rhs.year else { return lhs.year < rhs.year } + guard lhs.month == rhs.month else { return lhs.month < rhs.month } + return lhs.day < rhs.day + } +} diff --git a/Sources/XcodeVersionManager/Models/XcodeRelease.swift b/Sources/XcodeVersionManager/Models/XcodeRelease.swift new file mode 100644 index 0000000..cdcab27 --- /dev/null +++ b/Sources/XcodeVersionManager/Models/XcodeRelease.swift @@ -0,0 +1,37 @@ +import Foundation + +struct XcodeRelease { + let download: Download + let version: Version + let requires: OperatingSystemVersion + let date: CalendarDate + + struct Download: Equatable { + let architectures: [Architecture] + let url: URL + } + + struct Version: Equatable { + let number: String + let build: String + let release: Release + + enum Release: Codable, Equatable { + case beta(Int) + case rc(Int) + case release + } + } +} + +extension XcodeRelease: Equatable { + static func == (lhs: XcodeRelease, rhs: XcodeRelease) -> Bool { + lhs.version == rhs.version + } +} + +extension XcodeRelease: Comparable { + static func < (lhs: XcodeRelease, rhs: XcodeRelease) -> Bool { + lhs.date < rhs.date + } +} diff --git a/Sources/XcodeVersionManager/Models/XcodeReleases.swift b/Sources/XcodeVersionManager/Models/XcodeReleases.swift deleted file mode 100644 index fccdbc9..0000000 --- a/Sources/XcodeVersionManager/Models/XcodeReleases.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// File.swift -// -// -// Created by Craig on 2023-10-30. -// - -import Foundation - -class XcodeReleases { - var releases: [XcodeRelease] = [] - - init() async throws { - let (data, _) = try await URLSession.shared.data(from: URL(string: "https://xcodereleases.com/data.json")!) - - releases = try JSONDecoder() - .decode([XcodeReleaseResponse].self, from: data) - .prefix(100) - .compactMap(XcodeRelease.init(response:)) - } -} - -struct XcodeRelease: Codable, Equatable { - let downloadURL: URL - let version: Version - - fileprivate init?(response: XcodeReleaseResponse) { - guard let downloadURL = response.links?.download?.url, - let version = Version(response: response.version) - else { return nil } - - self.downloadURL = downloadURL - self.version = version - } - - struct Version: Codable, Equatable { - let number: String - let build: String - let release: Release - - fileprivate init?(response: XcodeReleaseResponse.Version) { - guard let release = Release(response: response.release) else { return nil } - - self.number = response.number - self.build = response.build - self.release = release - } - - enum Release: Codable, Equatable { - case beta(Int) - case rc(Int) - case release - - fileprivate init?(response: XcodeReleaseResponse.Version.Release) { - if let beta = response.beta { - self = .beta(beta) - } else if let rc = response.rc { - self = .rc(rc) - } else if response.release == true { - self = .release - } else { - return nil - } - } - } - } -} - -private struct XcodeReleaseResponse: Decodable { - let links: Links? - let version: Version - - struct Links: Decodable { - let download: Download? - - struct Download: Decodable { - let url: URL - } - } - - struct Version: Decodable { - let number: String - let build: String - let release: Release - - struct Release: Decodable { - let beta: Int? - let rc: Int? - let release: Bool? - } - } -} diff --git a/Sources/XcodeVersionManager/Models/XcodeReleasesAPI.swift b/Sources/XcodeVersionManager/Models/XcodeReleasesAPI.swift new file mode 100644 index 0000000..2a39b3b --- /dev/null +++ b/Sources/XcodeVersionManager/Models/XcodeReleasesAPI.swift @@ -0,0 +1,99 @@ +import Foundation + +enum XcodeReleasesAPI { + static func releases() async throws -> [XcodeRelease] { + let (data, _) = try await URLSession.shared.data(from: URL(string: "https://xcodereleases.com/data.json")!) + + return try JSONDecoder() + .decode([Responses.XcodeRelease].self, from: data) + .compactMap(XcodeRelease.init(response:)) + } +} + +// MARK: - Responses + +private extension XcodeReleasesAPI { + enum Responses {} +} + +extension XcodeReleasesAPI.Responses { + struct XcodeRelease: Decodable { + let links: Links? + let version: Version + let requires: String + let date: CalendarDate + + struct Links: Decodable { + let download: Download? + + struct Download: Decodable { + let architectures: [String]? + let url: URL + } + } + + struct Version: Decodable { + let number: String + let build: String + let release: Release + + struct Release: Decodable { + let beta: Int? + let rc: Int? + let release: Bool? + } + } + } +} + +// MARK: - Inits + +private extension XcodeRelease { + init?(response: XcodeReleasesAPI.Responses.XcodeRelease) { + guard let responseDownload = response.links?.download, + let version = Version(response: response.version) + else { return nil } + + let download = Download( + architectures: responseDownload.architectures?.compactMap(Architecture.init) ?? [], + url: responseDownload.url + ) + + let requiresParts = response.requires.components(separatedBy: ".").compactMap(Int.init) + var requires = OperatingSystemVersion() + if requiresParts.count > 0 { requires.majorVersion = requiresParts[0] } + if requiresParts.count > 1 { requires.minorVersion = requiresParts[1] } + if requiresParts.count > 2 { requires.patchVersion = requiresParts[2] } + + self.init( + download: download, + version: version, + requires: requires, + date: response.date + ) + } +} + +private extension XcodeRelease.Version { + init?(response: XcodeReleasesAPI.Responses.XcodeRelease.Version) { + guard let release = Release(response: response.release) else { return nil } + + self.number = response.number + self.build = response.build + self.release = release + } +} + +private extension XcodeRelease.Version.Release { + init?(response: XcodeReleasesAPI.Responses.XcodeRelease.Version.Release) { + if let beta = response.beta { + self = .beta(beta) + } else if let rc = response.rc { + self = .rc(rc) + } else if response.release == true { + self = .release + } else { + return nil + } + } +}