Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Install from build identifier #232

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/xcodes.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@
argument = "install 11 beta 5"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "install --build 13A5201i"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "runtimes"
isEnabled = "NO">
Expand Down
25 changes: 25 additions & 0 deletions Sources/XcodesKit/Build.swift
Original file line number Diff line number Diff line change
@@ -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..<identifier.endIndex, in: identifier)
let pattern = "^\\d+[A-Z]\\d+[a-z]*$"
guard
let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
regex.firstMatch(in: identifier, options: [], range: nsrange) != nil else {
return nil
}
self.identifier = identifier
}

public var description: String { identifier }
}
86 changes: 82 additions & 4 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ public final class XcodeInstaller {
case unsupportedFileFormat(extension: String)
case missingSudoerPassword
case unavailableVersion(Version)
case unavailableBuild(Build)
case noNonPrereleaseVersionAvailable
case noPrereleaseVersionAvailable
case missingUsernameOrPassword
case versionAlreadyInstalled(InstalledXcode)
case invalidVersion(String)
case invalidBuild(String)
case versionNotInstalled(Version)

public var errorDescription: String? {
Expand Down Expand Up @@ -61,6 +63,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:
Expand All @@ -71,6 +75,8 @@ public final class XcodeInstaller {
return "\(installedXcode.version.appleDescription) is already installed at \(installedXcode.path)"
case let .invalidVersion(version):
return "\(version) is not a valid version number."
case let .invalidBuild(buildIdentifier):
return "\(buildIdentifier) is not a valid build identifier."
case let .versionNotInstalled(version):
return "\(version.appleDescription) is not installed."
}
Expand Down Expand Up @@ -156,6 +162,7 @@ public final class XcodeInstaller {

public enum InstallationType {
case version(String)
case build(String)
case path(String, Path)
case latest
case latestPrerelease
Expand Down Expand Up @@ -276,6 +283,16 @@ public final class XcodeInstaller {
throw Error.versionAlreadyInstalled(installedXcode)
}
return self.downloadXcode(version: version, dataSource: dataSource, downloader: downloader, willInstall: willInstall)
case .build(let buildIdentifier):
guard let build = Build(identifier: buildIdentifier) ?? buildFromXcodeVersionFile() else {
throw Error.invalidBuild(buildIdentifier)
}
if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: {
$0.version.buildMetadataIdentifiers.contains(build.identifier)
}) {
throw Error.versionAlreadyInstalled(installedXcode)
}
return self.downloadXcode(build: build, dataSource: dataSource, downloader: downloader, willInstall: willInstall)
}
}
}
Expand All @@ -287,8 +304,16 @@ 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 buildFromXcodeVersionFile() -> 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<Xcode> {
return firstly { () -> Promise<Version> in
if dataSource == .apple {
return loginIfNeeded().map { version }
Expand All @@ -308,11 +333,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: Build, dataSource: DataSource) -> Promise<Xcode> {
return firstly { () -> Promise<Build> in
if dataSource == .apple {
return loginIfNeeded().map { build }
} else {
guard let xcode = self.xcodeList.availableXcodes.first(where: { xcode in
xcode.version.buildMetadataIdentifiers.contains(build.identifier)
}) else {
throw Error.unavailableVersion(version)
}

return validateADCSession(path: xcode.downloadPath).map { build }
}
}
.then { build -> Promise<Build> 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.identifier)
}) 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("")
Expand All @@ -337,6 +399,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: Build, 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<Void> {
return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid()
Expand Down
5 changes: 5 additions & 0 deletions Sources/xcodes/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand Down
15 changes: 15 additions & 0 deletions Tests/XcodesKitTests/Build+XcodeTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}

}