diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 2d6d53e..259d407 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -77,7 +77,7 @@ 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) @@ -85,12 +85,17 @@ public final class XcodeInstaller { 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)" @@ -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 @@ -151,7 +162,7 @@ public final class XcodeInstaller { private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> Promise { return firstly { () -> Promise<(Xcode, URL)> in - return self.getXcodeArchive(installationType, downloader: downloader) + return self.getXcodeArchive(installationType, downloader: downloader, willInstall: true) } .then { xcode, url -> Promise in return self.installArchivedXcode(xcode, at: url) @@ -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 { + 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: @@ -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...") @@ -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 { @@ -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) } } } @@ -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 in loginIfNeeded().map { version } } @@ -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))") } }) @@ -378,7 +404,7 @@ public final class XcodeInstaller { return nil } - public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> Promise { + public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, willInstall: Bool, progressChanged: @escaping (Progress) -> Void) -> Promise { // 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 @@ -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 { diff --git a/Sources/xcodes/main.swift b/Sources/xcodes/main.swift index e60b2a6..59ce141 100644 --- a/Sources/xcodes/main.swift +++ b/Sources/xcodes/main.swift @@ -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 ", 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 @@ -130,7 +131,7 @@ let install = Command(usage: "install ", aria2Path.exists, flags.getBool(name: "no-aria2") != true { downloader = .aria2(aria2Path) - } + } installer.install(installation, downloader: downloader) .catch { error in @@ -151,6 +152,63 @@ let install = Command(usage: "install ", } 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 ", + 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 ", shortMessage: "Uninstall a specific version of Xcode", example: "xcodes uninstall 10.2.1") { _, args in diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 0d2ba51..411c381 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -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) @@ -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)