From dedf03cc9f0800c064fd1e6e6773efcdf45c64d4 Mon Sep 17 00:00:00 2001 From: Andreas Ganske Date: Wed, 12 Oct 2022 23:23:50 +0200 Subject: [PATCH 1/2] Add option to download specific build of Xcode --- Sources/XcodesKit/XcodeInstaller.swift | 72 ++++++++++++++++++++++++-- Sources/xcodes/App.swift | 5 ++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 445ad17..f8f9992 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -21,6 +21,7 @@ public final class XcodeInstaller { case unsupportedFileFormat(extension: String) case missingSudoerPassword case unavailableVersion(Version) + case unavailableBuild(String) case noNonPrereleaseVersionAvailable case noPrereleaseVersionAvailable case missingUsernameOrPassword @@ -61,6 +62,8 @@ public final class XcodeInstaller { return "Missing password. Please try again." case let .unavailableVersion(version): return "Could not find version \(version.appleDescription)." + case let .unavailableBuild(build): + return "Could not find build \(build)." case .noNonPrereleaseVersionAvailable: return "No non-prerelease versions available." case .noPrereleaseVersionAvailable: @@ -156,6 +159,7 @@ public final class XcodeInstaller { public enum InstallationType { case version(String) + case build(String) case path(String, Path) case latest case latestPrerelease @@ -276,6 +280,13 @@ public final class XcodeInstaller { throw Error.versionAlreadyInstalled(installedXcode) } return self.downloadXcode(version: version, dataSource: dataSource, downloader: downloader, willInstall: willInstall) + case .build(let build): + if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { let buildMetadataIdentifiers = Set($0.version.buildMetadataIdentifiers) + return buildMetadataIdentifiers.contains(build) + }) { + throw Error.versionAlreadyInstalled(installedXcode) + } + return self.downloadXcode(build: build, dataSource: dataSource, downloader: downloader, willInstall: willInstall) } } } @@ -287,8 +298,8 @@ public final class XcodeInstaller { .flatMap(Version.init(gemVersion:)) return version } - - private func downloadXcode(version: Version, dataSource: DataSource, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> { + + private func findXcode(version: Version, dataSource: DataSource) -> Promise { return firstly { () -> Promise in if dataSource == .apple { return loginIfNeeded().map { version } @@ -308,11 +319,48 @@ public final class XcodeInstaller { return Promise.value(version) } } - .then { version -> Promise<(Xcode, URL)> in + .map { version -> Xcode in guard let xcode = self.xcodeList.availableXcodes.first(withVersion: version) else { throw Error.unavailableVersion(version) } - + return xcode + } + } + + private func findXcode(build: String, dataSource: DataSource) -> Promise { + return firstly { () -> Promise in + if dataSource == .apple { + return loginIfNeeded().map { build } + } else { + guard let xcode = self.xcodeList.availableXcodes.first(where: { xcode in + xcode.version.buildMetadataIdentifiers.contains(build) + }) else { + throw Error.unavailableVersion(version) + } + + return validateADCSession(path: xcode.downloadPath).map { build } + } + } + .then { build -> Promise in + if self.xcodeList.shouldUpdate { + return self.xcodeList.update(dataSource: dataSource).map { _ in build } + } + else { + return Promise.value(build) + } + } + .map { build -> Xcode in + guard let xcode = self.xcodeList.availableXcodes.first(where: { xcode in + xcode.version.buildMetadataIdentifiers.contains(build) + }) else { + throw Error.unavailableBuild(build) + } + return xcode + } + } + + private func downloadXcode(xcode: Xcode, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> { + return firstly { 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("") @@ -337,6 +385,22 @@ public final class XcodeInstaller { .map { return (xcode, $0) } } } + + private func downloadXcode(version: Version, dataSource: DataSource, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> { + return firstly { + findXcode(version: version, dataSource: dataSource) + }.then { xcode in + self.downloadXcode(xcode: xcode, downloader: downloader, willInstall: willInstall) + } + } + + private func downloadXcode(build: String, dataSource: DataSource, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> { + return firstly { + findXcode(build: build, dataSource: dataSource) + }.then { xcode in + self.downloadXcode(xcode: xcode, downloader: downloader, willInstall: willInstall) + } + } func validateADCSession(path: String) -> Promise { return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid() diff --git a/Sources/xcodes/App.swift b/Sources/xcodes/App.swift index de9ae87..91737ed 100644 --- a/Sources/xcodes/App.swift +++ b/Sources/xcodes/App.swift @@ -180,6 +180,9 @@ struct Xcodes: AsyncParsableCommand { completion: .file(extensions: ["xip"])) var pathString: String? + @Flag(help: "Install a specific build number of Xcode.") + var build = false + @Flag(help: "Update and then install the latest non-prerelease version available.") var latest: Bool = false @@ -224,6 +227,8 @@ struct Xcodes: AsyncParsableCommand { installation = .latestPrerelease } else if let pathString = pathString, let path = Path(pathString) { installation = .path(versionString, path) + } else if build { + installation = .build(versionString) } else { installation = .version(versionString) } From dd0688a7d2635a90bc958121d2ce6f35e325a3a7 Mon Sep 17 00:00:00 2001 From: Andreas Ganske Date: Thu, 13 Oct 2022 12:56:32 +0200 Subject: [PATCH 2/2] Use type safe build identifier --- .../xcshareddata/xcschemes/xcodes.xcscheme | 4 +++ Sources/XcodesKit/Build.swift | 25 ++++++++++++++ Sources/XcodesKit/XcodeInstaller.swift | 34 +++++++++++++------ Tests/XcodesKitTests/Build+XcodeTests.swift | 15 ++++++++ 4 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 Sources/XcodesKit/Build.swift create mode 100644 Tests/XcodesKitTests/Build+XcodeTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/xcodes.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/xcodes.xcscheme index cfe6296..3a76266 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/xcodes.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/xcodes.xcscheme @@ -127,6 +127,10 @@ argument = "install 11 beta 5" isEnabled = "NO"> + + diff --git a/Sources/XcodesKit/Build.swift b/Sources/XcodesKit/Build.swift new file mode 100644 index 0000000..c447ef7 --- /dev/null +++ b/Sources/XcodesKit/Build.swift @@ -0,0 +1,25 @@ +import Foundation + +public struct Build: Equatable, CustomStringConvertible { + + let identifier: String + + /** + E.g.: + 13E500a + 12E507 + 7B85 + */ + init?(identifier: String) { + let nsrange = NSRange(identifier.startIndex.. Build? { + let xcodeVersionFilePath = Path.cwd.join(".xcode-version") + let version = (try? Data(contentsOf: xcodeVersionFilePath.url)) + .flatMap { String(data: $0, encoding: .utf8) } + .flatMap(Build.init(identifier:)) + return version + } + private func findXcode(version: Version, dataSource: DataSource) -> Promise { return firstly { () -> Promise in if dataSource == .apple { @@ -327,13 +341,13 @@ public final class XcodeInstaller { } } - private func findXcode(build: String, dataSource: DataSource) -> Promise { - return firstly { () -> Promise in + private func findXcode(build: Build, dataSource: DataSource) -> Promise { + return firstly { () -> Promise in if dataSource == .apple { return loginIfNeeded().map { build } } else { guard let xcode = self.xcodeList.availableXcodes.first(where: { xcode in - xcode.version.buildMetadataIdentifiers.contains(build) + xcode.version.buildMetadataIdentifiers.contains(build.identifier) }) else { throw Error.unavailableVersion(version) } @@ -341,7 +355,7 @@ public final class XcodeInstaller { return validateADCSession(path: xcode.downloadPath).map { build } } } - .then { build -> Promise in + .then { build -> Promise in if self.xcodeList.shouldUpdate { return self.xcodeList.update(dataSource: dataSource).map { _ in build } } @@ -351,7 +365,7 @@ public final class XcodeInstaller { } .map { build -> Xcode in guard let xcode = self.xcodeList.availableXcodes.first(where: { xcode in - xcode.version.buildMetadataIdentifiers.contains(build) + xcode.version.buildMetadataIdentifiers.contains(build.identifier) }) else { throw Error.unavailableBuild(build) } @@ -394,7 +408,7 @@ public final class XcodeInstaller { } } - private func downloadXcode(build: String, dataSource: DataSource, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> { + private func downloadXcode(build: Build, dataSource: DataSource, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> { return firstly { findXcode(build: build, dataSource: dataSource) }.then { xcode in diff --git a/Tests/XcodesKitTests/Build+XcodeTests.swift b/Tests/XcodesKitTests/Build+XcodeTests.swift new file mode 100644 index 0000000..5a9ddd0 --- /dev/null +++ b/Tests/XcodesKitTests/Build+XcodeTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import XcodesKit + +class BuildXcodeTests: XCTestCase { + + func test_InitXcodeVersion() { + XCTAssertNotNil(Build(identifier: "13E500a")) + XCTAssertNotNil(Build(identifier: "12E507")) + XCTAssertNotNil(Build(identifier: "7B85")) + XCTAssertNil(Build(identifier: "13.1.0")) + XCTAssertNil(Build(identifier: "13")) + XCTAssertNil(Build(identifier: "14B")) + } + +}