Skip to content

Commit

Permalink
Merge pull request #121 from art-divin/feature/download_only
Browse files Browse the repository at this point in the history
download command to download without installing/expanding
  • Loading branch information
Brandon Evans committed Dec 31, 2020
2 parents a30c846 + 2b85374 commit 1478a34
Show file tree
Hide file tree
Showing 3 changed files with 113 additions and 25 deletions.
64 changes: 47 additions & 17 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,25 @@ public final class XcodeInstaller {

/// A numbered step
enum InstallationStep: CustomStringConvertible {
case downloading(version: String, progress: String)
case downloading(version: String, progress: String, willInstall: Bool)
case unarchiving
case moving(destination: String)
case trashingArchive(archiveName: String)
case checkingSecurity
case finishing

var description: String {
"(\(stepNumber)/\(stepCount)) \(message)"
switch self {
case .downloading(_, _, let willInstall) where !willInstall:
return "(\(stepNumber)/\(InstallationStep.downloadStepCount)) \(message)"
default:
return "(\(stepNumber)/\(InstallationStep.installStepCount)) \(message)"
}
}

var message: String {
switch self {
case .downloading(let version, let progress):
case .downloading(let version, let progress, _):
return "Downloading Xcode \(version): \(progress)"
case .unarchiving:
return "Unarchiving Xcode (This can take a while)"
Expand All @@ -116,7 +121,13 @@ public final class XcodeInstaller {
}
}

var stepCount: Int { 6 }
static var downloadStepCount: Int {
return 1
}

static var installStepCount: Int {
return 6
}
}

private var configuration: Configuration
Expand Down Expand Up @@ -151,7 +162,7 @@ public final class XcodeInstaller {

private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> Promise<InstalledXcode> {
return firstly { () -> Promise<(Xcode, URL)> in
return self.getXcodeArchive(installationType, downloader: downloader)
return self.getXcodeArchive(installationType, downloader: downloader, willInstall: true)
}
.then { xcode, url -> Promise<InstalledXcode> in
return self.installArchivedXcode(xcode, at: url)
Expand Down Expand Up @@ -180,7 +191,22 @@ public final class XcodeInstaller {
}
}

private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader) -> Promise<(Xcode, URL)> {
public func download(_ installation: InstallationType, downloader: Downloader, destinationDirectory: Path) -> Promise<Void> {
return firstly { () -> Promise<(Xcode, URL)> in
return self.getXcodeArchive(installation, downloader: downloader, willInstall: false)
}
.map { (xcode, url) -> (Xcode, URL) in
let destination = destinationDirectory.url.appendingPathComponent(url.lastPathComponent)
try Current.files.moveItem(at: url, to: destination)
return (xcode, destination)
}
.done { (xcode, url) in
Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been downloaded to \(url.path)")
Current.shell.exit(0)
}
}

private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> {
return firstly { () -> Promise<(Xcode, URL)> in
switch installationType {
case .latest:
Expand All @@ -193,11 +219,11 @@ public final class XcodeInstaller {
}
Current.logging.log("Latest non-prerelease version available is \(latestNonPrereleaseXcode.version.xcodeDescription)")

if let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestNonPrereleaseXcode.version) }) {
if willInstall, let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestNonPrereleaseXcode.version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}

return self.downloadXcode(version: latestNonPrereleaseXcode.version, downloader: downloader)
return self.downloadXcode(version: latestNonPrereleaseXcode.version, downloader: downloader, willInstall: willInstall)
}
case .latestPrerelease:
Current.logging.log("Updating...")
Expand All @@ -214,11 +240,11 @@ public final class XcodeInstaller {
}
Current.logging.log("Latest prerelease version available is \(latestPrereleaseXcode.version.xcodeDescription)")

if let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestPrereleaseXcode.version) }) {
if willInstall, let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestPrereleaseXcode.version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}

return self.downloadXcode(version: latestPrereleaseXcode.version, downloader: downloader)
return self.downloadXcode(version: latestPrereleaseXcode.version, downloader: downloader, willInstall: willInstall)
}
case .path(let versionString, let path):
guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else {
Expand All @@ -230,10 +256,10 @@ public final class XcodeInstaller {
guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else {
throw Error.invalidVersion(versionString)
}
if let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) {
if willInstall, let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}
return self.downloadXcode(version: version, downloader: downloader)
return self.downloadXcode(version: version, downloader: downloader, willInstall: willInstall)
}
}
}
Expand All @@ -246,7 +272,7 @@ public final class XcodeInstaller {
return version
}

private func downloadXcode(version: Version, downloader: Downloader) -> Promise<(Xcode, URL)> {
private func downloadXcode(version: Version, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> {
return firstly { () -> Promise<Version> in
loginIfNeeded().map { version }
}
Expand All @@ -268,11 +294,11 @@ public final class XcodeInstaller {
let formatter = NumberFormatter(numberStyle: .percent)
var observation: NSKeyValueObservation?

let promise = self.downloadOrUseExistingArchive(for: xcode, downloader: downloader, progressChanged: { progress in
let promise = self.downloadOrUseExistingArchive(for: xcode, downloader: downloader, willInstall: willInstall, progressChanged: { progress in
observation?.invalidate()
observation = progress.observe(\.fractionCompleted) { progress, _ in
// These escape codes move up a line and then clear to the end
Current.logging.log("\u{1B}[1A\u{1B}[K\(InstallationStep.downloading(version: xcode.version.description, progress: formatter.string(from: progress.fractionCompleted)!))")
Current.logging.log("\u{1B}[1A\u{1B}[K\(InstallationStep.downloading(version: xcode.version.description, progress: formatter.string(from: progress.fractionCompleted)!, willInstall: willInstall))")
}
})

Expand Down Expand Up @@ -378,7 +404,7 @@ public final class XcodeInstaller {
return nil
}

public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> Promise<URL> {
public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, willInstall: Bool, progressChanged: @escaping (Progress) -> Void) -> Promise<URL> {
// 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: "."))"
// aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
Expand All @@ -388,7 +414,11 @@ public final class XcodeInstaller {
aria2DownloadIsIncomplete = true
}
if Current.files.fileExistsAtPath(expectedArchivePath.string), aria2DownloadIsIncomplete == false {
Current.logging.log("(1/6) Found existing archive that will be used for installation at \(expectedArchivePath).")
if willInstall {
Current.logging.log("(1/\(InstallationStep.installStepCount)) Found existing archive that will be used for installation at \(expectedArchivePath).")
} else {
Current.logging.log("(1/\(InstallationStep.downloadStepCount)) Found existing archive at \(expectedArchivePath).")
}
return Promise.value(expectedArchivePath.url)
}
else {
Expand Down
70 changes: 64 additions & 6 deletions Sources/xcodes/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,20 @@ let update = Command(usage: "update",
}
app.add(subCommand: update)

let pathFlag = Flag(longName: "path", type: String.self, description: "Local path to Xcode .xip")
let latestFlag = Flag(longName: "latest", value: false, description: "Update and then install the latest non-prerelease version available.")
let latestPrereleaseFlag = Flag(longName: "latest-prerelease", value: false, description: "Update and then install the latest prerelease version available, including GM seeds and GMs.")
let installPathFlag = Flag(longName: "path", type: String.self, description: "Local path to Xcode .xip")
let installLatestFlag = Flag(longName: "latest", value: false, description: "Update and then install the latest non-prerelease version available.")
let installLatestPrereleaseFlag = Flag(longName: "latest-prerelease", value: false, description: "Update and then install the latest prerelease version available, including GM seeds and GMs.")
let aria2 = Flag(longName: "aria2", type: String.self, description: "The path to an aria2 executable. Defaults to /usr/local/bin/aria2c.")
let noAria2 = Flag(longName: "no-aria2", value: false, description: "Don't use aria2 to download Xcode, even if its available.")

let install = Command(usage: "install <version>",
shortMessage: "Download and install a specific version of Xcode",
longMessage: """
Download and install a specific version of Xcode
By default, xcodes will use a URLSession to download the specified version. If aria2 (https://aria2.github.io, available in Homebrew) is installed, either at /usr/local/bin/aria2c or at the path specified by the --aria2 flag, then it will be used instead. aria2 will use up to 16 connections to download Xcode 3-5x faster. If you have aria2 installed and would prefer to not use it, you can use the --no-aria2 flag.
By default, xcodes will use a URLSession to download the specified version. If aria2 (https://aria2.github.io, available in Homebrew) is installed, either somewhere in PATH or at the path specified by the --aria2 flag, then it will be used instead. aria2 will use up to 16 connections to download Xcode 3-5x faster. If you have aria2 installed and would prefer to not use it, you can use the --no-aria2 flag.
""",
flags: [pathFlag, latestFlag, latestPrereleaseFlag, aria2, noAria2],
flags: [installPathFlag, installLatestFlag, installLatestPrereleaseFlag, aria2, noAria2],
example: """
xcodes install 10.2.1
xcodes install 11 Beta 7
Expand All @@ -130,7 +131,7 @@ let install = Command(usage: "install <version>",
aria2Path.exists,
flags.getBool(name: "no-aria2") != true {
downloader = .aria2(aria2Path)
}
}

installer.install(installation, downloader: downloader)
.catch { error in
Expand All @@ -151,6 +152,63 @@ let install = Command(usage: "install <version>",
}
app.add(subCommand: install)

let downloadDirectoryFlag = Flag(longName: "directory", type: String.self, description: "Directory to download .xip to. Defaults to ~/Downloads.")
let downloadLatestFlag = Flag(longName: "latest", value: false, description: "Update and then download the latest non-prerelease version available.")
let downloadLatestPrereleaseFlag = Flag(longName: "latest-prerelease", value: false, description: "Update and then download the latest prerelease version available, including GM seeds and GMs.")
let download = Command(usage: "download <version>",
shortMessage: "Download a specific version of Xcode",
longMessage: """
Download a specific version of Xcode
By default, xcodes will use a URLSession to download the specified version. If aria2 (https://aria2.github.io, available in Homebrew) is installed, either somewhere in PATH or at the path specified by the --aria2 flag, then it will be used instead. aria2 will use up to 16 connections to download Xcode 3-5x faster. If you have aria2 installed and would prefer to not use it, you can use the --no-aria2 flag.
""",
flags: [downloadDirectoryFlag, downloadLatestFlag, downloadLatestPrereleaseFlag, aria2, noAria2],
example: """
xcodes download 10.2.1
xcodes download 11 Beta 7
xcodes download 11.2 GM seed
xcodes download 9.0 --directory ~/Archive
xcodes download --latest-prerelease
""") { flags, args in
let versionString = args.joined(separator: " ")

let installation: XcodeInstaller.InstallationType
// Deliberately not using InstallationType.path here as it doesn't make sense to download an Xcode from a .xip that's already on disk
if flags.getBool(name: "latest") == true {
installation = .latest
} else if flags.getBool(name: "latest-prerelease") == true {
installation = .latestPrerelease
} else {
installation = .version(versionString)
}

var downloader = XcodeInstaller.Downloader.urlSession
if let aria2Path = flags.getString(name: "aria2").flatMap(Path.init) ?? Current.shell.findExecutable("aria2c"),
aria2Path.exists,
flags.getBool(name: "no-aria2") != true {
downloader = .aria2(aria2Path)
}

let directory = flags.getString(name: "directory").flatMap(Path.init)
installer.download(installation, downloader: downloader, destinationDirectory: directory ?? Path.home.join("Downloads"))
.catch { error in
switch error {
case Process.PMKError.execution(let process, let standardOutput, let standardError):
Current.logging.log("""
Failed executing: `\(process)` (\(process.terminationStatus))
\([standardOutput, standardError].compactMap { $0 }.joined(separator: "\n"))
""")
default:
Current.logging.log(error.legibleLocalizedDescription)
}

exit(1)
}

RunLoop.current.run()
}
app.add(subCommand: download)

let uninstall = Command(usage: "uninstall <version>",
shortMessage: "Uninstall a specific version of Xcode",
example: "xcodes uninstall 10.2.1") { _, args in
Expand Down
4 changes: 2 additions & 2 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,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, progressChanged: { _ in })
installer.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)
Expand All @@ -69,7 +69,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, progressChanged: { _ in })
installer.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)
Expand Down

0 comments on commit 1478a34

Please sign in to comment.