From 762d18201f25e86cb3e63f65d06a993f9f944846 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Tue, 24 Nov 2020 21:06:16 -0700 Subject: [PATCH] Dump WIP --- README.md | 4 +- XcodesMac.xcodeproj/._project.xcworkspace | Bin 0 -> 4096 bytes XcodesMac.xcodeproj/project.pbxproj | 552 +++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 70 ++ XcodesMac/AppState.swift | 141 ++++ XcodesMac/AppStoreButtonStyle.swift | 64 ++ XcodesMac/AppleAPI/.gitignore | 5 + XcodesMac/AppleAPI/Package.swift | 30 + XcodesMac/AppleAPI/README.md | 3 + .../AppleAPI/Sources/AppleAPI/Client.swift | 327 ++++++++ .../Sources/AppleAPI/Environment.swift | 41 + .../Sources/AppleAPI/URLRequest+Apple.swift | 120 +++ .../Tests/AppleAPITests/AppleAPITests.swift | 15 + .../Tests/AppleAPITests/XCTestManifests.swift | 9 + XcodesMac/AppleAPI/Tests/LinuxMain.swift | 7 + .../AppIcon.appiconset/Contents.json | 58 ++ XcodesMac/Assets.xcassets/Contents.json | 6 + .../install.imageset/Contents.json | 15 + .../install.imageset/install.pdf | Bin 0 -> 4312 bytes XcodesMac/Base.lproj/Main.storyboard | 402 ++++++++++ XcodesMac/ContentView.swift | 98 +++ XcodesMac/Info.plist | 34 + .../Preview Assets.xcassets/Contents.json | 6 + XcodesMac/SignIn/SignIn2FAView.swift | 33 + XcodesMac/SignIn/SignInCredentialsView.swift | 42 + XcodesMac/SignIn/SignInPhoneListView.swift | 31 + XcodesMac/SignIn/SignInSMSView.swift | 31 + XcodesMac/Tag.swift | 18 + XcodesMac/XcodesApp.swift | 11 + XcodesMac/XcodesKit/.gitignore | 5 + XcodesMac/XcodesKit/Package.swift | 37 + XcodesMac/XcodesKit/README.md | 3 + .../Sources/XcodesKit/Aria2CError.swift | 125 +++ .../Sources/XcodesKit/Configuration.swift | 21 + .../Sources/XcodesKit/DateFormatter+.swift | 17 + .../XcodesKit/Sources/XcodesKit/Entry+.swift | 20 + .../Sources/XcodesKit/Environment.swift | 278 +++++++ .../Sources/XcodesKit/FileManager+.swift | 17 + .../Sources/XcodesKit/Foundation.swift | 28 + .../Sources/XcodesKit/Migration.swift | 17 + .../XcodesKit/Sources/XcodesKit/Models.swift | 89 +++ .../XcodesKit/Sources/XcodesKit/Path+.swift | 8 + .../XcodesKit/Sources/XcodesKit/Process.swift | 41 + .../Sources/XcodesKit/Promise+.swift | 40 + .../Sources/XcodesKit/URLRequest+Apple.swift | 28 + .../XcodesKit/URLSession+Promise.swift | 47 ++ .../Sources/XcodesKit/Version+.swift | 51 ++ .../Sources/XcodesKit/Version+Gem.swift | 45 ++ .../Sources/XcodesKit/Version+Xcode.swift | 65 ++ .../XcodesKit/Sources/XcodesKit/Version.swift | 3 + .../Sources/XcodesKit/XcodeInstaller.swift | 733 ++++++++++++++++++ .../Sources/XcodesKit/XcodeList.swift | 103 +++ .../Sources/XcodesKit/XcodeSelect.swift | 134 ++++ XcodesMac/XcodesKit/Tests/LinuxMain.swift | 7 + .../XcodesKitTests/XCTestManifests.swift | 9 + .../Tests/XcodesKitTests/XcodesKitTests.swift | 15 + XcodesMac/XcodesMac.entitlements | 8 + XcodesMacTests/Info.plist | 22 + XcodesMacTests/XcodesMacTests.swift | 26 + 61 files changed, 4229 insertions(+), 1 deletion(-) create mode 100644 XcodesMac.xcodeproj/._project.xcworkspace create mode 100644 XcodesMac.xcodeproj/project.pbxproj create mode 100644 XcodesMac.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 XcodesMac.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 XcodesMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 XcodesMac/AppState.swift create mode 100644 XcodesMac/AppStoreButtonStyle.swift create mode 100644 XcodesMac/AppleAPI/.gitignore create mode 100644 XcodesMac/AppleAPI/Package.swift create mode 100644 XcodesMac/AppleAPI/README.md create mode 100644 XcodesMac/AppleAPI/Sources/AppleAPI/Client.swift create mode 100644 XcodesMac/AppleAPI/Sources/AppleAPI/Environment.swift create mode 100644 XcodesMac/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift create mode 100644 XcodesMac/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift create mode 100644 XcodesMac/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift create mode 100644 XcodesMac/AppleAPI/Tests/LinuxMain.swift create mode 100644 XcodesMac/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 XcodesMac/Assets.xcassets/Contents.json create mode 100644 XcodesMac/Assets.xcassets/install.imageset/Contents.json create mode 100644 XcodesMac/Assets.xcassets/install.imageset/install.pdf create mode 100644 XcodesMac/Base.lproj/Main.storyboard create mode 100644 XcodesMac/ContentView.swift create mode 100644 XcodesMac/Info.plist create mode 100644 XcodesMac/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 XcodesMac/SignIn/SignIn2FAView.swift create mode 100644 XcodesMac/SignIn/SignInCredentialsView.swift create mode 100644 XcodesMac/SignIn/SignInPhoneListView.swift create mode 100644 XcodesMac/SignIn/SignInSMSView.swift create mode 100644 XcodesMac/Tag.swift create mode 100644 XcodesMac/XcodesApp.swift create mode 100644 XcodesMac/XcodesKit/.gitignore create mode 100644 XcodesMac/XcodesKit/Package.swift create mode 100644 XcodesMac/XcodesKit/README.md create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Aria2CError.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Configuration.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/DateFormatter+.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Entry+.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Environment.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/FileManager+.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Foundation.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Migration.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Models.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Path+.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Process.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Promise+.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/URLRequest+Apple.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/URLSession+Promise.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Version+.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Version+Gem.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Version+Xcode.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/Version.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/XcodeList.swift create mode 100644 XcodesMac/XcodesKit/Sources/XcodesKit/XcodeSelect.swift create mode 100644 XcodesMac/XcodesKit/Tests/LinuxMain.swift create mode 100644 XcodesMac/XcodesKit/Tests/XcodesKitTests/XCTestManifests.swift create mode 100644 XcodesMac/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift create mode 100644 XcodesMac/XcodesMac.entitlements create mode 100644 XcodesMacTests/Info.plist create mode 100644 XcodesMacTests/XcodesMacTests.swift diff --git a/README.md b/README.md index 77c0188..25957e2 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# Xcodes.app \ No newline at end of file +# Xcodes.app + +Like xcodes, but app-ier. diff --git a/XcodesMac.xcodeproj/._project.xcworkspace b/XcodesMac.xcodeproj/._project.xcworkspace new file mode 100644 index 0000000000000000000000000000000000000000..71f64f5cb0107361a500c3c2ba8f7870af786d70 GIT binary patch literal 4096 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFz{^v(m+1nBL)UWIUt(=a103v+SRUg z2%>{w0Z_RBnifVNA1W@DoS& + + + + diff --git a/XcodesMac.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/XcodesMac.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/XcodesMac.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/XcodesMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/XcodesMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..14fd872 --- /dev/null +++ b/XcodesMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,70 @@ +{ + "object": { + "pins": [ + { + "package": "PMKFoundation", + "repositoryURL": "https://github.com/PromiseKit/Foundation.git", + "state": { + "branch": null, + "revision": "1a276e598dac59489ed904887e0740fa75e571e0", + "version": "3.3.4" + } + }, + { + "package": "KeychainAccess", + "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state": { + "branch": null, + "revision": "8d33ffd6f74b3bcfc99af759d4204c6395a3f918", + "version": "3.2.1" + } + }, + { + "package": "LegibleError", + "repositoryURL": "https://github.com/mxcl/LegibleError.git", + "state": { + "branch": null, + "revision": "909e9bab3ded97350b28a5ab41dd745dd8aa9710", + "version": "1.0.4" + } + }, + { + "package": "Path.swift", + "repositoryURL": "https://github.com/mxcl/Path.swift.git", + "state": { + "branch": null, + "revision": "dac007e907a4f4c565cfdc55a9ce148a761a11d5", + "version": "0.16.3" + } + }, + { + "package": "PromiseKit", + "repositoryURL": "https://github.com/mxcl/PromiseKit.git", + "state": { + "branch": null, + "revision": "aea48ea1855f5d82e2dffa6027afce3aab8f3dd7", + "version": "6.13.3" + } + }, + { + "package": "SwiftSoup", + "repositoryURL": "https://github.com/scinfu/SwiftSoup.git", + "state": { + "branch": null, + "revision": "774dc9c7213085db8aa59595e27c1cd22e428904", + "version": "2.3.2" + } + }, + { + "package": "Version", + "repositoryURL": "https://github.com/mxcl/Version.git", + "state": { + "branch": null, + "revision": "a94b48f36763c05629fc102837398505032dead9", + "version": "2.0.0" + } + } + ] + }, + "version": 1 +} diff --git a/XcodesMac/AppState.swift b/XcodesMac/AppState.swift new file mode 100644 index 0000000..d34df78 --- /dev/null +++ b/XcodesMac/AppState.swift @@ -0,0 +1,141 @@ +import AppKit +import AppleAPI +import Combine +import Path +import PromiseKit +import XcodesKit + +class AppState: ObservableObject { + private let list = XcodeList() + private lazy var installer = XcodeInstaller(configuration: Configuration(), xcodeList: list) + + struct XcodeVersion: Identifiable { + let title: String + let installState: InstallState + let selected: Bool + let path: String? + var id: String { title } + var installed: Bool { installState == .installed } + } + enum InstallState: Equatable { + case notInstalled + case installing(Progress) + case installed + } + @Published var allVersions: [XcodeVersion] = [] + + struct AlertContent: Identifiable { + var title: String + var message: String + var id: String { title + message } + } + @Published var error: AlertContent? + + @Published var presentingSignInAlert = false + + func load() { +// if list.shouldUpdate { + update() + .done { _ in + self.updateAllVersions() + } + .catch { error in + self.error = AlertContent(title: "Error", + message: error.localizedDescription) + } +// } +// else { +// updateAllVersions() +// } + } + + func validateSession() -> Promise { + return firstly { () -> Promise in + return Current.network.validateSession() + } + .recover { _ in + self.presentingSignInAlert = true + } + } + + func continueLogin(username: String, password: String) -> Promise { + firstly { () -> Promise in + self.installer.login(username, password: password) + } + .recover { error -> Promise in + XcodesKit.Current.logging.log(error.legibleLocalizedDescription) + + if case Client.Error.invalidUsernameOrPassword = error { + self.presentingSignInAlert = true + } + return Promise(error: error) + } + } + + public func update() -> Promise<[Xcode]> { + return firstly { () -> Promise in + validateSession() + } + .then { () -> Promise<[Xcode]> in + self.list.update() + } + } + + private func updateAllVersions() { + let installedXcodes = Current.files.installedXcodes(Path.root/"Applications") + var allXcodeVersions = list.availableXcodes.map { $0.version } + for installedXcode in installedXcodes { + // If an installed version isn't listed online, add the installed version + if !allXcodeVersions.contains(where: { version in + version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) + }) { + allXcodeVersions.append(installedXcode.version) + } + // If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version with build metadata + else if let index = allXcodeVersions.firstIndex(where: { version in + version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) && + version.buildMetadataIdentifiers.isEmpty + }) { + allXcodeVersions[index] = installedXcode.version + } + } + + allVersions = allXcodeVersions + .sorted(by: >) + .map { xcodeVersion in + let installedXcode = installedXcodes.first(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) + return XcodeVersion( + title: xcodeVersion.xcodeDescription, + installState: installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) ? .installed : .notInstalled, + selected: installedXcode?.path.string.contains("11.4.1") == true, + path: installedXcode?.path.string + ) + } + } + + func install(id: String) { + // TODO: + } + + func uninstall(id: String) { + guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.xcodeDescription == id }) else { return } + // TODO: would be nice to have a version of this method that just took the InstalledXcode + installer.uninstallXcode(installedXcode.version.xcodeDescription, destination: Path.root/"Applications") + .done { + + } + .catch { error in + + } + } + + func reveal(id: String) { + // TODO: show error if not + guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.xcodeDescription == id }) else { return } + NSWorkspace.shared.activateFileViewerSelecting([installedXcode.path.url]) + } + + func select(id: String) { + // TODO: + } +} diff --git a/XcodesMac/AppStoreButtonStyle.swift b/XcodesMac/AppStoreButtonStyle.swift new file mode 100644 index 0000000..172063e --- /dev/null +++ b/XcodesMac/AppStoreButtonStyle.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct AppStoreButtonStyle: ButtonStyle { + var installed: Bool + var highlighted: Bool + + var textColor: Color { + if installed { + if highlighted { + return Color.white + } + else { + return Color.secondary + } + } + else { + if highlighted { + return Color.accentColor + } + else { + return Color.white + } + } + } + + func background(isPressed: Bool) -> some View { + Group { + if installed { + EmptyView() + } else { + Capsule() + .fill( + highlighted ? + Color.white : + Color.accentColor + ) + .brightness(isPressed ? -0.25 : 0) + } + } + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(Font.caption.weight(.medium)) + .foregroundColor(textColor) + .padding(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)) + .frame(minWidth: 80) + .background(background(isPressed: configuration.isPressed)) + .padding(1) + } +} + +struct AppStoreButtonStyle_Previews: PreviewProvider { + static var previews: some View { + Group { + Button("INSTALL", action: {}) + .buttonStyle(AppStoreButtonStyle(installed: true, highlighted: false)) + .padding() + Button("UNINSTALLED", action: {}) + .buttonStyle(AppStoreButtonStyle(installed: false, highlighted: false)) + .padding() + } + } +} diff --git a/XcodesMac/AppleAPI/.gitignore b/XcodesMac/AppleAPI/.gitignore new file mode 100644 index 0000000..95c4320 --- /dev/null +++ b/XcodesMac/AppleAPI/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/XcodesMac/AppleAPI/Package.swift b/XcodesMac/AppleAPI/Package.swift new file mode 100644 index 0000000..bfab7a9 --- /dev/null +++ b/XcodesMac/AppleAPI/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AppleAPI", + platforms: [.macOS(.v10_13)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "AppleAPI", + targets: ["AppleAPI"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + .package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMajor(from: "6.8.3")), + .package(name: "PMKFoundation", url: "https://github.com/PromiseKit/Foundation.git", .upToNextMajor(from: "3.3.1")), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "AppleAPI", + dependencies: ["PromiseKit", "PMKFoundation"]), + .testTarget( + name: "AppleAPITests", + dependencies: ["AppleAPI"]), + ] +) diff --git a/XcodesMac/AppleAPI/README.md b/XcodesMac/AppleAPI/README.md new file mode 100644 index 0000000..158b3f2 --- /dev/null +++ b/XcodesMac/AppleAPI/README.md @@ -0,0 +1,3 @@ +# AppleAPI + +A description of this package. diff --git a/XcodesMac/AppleAPI/Sources/AppleAPI/Client.swift b/XcodesMac/AppleAPI/Sources/AppleAPI/Client.swift new file mode 100644 index 0000000..8617915 --- /dev/null +++ b/XcodesMac/AppleAPI/Sources/AppleAPI/Client.swift @@ -0,0 +1,327 @@ +import Foundation +import PromiseKit +import PMKFoundation + +public class Client { + private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] + + public init() {} + + public enum Error: Swift.Error, LocalizedError, Equatable { + case invalidSession + case invalidUsernameOrPassword(username: String) + case invalidPhoneNumberIndex(min: Int, max: Int, given: String?) + case incorrectSecurityCode + case unexpectedSignInResponse(statusCode: Int, message: String?) + case appleIDAndPrivacyAcknowledgementRequired + case noTrustedPhoneNumbers + + public var errorDescription: String? { + switch self { + case .invalidUsernameOrPassword(let username): + return "Invalid username and password combination. Attempted to sign in with username \(username)." + case .appleIDAndPrivacyAcknowledgementRequired: + return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement." + case .invalidPhoneNumberIndex(let min, let max, let given): + return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")." + case .noTrustedPhoneNumbers: + return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915." + default: + return String(describing: self) + } + } + } + + /// Use the olympus session endpoint to see if the existing session is still valid + public func validateSession() -> Promise { + return Current.network.dataTask(with: URLRequest.olympusSession) + .done { data, response in + guard + let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + jsonObject["provider"] != nil + else { throw Error.invalidSession } + } + } + + public func login(accountName: String, password: String) -> Promise { + var serviceKey: String! + + return firstly { () -> Promise<(data: Data, response: URLResponse)> in + Current.network.dataTask(with: URLRequest.itcServiceKey) + } + .then { (data, _) -> Promise<(data: Data, response: URLResponse)> in + struct ServiceKeyResponse: Decodable { + let authServiceKey: String + } + + let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data) + serviceKey = response.authServiceKey + + return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password)) + } + .then { (data, response) -> Promise in + struct SignInResponse: Decodable { + let authType: String? + let serviceErrors: [ServiceError]? + + struct ServiceError: Decodable, CustomStringConvertible { + let code: String + let message: String + + var description: String { + return "\(code): \(message)" + } + } + } + + let httpResponse = response as! HTTPURLResponse + let responseBody = try JSONDecoder().decode(SignInResponse.self, from: data) + + switch httpResponse.statusCode { + case 200: + return Current.network.dataTask(with: URLRequest.olympusSession).asVoid() + case 401: + throw Error.invalidUsernameOrPassword(username: accountName) + case 409: + return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) + case 412 where Client.authTypes.contains(responseBody.authType ?? ""): + throw Error.appleIDAndPrivacyAcknowledgementRequired + default: + throw Error.unexpectedSignInResponse(statusCode: httpResponse.statusCode, + message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", ")) + } + } + } + + func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> Promise { + let httpResponse = response as! HTTPURLResponse + let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String) + let scnt = (httpResponse.allHeaderFields["scnt"] as! String) + + return firstly { () -> Promise in + return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) + .map { try JSONDecoder().decode(AuthOptionsResponse.self, from: $0.data) } + } + .then { authOptions -> Promise in + switch authOptions.kind { + case .twoStep: + Current.logging.log("Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or open an issue on GitHub explaining why this isn't an option for you here: https://github.com/RobotsAndPencils/xcodes/issues/new") + return Promise.value(()) + case .twoFactor: + return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) + case .unknown: + Current.logging.log("Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:") + String(data: data, encoding: .utf8).map { Current.logging.log($0) } + return Promise.value(()) + } + } + } + + func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise { + let option: TwoFactorOption + + // SMS was sent automatically + if authOptions.smsAutomaticallySent { + option = .smsSent(authOptions.securityCode.length, authOptions.trustedPhoneNumbers!.first!) + // SMS wasn't sent automatically because user needs to choose a phone to send to + } else if authOptions.canFallBackToSMS { + option = .smsPendingChoice(authOptions.securityCode.length, authOptions.trustedPhoneNumbers ?? []) + // Code is shown on trusted devices + } else { + option = .codeSent(authOptions.securityCode.length) + } + + return handleTwoFactorOption(option, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + } + + func handleTwoFactorOption(_ option: TwoFactorOption, serviceKey: String, sessionID: String, scnt: String) -> Promise { + Current.logging.log("Two-factor authentication is enabled for this account.\n") + switch option { + case let .smsSent(codeLength, phoneNumber): + return firstly { () throws -> Promise<(data: Data, response: URLResponse)> in + let code = self.promptForSMSSecurityCode(length: codeLength, for: phoneNumber) + return Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code)) + .validateSecurityCodeResponse() + } + .then { (data, response) -> Promise in + self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + } + case let .smsPendingChoice(codeLength, trustedPhoneNumbers): + return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: trustedPhoneNumbers, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + case let .codeSent(codeLength): + let code = Current.shell.readLine(""" + Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to. + Enter the \(codeLength) digit code from one of your trusted devices: + """) ?? "" + + if code == "sms" { + // return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: authOp, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + } + + return firstly { + Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: .device(code: code))) + .validateSecurityCodeResponse() + + } + .then { (data, response) -> Promise in + self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + } + } + } + + func updateSession(serviceKey: String, sessionID: String, scnt: String) -> Promise { + return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) + .then { (data, response) -> Promise in + Current.network.dataTask(with: URLRequest.olympusSession).asVoid() + } + } + + func selectPhoneNumberInteractively(from trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]) -> Promise { + return firstly { () throws -> Guarantee in + Current.logging.log("Trusted phone numbers:") + trustedPhoneNumbers.enumerated().forEach { (index, phoneNumber) in + Current.logging.log("\(index + 1): \(phoneNumber.numberWithDialCode)") + } + + let possibleSelectionNumberString = Current.shell.readLine("Select a trusted phone number to receive a code via SMS: ") + guard + let selectionNumberString = possibleSelectionNumberString, + let selectionNumber = Int(selectionNumberString) , + trustedPhoneNumbers.indices.contains(selectionNumber - 1) + else { + throw Error.invalidPhoneNumberIndex(min: 1, max: trustedPhoneNumbers.count, given: possibleSelectionNumberString) + } + + return .value(trustedPhoneNumbers[selectionNumber - 1]) + } + .recover { error throws -> Promise in + guard case Error.invalidPhoneNumberIndex = error else { throw error } + Current.logging.log("\(error.localizedDescription)\n") + return self.selectPhoneNumberInteractively(from: trustedPhoneNumbers) + } + } + + func promptForSMSSecurityCode(length: Int, for trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber) -> SecurityCode { + let code = Current.shell.readLine("Enter the \(length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ") ?? "" + return .sms(code: code, phoneNumberId: trustedPhoneNumber.id) + } + + func handleWithPhoneNumberSelection(codeLength: Int, trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, serviceKey: String, sessionID: String, scnt: String) -> Promise { + return firstly { () throws -> Promise in + // I don't think this should ever be nil or empty, because 2FA requires at least one trusted phone number, + // but if it is nil or empty it's better to inform the user so they can try to address it instead of crashing. + guard let trustedPhoneNumbers = trustedPhoneNumbers, trustedPhoneNumbers.isEmpty == false else { + throw Error.noTrustedPhoneNumbers + } + + return selectPhoneNumberInteractively(from: trustedPhoneNumbers) + } + .then { trustedPhoneNumber in + Current.network.dataTask(with: try URLRequest.requestSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, trustedPhoneID: trustedPhoneNumber.id)) + .map { _ in + self.promptForSMSSecurityCode(length: codeLength, for: trustedPhoneNumber) + } + } + .then { code in + Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code)) + .validateSecurityCodeResponse() + } + .then { (data, response) -> Promise in + self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + } + } +} + +enum TwoFactorOption { + case smsSent(Int, AuthOptionsResponse.TrustedPhoneNumber) + case codeSent(Int) + case smsPendingChoice(Int, [AuthOptionsResponse.TrustedPhoneNumber]) +} + +public extension Promise where T == (data: Data, response: URLResponse) { + func validateSecurityCodeResponse() -> Promise { + validate() + .recover { error -> Promise<(data: Data, response: URLResponse)> in + switch error { + case PMKHTTPError.badStatusCode(let code, _, _): + if code == 401 { + throw Client.Error.incorrectSecurityCode + } else { + throw error + } + default: + throw error + } + } + } +} + +struct AuthOptionsResponse: Decodable { + let trustedPhoneNumbers: [TrustedPhoneNumber]? + let trustedDevices: [TrustedDevice]? + let securityCode: SecurityCodeInfo + let noTrustedDevices: Bool? + let serviceErrors: [ServiceError]? + + var kind: Kind { + if trustedDevices != nil { + return .twoStep + } else if trustedPhoneNumbers != nil { + return .twoFactor + } else { + return .unknown + } + } + + // One time with a new testing account I had a response where noTrustedDevices was nil, but the account didn't have any trusted devices. + // This should have been a situation where an SMS security code was sent automatically. + // This resolved itself either after some time passed, or by signing into appleid.apple.com with the account. + // Not sure if it's worth explicitly handling this case or if it'll be really rare. + var canFallBackToSMS: Bool { + noTrustedDevices == true + } + + var smsAutomaticallySent: Bool { + trustedPhoneNumbers?.count == 1 && canFallBackToSMS + } + + struct TrustedPhoneNumber: Decodable { + let id: Int + let numberWithDialCode: String + } + + struct TrustedDevice: Decodable { + let id: String + let name: String + let modelName: String + } + + struct SecurityCodeInfo: Decodable { + let length: Int + let tooManyCodesSent: Bool + let tooManyCodesValidated: Bool + let securityCodeLocked: Bool + let securityCodeCooldown: Bool + } + + enum Kind { + case twoStep, twoFactor, unknown + } +} + +public struct ServiceError: Decodable, Equatable { + let code: String + let message: String +} + +enum SecurityCode { + case device(code: String) + case sms(code: String, phoneNumberId: Int) + + var urlPathComponent: String { + switch self { + case .device: return "trusteddevice" + case .sms: return "phone" + } + } +} diff --git a/XcodesMac/AppleAPI/Sources/AppleAPI/Environment.swift b/XcodesMac/AppleAPI/Sources/AppleAPI/Environment.swift new file mode 100644 index 0000000..3977321 --- /dev/null +++ b/XcodesMac/AppleAPI/Sources/AppleAPI/Environment.swift @@ -0,0 +1,41 @@ +import Foundation +import PromiseKit +import PMKFoundation + +/** + Lightweight dependency injection using global mutable state :P + + - SeeAlso: https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy + - SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable + - SeeAlso: https://vimeo.com/291588126 + */ +public struct Environment { + public var shell = Shell() + public var network = Network() + public var logging = Logging() +} + +public var Current = Environment() + +public struct Shell { + public var readLine: (String) -> String? = { prompt in + print(prompt, terminator: "") + return Swift.readLine() + } + public func readLine(prompt: String) -> String? { + readLine(prompt) + } +} + +public struct Network { + public var session = URLSession.shared + + public var dataTask: (URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> = { Current.network.session.dataTask(.promise, with: $0) } + public func dataTask(with convertible: URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> { + dataTask(convertible) + } +} + +public struct Logging { + public var log: (String) -> Void = { print($0) } +} diff --git a/XcodesMac/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift b/XcodesMac/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift new file mode 100644 index 0000000..c6f33aa --- /dev/null +++ b/XcodesMac/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift @@ -0,0 +1,120 @@ +import Foundation + +extension URL { + static let itcServiceKey = URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")! + static let signIn = URL(string: "https://idmsa.apple.com/appleauth/auth/signin")! + static let authOptions = URL(string: "https://idmsa.apple.com/appleauth/auth")! + static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")! + static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! } + static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")! + static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")! +} + +extension URLRequest { + static var itcServiceKey: URLRequest { + return URLRequest(url: .itcServiceKey) + } + + static func signIn(serviceKey: String, accountName: String, password: String) -> URLRequest { + struct Body: Encodable { + let accountName: String + let password: String + let rememberMe = true + } + + var request = URLRequest(url: .signIn) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + request.allHTTPHeaderFields?["Content-Type"] = "application/json" + request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" + request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey + request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript" + request.httpMethod = "POST" + request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password)) + return request + } + + static func authOptions(serviceKey: String, sessionID: String, scnt: String) -> URLRequest { + var request = URLRequest(url: .authOptions) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID + request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey + request.allHTTPHeaderFields?["scnt"] = scnt + request.allHTTPHeaderFields?["accept"] = "application/json" + return request + } + + static func requestSecurityCode(serviceKey: String, sessionID: String, scnt: String, trustedPhoneID: Int) throws -> URLRequest { + struct Body: Encodable { + let phoneNumber: PhoneNumber + let mode = "sms" + + struct PhoneNumber: Encodable { + let id: Int + } + } + + var request = URLRequest(url: .requestSecurityCode) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + request.allHTTPHeaderFields?["Content-Type"] = "application/json" + request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID + request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey + request.allHTTPHeaderFields?["scnt"] = scnt + request.allHTTPHeaderFields?["accept"] = "application/json" + request.httpMethod = "PUT" + request.httpBody = try JSONEncoder().encode(Body(phoneNumber: .init(id: trustedPhoneID))) + return request + } + + static func submitSecurityCode(serviceKey: String, sessionID: String, scnt: String, code: SecurityCode) throws -> URLRequest { + struct DeviceSecurityCodeRequest: Encodable { + let securityCode: SecurityCode + + struct SecurityCode: Encodable { + let code: String + } + } + + struct SMSSecurityCodeRequest: Encodable { + let securityCode: SecurityCode + let phoneNumber: PhoneNumber + let mode = "sms" + + struct SecurityCode: Encodable { + let code: String + } + struct PhoneNumber: Encodable { + let id: Int + } + } + + var request = URLRequest(url: .submitSecurityCode(code)) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID + request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey + request.allHTTPHeaderFields?["scnt"] = scnt + request.allHTTPHeaderFields?["Accept"] = "application/json" + request.allHTTPHeaderFields?["Content-Type"] = "application/json" + request.httpMethod = "POST" + switch code { + case .device(let code): + request.httpBody = try JSONEncoder().encode(DeviceSecurityCodeRequest(securityCode: .init(code: code))) + case .sms(let code, let phoneNumberId): + request.httpBody = try JSONEncoder().encode(SMSSecurityCodeRequest(securityCode: .init(code: code), phoneNumber: .init(id: phoneNumberId))) + } + return request + } + + static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest { + var request = URLRequest(url: .trust) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID + request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey + request.allHTTPHeaderFields?["scnt"] = scnt + request.allHTTPHeaderFields?["Accept"] = "application/json" + return request + } + + static var olympusSession: URLRequest { + return URLRequest(url: .olympusSession) + } +} diff --git a/XcodesMac/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift b/XcodesMac/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift new file mode 100644 index 0000000..283dbc6 --- /dev/null +++ b/XcodesMac/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import AppleAPI + +final class AppleAPITests: XCTestCase { + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + XCTAssertEqual(AppleAPI().text, "Hello, World!") + } + + static var allTests = [ + ("testExample", testExample), + ] +} diff --git a/XcodesMac/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift b/XcodesMac/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift new file mode 100644 index 0000000..97d152c --- /dev/null +++ b/XcodesMac/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(AppleAPITests.allTests), + ] +} +#endif diff --git a/XcodesMac/AppleAPI/Tests/LinuxMain.swift b/XcodesMac/AppleAPI/Tests/LinuxMain.swift new file mode 100644 index 0000000..9c8128c --- /dev/null +++ b/XcodesMac/AppleAPI/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import AppleAPITests + +var tests = [XCTestCaseEntry]() +tests += AppleAPITests.allTests() +XCTMain(tests) diff --git a/XcodesMac/Assets.xcassets/AppIcon.appiconset/Contents.json b/XcodesMac/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/XcodesMac/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/XcodesMac/Assets.xcassets/Contents.json b/XcodesMac/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/XcodesMac/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/XcodesMac/Assets.xcassets/install.imageset/Contents.json b/XcodesMac/Assets.xcassets/install.imageset/Contents.json new file mode 100644 index 0000000..7c8fff9 --- /dev/null +++ b/XcodesMac/Assets.xcassets/install.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "install.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/XcodesMac/Assets.xcassets/install.imageset/install.pdf b/XcodesMac/Assets.xcassets/install.imageset/install.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5f92eb78106337a6780141a07cab08df7659310c GIT binary patch literal 4312 zcmai&2UHVlw}vTEARvNL1W`r~38Fv(ArzI~jG&>25UQzkf+18z1S!%)niK^Dlqy9* zDKQ{Lig1WXR{@bG(iN#9H>l?x?>YZn|EyUv-`d|h?|d_R@3o#8h_SA|th^io3~8G9 zJTa5^<=(5NX0Rdv2go>Q@TpS(%!ugjKy?I=%#bMn(|2;E5-H5HE0#*sCF02hBA~7g z_M}pXSU0dQ>)G>KH{Zc|Xj6h4n&yU&3G2^b17_lU_XgMo*`l^Ij88mLdtnn6eHJB< z0Q1j-6*dn>CP+I_v(SY}fs=%VNpM<^gNY6Wma!@ zLf;8JV)y)b!;$>Qk0?E#sB)8wVCY@#>Rpo84Ub-ArE;FT62E%H?atD;07QJ~jFe{A zL!G_ppkw!vFWj>^C_EP?w^S<1i5Ew@XFCnsN=1^jBOg~cEs2{|3n7C~(gh*Xtd|!Z zL~0@u3O!sN)OA`;9q#3oHY4s+SrX8uMTrV^lE6jy4;FPo@47u>LRKp9LXB&luJA<& z_m#9}$1#~DiYYs5YfZo@)FlgRv33{aQ{!1&32BV}?9Yy`Yz`(eW0-ALy-HE`5!Py1 z>DxDkc=)5u<%0Jtq#U``%TE*On4WpGh!U5)iO3E46DQK@zMrqPUsCQ&zWl7+VTRW#Bl1C;tC}JS8IOk}hLmM6olEok$`TL=-Y$WXZtFCvvZ;j$o z0ex-bK_+Afq(8w~A8af=yNfUXAHrp@-FSC&JlouYQ zXk`nE!m=d?}G!6Le`ipbSyE;0iv z9X{dKepbMnUu`n-u%f#3Dj3~_JylDbN7_gEQObHq&ZH;!2L1~BWnqip@2?n@IV-`HcP4j+3cuCvc|Q*#05irq{5VpG?o+-6RKS{Ps;dv!0x%u2E16>EfyEPnoo3PTln0c58T{s< z@|(x6ep3CRf}LY72&T2uQ|`=$0${pCZznv_)Ij^cw_5sbpYvveLg5U$GX2#)2>`Mz z>dytdK6zRYWDo`+6|0pX)Q9adW+iuHM8@ zv{;#{^{oOm3g^q1ln7fI%BA3-*#Y}gtCt0ZWgd!uhOZjP~i!- zM6>c2A`07}m&-}g3SsgJLRnnd4<*K$N?lj-CXdtFQj($LS)!Ouk!Ok-(KO@XG{|U9 zG$_j^1@hh@=L!drejb&^GO>EJyL;`f5er-()xKj2n&ldypeGK4|Yj+4S%+P z8EZayaBJ-|$KtqB&sYWj?^7G%+%}Q!qefa1W|J#Cu>=YvTSg>jB*e9zZQm^p>OHVx zkQ|&%=Hec)YX}eaVSpv%+G#%a+w5|u*_<>%kG0eXSioBH1FYWHojq83g8W~B&IK!Z z?6v_}Jm2HN&2!B_6C74=2xlD*a^ehL zigPQ3zd_L?C?8Fg2;=i>vcX9|x;dcr3?&9Vmmq)&lN2_UluoS(I*)Reu!X8Y=|>XU zP7n+z2QZp;=n~0Hz#g(UUc)%?C{zG_Modq8@SN(IYE3^)Kb;4rd-C0TEDKX=(yh>R z-GDOzFoOg{@|WbXw&H}MIG3cEL#wGv9N+Rk4}WYu1|u<&<5> z(jTp8Pzy!rrDZ4KZs5{z5xDCgkS|Oi&*FpPEBB!bG^fj6LmnIv)$V@a_qwX$Y{iK; z#5eFm2Fmw0yA!5gEQ^1nFLMzgMC*y86Jish6Xh>WcQ?QO&N*eW}Spmbyu1apwIxgF5TLm(O1TZ!Sh@-Ko#dip;9RW?*wh@qZM?6?GlU zvC28wtb%`fDRN1D%o|lOm;>_93zd{W$9M&scfV1O;ehSEk2+n%y|r7 z-kCcz`J}i&cEr1GF5qOEZP}0z?HrAN_Xz*%;Dw{UKH+;JwWNYG+HG_iIXG9Mcey&Wc8T8O&YVB z+2}aiA2a3M=xUW_r}nd{>7<{{!KJU1@w^XBltc@A9R63om+ux-!3muV?Ap< zEm$1(+FsJ8M~F@Pn~rzrDJ~?Bt>A{|0XZ4PhtOHSDs$hfzCIvXoSv>u6YX?t>+4p7 zs2CwbAs_uVgBNm}+1Fz(9Xotb{Ll#BcEtXMf(fINW&3KM2~d9mEqpV6s`>a- z%#!UI@mm}yIplFjmu_;CZ3G@wf)YfHv7xn|y!U!+|2b&9An{gWjx?g6tKd(kkczd6 zZfyuQvh27mLQRSeYgEjE>(sn->YX>=pRL&yXLQsPL!zbJ{BHF1bhP9h$aM^Nx%1;; z2Loc}OOp=%$>s61argE^ZC~0puOdlEA1{Ym@9$yX>XGvl90ftx(8+Og~fVY|xwc>Heo6(b%*}`lrj4bLnqd-uBmx zxCM5z#<0KOP(Lgn_AIb<)!3z2%k2gB-RQ$c#!U97nmUI-`#{~f(@Sv-gHVG9Ee)|= zy(fG6mGhMol^SB=n;(r`Y9VjE|EhC&f6=rtBBy!{q;f=c=|jt!_(BHHsX@QUfWm31 zt<06DudE`ivijcjMLfN)Qhh#PGY+qT9UP(E=B?4xPFEHCxc=GM)Z30kpH9Q7mMxD$=ze2b<*FTt|oOAWnT5!q&wi+nigHU zW>PiI4CmLXZuG6VO%Xnhcl$UkV7u9O41Z`z);+I?Erf1P>=%maT4WBDq-#+_7 z-AI!ot07-D|IN=k6#4_2mE;xv0^S{d-H9_GHiO#C-v zdjhbtM1m7mo9qi%!! zmF)17-#veU``=tH|JwyjE+=^5nct;H-LQDH8E}C}@pK})Gw&`ZkCamcY>sMsIk^%5 zc~w0HB_$haz{Cqnq51<%i2uR;zEo)@yE8vwsm!Z%nIH~6s(V5Kt^kKC$}1}ADe=FZfeB@=ce!2d7#dy&2rA_)u!2qgqF4gb)=|E+^7!B4?%!)cAXWb5hfr4dZOv2)*2$Gf`SH8J z%*mg~d|m)%N+vT?zLQqwZv&J&iOkIZ&#^AE0#4!ZL=v8$Kq4q8;fZj9DnbQGQbNcR kaBu|zRvE9T4*u_wpB3auW#;n7i$ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XcodesMac/ContentView.swift b/XcodesMac/ContentView.swift new file mode 100644 index 0000000..0f96606 --- /dev/null +++ b/XcodesMac/ContentView.swift @@ -0,0 +1,98 @@ +import SwiftUI +import XcodesKit +import Version +import PromiseKit + +struct ContentView: View { + @ObservedObject var appState = AppState() + @State private var selection = Set() + @State private var rowBeingConfirmedForUninstallation: AppState.XcodeVersion? + + var body: some View { + List(appState.allVersions, selection: $selection) { row in + VStack(alignment: .leading) { + HStack { + Text(row.title) + .font(.body) + if row.selected { + Tag(text: "SELECTED") + .foregroundColor(.green) + } + Spacer() + Button(row.installed ? "INSTALLED" : "INSTALL") { + print("Installing...") + } + .buttonStyle(AppStoreButtonStyle(installed: row.installed, + highlighted: self.selection.contains(row.id))) + .disabled(row.installed) + } + Text(verbatim: row.path ?? "") + .font(.caption) + .foregroundColor(self.selection.contains(row.id) ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) + // if row.installed { + // HStack { + // Button(action: { row.installed ? self.rowBeingConfirmedForUninstallation = row : self.appState.install(id: row.id) }) { + // Text("Uninstall") + // } + // Button(action: { self.appState.reveal(id: row.id) }) { + // Text("Reveal in Finder") + // } + // Button(action: { self.appState.select(id: row.id) }) { + // Text("Select") + // } + // } + // .buttonStyle(PlainButtonStyle()) + // .foregroundColor( + // self.selection.contains(row.id) ? + // Color(NSColor.selectedMenuItemTextColor) : + // .accentColor + // ) + // } + } + .contextMenu { + Button(action: { row.installed ? self.rowBeingConfirmedForUninstallation = row : self.appState.install(id: row.id) }) { + Text(row.installed ? "Uninstall" : "Install") + } + if row.installed { + Button(action: { self.appState.reveal(id: row.id) }) { + Text("Reveal in Finder") + } + Button(action: { self.appState.select(id: row.id) }) { + Text("Select") + } + } + } + } + .frame(minWidth: 200, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity) + .onAppear(perform: appState.load) + .toolbar { + ToolbarItem { + Button(action: { appState.update().cauterize() }) { + Image(systemName: "arrow.clockwise") + } + .keyboardShortcut("r") + } + } + .alert(item: $appState.error) { error in + Alert(title: Text(error.title), + message: Text(verbatim: error.message), + dismissButton: .default(Text("OK"))) + } + .alert(item: self.$rowBeingConfirmedForUninstallation) { row in + Alert(title: Text("Uninstall Xcode \(row.title)?"), + message: Text("It will be moved to the Trash, but won't be emptied."), + primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: row.id) }), + secondaryButton: .cancel(Text("Cancel"))) + } + .sheet(isPresented: $appState.presentingSignInAlert, content: { + SignInCredentialsView(isPresented: $appState.presentingSignInAlert) + .environmentObject(appState) + }) + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/XcodesMac/Info.plist b/XcodesMac/Info.plist new file mode 100644 index 0000000..a09a55c --- /dev/null +++ b/XcodesMac/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright © 2020 Robots and Pencils. All rights reserved. + NSPrincipalClass + NSApplication + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + + diff --git a/XcodesMac/Preview Content/Preview Assets.xcassets/Contents.json b/XcodesMac/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/XcodesMac/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/XcodesMac/SignIn/SignIn2FAView.swift b/XcodesMac/SignIn/SignIn2FAView.swift new file mode 100644 index 0000000..deb46b6 --- /dev/null +++ b/XcodesMac/SignIn/SignIn2FAView.swift @@ -0,0 +1,33 @@ +import SwiftUI +import AppleAPI + +struct SignIn2FAView: View { + @EnvironmentObject var appState: AppState + @Binding var isPresented: Bool + @State private var code: String = "" + + var body: some View { + VStack(alignment: .leading) { + Text("Enter the \(6) digit code from one of your trusted devices:") + + HStack { + TextField("\(6) digit code", text: $code) + } + + HStack { + Button("Cancel", action: { isPresented = false }) + Button("Send SMS", action: {}) + Spacer() + Button("Continue", action: {}) + } + } + .padding() + } +} + +struct SignIn2FAView_Previews: PreviewProvider { + static var previews: some View { + SignIn2FAView(isPresented: .constant(true)) + .environmentObject(AppState()) + } +} diff --git a/XcodesMac/SignIn/SignInCredentialsView.swift b/XcodesMac/SignIn/SignInCredentialsView.swift new file mode 100644 index 0000000..2d2f004 --- /dev/null +++ b/XcodesMac/SignIn/SignInCredentialsView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct SignInCredentialsView: View { + @EnvironmentObject var appState: AppState + @Binding var isPresented: Bool + @State private var username: String = "" + @State private var password: String = "" + + var body: some View { + VStack { + HStack { + Text("Apple ID") + TextField("Apple ID", text: $username) + } + + HStack { + Text("Password") + SecureField("Password", text: $password) + } + + HStack { + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Sign In") { + appState.continueLogin(username: username, password: password) + } + .keyboardShortcut(.defaultAction) + } + } + .padding() + } +} + +struct SignInCredentialsView_Previews: PreviewProvider { + static var previews: some View { + SignInCredentialsView(isPresented: .constant(true)) + .environmentObject(AppState()) + } +} diff --git a/XcodesMac/SignIn/SignInPhoneListView.swift b/XcodesMac/SignIn/SignInPhoneListView.swift new file mode 100644 index 0000000..5348a06 --- /dev/null +++ b/XcodesMac/SignIn/SignInPhoneListView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct SignInPhoneListView: View { + @EnvironmentObject var appState: AppState + @Binding var isPresented: Bool + var phoneNumbers: [String] + + var body: some View { + VStack(alignment: .leading) { + Text("Select a trusted phone number to receive a code via SMS: ") + + List(phoneNumbers, id: \.self) { + Text($0) + } + .frame(height: 200) + + HStack { + Button("Cancel", action: { isPresented = false }) + Spacer() + Button("Continue", action: {}) + } + } + .padding() + } +} + +struct SignInPhoneListView_Previews: PreviewProvider { + static var previews: some View { + SignInPhoneListView(isPresented: .constant(true), phoneNumbers: ["123-456-7890"]) + } +} diff --git a/XcodesMac/SignIn/SignInSMSView.swift b/XcodesMac/SignIn/SignInSMSView.swift new file mode 100644 index 0000000..07f5a97 --- /dev/null +++ b/XcodesMac/SignIn/SignInSMSView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct SignInSMSView: View { + @EnvironmentObject var appState: AppState + @Binding var isPresented: Bool + @State private var code: String = "" + + var body: some View { + VStack(alignment: .leading) { + Text("Enter the \(6) digit code sent to \("phone number"): ") + + HStack { + TextField("\(6) digit code", text: $code) + } + + HStack { + Button("Cancel", action: { isPresented = false }) + Spacer() + Button("Continue", action: {}) + } + } + .padding() + } +} + +struct SignInSMSView_Previews: PreviewProvider { + static var previews: some View { + SignInSMSView(isPresented: .constant(true)) + .environmentObject(AppState()) + } +} diff --git a/XcodesMac/Tag.swift b/XcodesMac/Tag.swift new file mode 100644 index 0000000..38b9262 --- /dev/null +++ b/XcodesMac/Tag.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct Tag: View { + var text: String + var body: some View { + Text(text) + .foregroundColor(.white) + .background(RoundedRectangle(cornerRadius: 3).padding([.leading, .trailing], -3)) + } +} + +struct Tag_Previews: PreviewProvider { + static var previews: some View { + Tag(text: "SELECTED") + .foregroundColor(.green) + .padding() + } +} diff --git a/XcodesMac/XcodesApp.swift b/XcodesMac/XcodesApp.swift new file mode 100644 index 0000000..ca2d78c --- /dev/null +++ b/XcodesMac/XcodesApp.swift @@ -0,0 +1,11 @@ +import Cocoa +import SwiftUI + +@main +struct XcodesApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/XcodesMac/XcodesKit/.gitignore b/XcodesMac/XcodesKit/.gitignore new file mode 100644 index 0000000..95c4320 --- /dev/null +++ b/XcodesMac/XcodesKit/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/XcodesMac/XcodesKit/Package.swift b/XcodesMac/XcodesKit/Package.swift new file mode 100644 index 0000000..6bdda8e --- /dev/null +++ b/XcodesMac/XcodesKit/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "XcodesKit", + platforms: [.macOS(.v10_13)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "XcodesKit", + targets: ["XcodesKit"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + .package(path: "../AppleAPI"), + .package(url: "https://github.com/mxcl/Path.swift.git", .upToNextMajor(from: "0.16.0")), + .package(url: "https://github.com/mxcl/Version.git", .upToNextMajor(from: "2.0.0")), + .package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMajor(from: "6.8.3")), + .package(name: "PMKFoundation", url: "https://github.com/PromiseKit/Foundation.git", .upToNextMajor(from: "3.3.1")), + .package(url: "https://github.com/scinfu/SwiftSoup.git", .upToNextMajor(from: "2.3.2")), + .package(url: "https://github.com/mxcl/LegibleError.git", .upToNextMajor(from: "1.0.1")), + .package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", .upToNextMajor(from: "3.2.0")), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "XcodesKit", + dependencies: ["AppleAPI", .product(name: "Path", package: "Path.swift"), "Version", "PromiseKit", "PMKFoundation", "SwiftSoup", "LegibleError", "KeychainAccess"]), + .testTarget( + name: "XcodesKitTests", + dependencies: ["XcodesKit"]), + ] +) diff --git a/XcodesMac/XcodesKit/README.md b/XcodesMac/XcodesKit/README.md new file mode 100644 index 0000000..5312c49 --- /dev/null +++ b/XcodesMac/XcodesKit/README.md @@ -0,0 +1,3 @@ +# XcodesKit + +A description of this package. diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Aria2CError.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Aria2CError.swift new file mode 100644 index 0000000..c652626 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Aria2CError.swift @@ -0,0 +1,125 @@ +import Foundation + +/// A LocalizedError that represents a non-zero exit code from running aria2c. +struct Aria2CError: LocalizedError { + var code: Code + + init?(exitStatus: Int32) { + guard let code = Code(rawValue: exitStatus) else { return nil } + self.code = code + } + + var errorDescription: String? { + "aria2c error: \(code.description)" + } + + // https://github.com/aria2/aria2/blob/master/src/error_code.h + enum Code: Int32, CustomStringConvertible { + case undefined = -1 + // Ignoring, not an error + // case finished = 0 + case unknownError = 1 + case timeOut + case resourceNotFound + case maxFileNotFound + case tooSlowDownloadSpeed + case networkProblem + case inProgress + case cannotResume + case notEnoughDiskSpace + case pieceLengthChanged + case duplicateDownload + case duplicateInfoHash + case fileAlreadyExists + case fileRenamingFailed + case fileOpenError + case fileCreateError + case fileIoError + case dirCreateError + case nameResolveError + case metalinkParseError + case ftpProtocolError + case httpProtocolError + case httpTooManyRedirects + case httpAuthFailed + case bencodeParseError + case bittorrentParseError + case magnetParseError + case optionError + case httpServiceUnavailable + case jsonParseError + case removed + case checksumError + + var description: String { + switch self { + case .undefined: + return "Undefined" + case .unknownError: + return "Unknown error" + case .timeOut: + return "Timed out" + case .resourceNotFound: + return "Resource not found" + case .maxFileNotFound: + return "Maximum number of file not found errors reached" + case .tooSlowDownloadSpeed: + return "Download speed too slow" + case .networkProblem: + return "Network problem" + case .inProgress: + return "Unfinished downloads in progress" + case .cannotResume: + return "Remote server did not support resume when resume was required to complete download" + case .notEnoughDiskSpace: + return "Not enough disk space available" + case .pieceLengthChanged: + return "Piece length was different from one in .aria2 control file" + case .duplicateDownload: + return "Duplicate download" + case .duplicateInfoHash: + return "Duplicate info hash torrent" + case .fileAlreadyExists: + return "File already exists" + case .fileRenamingFailed: + return "Renaming file failed" + case .fileOpenError: + return "Could not open existing file" + case .fileCreateError: + return "Could not create new file or truncate existing file" + case .fileIoError: + return "File I/O error" + case .dirCreateError: + return "Could not create directory" + case .nameResolveError: + return "Name resolution failed" + case .metalinkParseError: + return "Could not parse Metalink document" + case .ftpProtocolError: + return "FTP command failed" + case .httpProtocolError: + return "HTTP response header was bad or unexpected" + case .httpTooManyRedirects: + return "Too many redirects occurred" + case .httpAuthFailed: + return "HTTP authorization failed" + case .bencodeParseError: + return "Could not parse bencoded file (usually \".torrent\" file)" + case .bittorrentParseError: + return "\".torrent\" file was corrupted or missing information" + case .magnetParseError: + return "Magnet URI was bad" + case .optionError: + return "Bad/unrecognized option was given or unexpected option argument was given" + case .httpServiceUnavailable: + return "HTTP service unavailable" + case .jsonParseError: + return "Could not parse JSON-RPC request" + case .removed: + return "Reserved. Not used." + case .checksumError: + return "Checksum validation failed" + } + } + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Configuration.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Configuration.swift new file mode 100644 index 0000000..9446730 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Configuration.swift @@ -0,0 +1,21 @@ +import Foundation +import Path + +public struct Configuration: Codable { + public var defaultUsername: String? + + public init() { + self.defaultUsername = nil + } + + public mutating func load() throws { + guard let data = Current.files.contents(atPath: Path.configurationFile.string) else { return } + self = try JSONDecoder().decode(Configuration.self, from: data) + } + + public func save() throws { + let data = try JSONEncoder().encode(self) + try Current.files.createDirectory(at: Path.configurationFile.url.deletingLastPathComponent(), withIntermediateDirectories: true) + Current.files.createFile(atPath: Path.configurationFile.string, contents: data) + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/DateFormatter+.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/DateFormatter+.swift new file mode 100644 index 0000000..a9eb59e --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/DateFormatter+.swift @@ -0,0 +1,17 @@ +import Foundation + +extension DateFormatter { + /// Date format used in JSON returned from `URL.downloads` + static let downloadsDateModified: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MM/dd/yy HH:mm" + return formatter + }() + + /// Date format used in HTML returned from `URL.download` + static let downloadsReleaseDate: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM d, yyyy" + return formatter + }() +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Entry+.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Entry+.swift new file mode 100644 index 0000000..bdf85f7 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Entry+.swift @@ -0,0 +1,20 @@ +import Foundation +import Path + +extension Entry { + var isAppBundle: Bool { + kind == .directory && + path.extension == "app" && + !path.isSymlink + } + + var infoPlist: InfoPlist? { + let infoPlistPath = path.join("Contents").join("Info.plist") + guard + let infoPlistData = try? Data(contentsOf: infoPlistPath.url), + let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData) + else { return nil } + + return infoPlist + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Environment.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Environment.swift new file mode 100644 index 0000000..bdb10af --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Environment.swift @@ -0,0 +1,278 @@ +import Foundation +import PromiseKit +import PMKFoundation +import Path +import AppleAPI +import KeychainAccess + +/** + Lightweight dependency injection using global mutable state :P + + - SeeAlso: https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy + - SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable + - SeeAlso: https://vimeo.com/291588126 + */ +public struct Environment { + public var shell = Shell() + public var files = Files() + public var network = Network() + public var logging = Logging() + public var keychain = Keychain() +} + +public var Current = Environment() + +public struct Shell { + public var unxip: (URL) -> Promise = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") } + public var spctlAssess: (URL) -> Promise = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") } + public var codesignVerify: (URL) -> Promise = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") } + public var devToolsSecurityEnable: (String?) -> Promise = { Process.sudo(password: $0, Path.root.usr.sbin.DevToolsSecurity, "-enable") } + public var addStaffToDevelopersGroup: (String?) -> Promise = { Process.sudo(password: $0, Path.root.usr.sbin.dseditgroup, "-o", "edit", "-t", "group", "-a", "staff", "_developer") } + public var acceptXcodeLicense: (InstalledXcode, String?) -> Promise = { Process.sudo(password: $1, $0.path.join("/Contents/Developer/usr/bin/xcodebuild"), "-license", "accept") } + public var runFirstLaunch: (InstalledXcode, String?) -> Promise = { Process.sudo(password: $1, $0.path.join("/Contents/Developer/usr/bin/xcodebuild"),"-runFirstLaunch") } + public var buildVersion: () -> Promise = { Process.run(Path.root.usr.bin.sw_vers, "-buildVersion") } + public var xcodeBuildVersion: (InstalledXcode) -> Promise = { Process.run(Path.root.usr.libexec.PlistBuddy, "-c", "Print :ProductBuildVersion", "\($0.path.string)/Contents/version.plist") } + public var getUserCacheDir: () -> Promise = { Process.run(Path.root.usr.bin.getconf, "DARWIN_USER_CACHE_DIR") } + public var touchInstallCheck: (String, String, String) -> Promise = { Process.run(Path.root.usr.bin/"touch", "\($0)com.apple.dt.Xcode.InstallCheckCache_\($1)_\($2)") } + + public var validateSudoAuthentication: () -> Promise = { Process.run(Path.root.usr.bin.sudo, "-nv") } + public var authenticateSudoerIfNecessary: (@escaping () -> Promise) -> Promise = { passwordInput in + firstly { () -> Promise in + Current.shell.validateSudoAuthentication().map { _ in return nil } + } + .recover { _ -> Promise in + return passwordInput().map(Optional.init) + } + } + public func authenticateSudoerIfNecessary(passwordInput: @escaping () -> Promise) -> Promise { + authenticateSudoerIfNecessary(passwordInput) + } + + public var xcodeSelectPrintPath: () -> Promise = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } + public var xcodeSelectSwitch: (String?, String) -> Promise = { Process.sudo(password: $0, Path.root.usr.bin.join("xcode-select"), "-s", $1) } + public func xcodeSelectSwitch(password: String?, path: String) -> Promise { + xcodeSelectSwitch(password, path) + } + + public var downloadWithAria2: (Path, URL, Path, [HTTPCookie]) -> (Progress, Promise) = { aria2Path, url, destination, cookies in + let process = Process() + process.executableURL = aria2Path.url + process.arguments = [ + "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", + "--max-connection-per-server=16", + "--split=16", + "--summary-interval=1", + "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", + "--dir=\(destination.parent.string)", + "--out=\(destination.basename())", + url.absoluteString, + ] + let stdOutPipe = Pipe() + process.standardOutput = stdOutPipe + let stdErrPipe = Pipe() + process.standardError = stdErrPipe + + var progress = Progress(totalUnitCount: 100) + + let observer = NotificationCenter.default.addObserver( + forName: .NSFileHandleDataAvailable, + object: nil, + queue: OperationQueue.main + ) { note in + guard + // This should always be the case for Notification.Name.NSFileHandleDataAvailable + let handle = note.object as? FileHandle, + handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading + else { return } + + defer { handle.waitForDataInBackgroundAndNotify() } + + let string = String(decoding: handle.availableData, as: UTF8.self) + let regex = try! NSRegularExpression(pattern: #"((?\d+)%\))"#) + let range = NSRange(location: 0, length: string.utf16.count) + + guard + let match = regex.firstMatch(in: string, options: [], range: range), + let matchRange = Range(match.range(withName: "percent"), in: string), + let percentCompleted = Int64(string[matchRange]) + else { return } + + progress.completedUnitCount = percentCompleted + } + + stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() + + do { + try process.run() + } catch { + return (progress, Promise(error: error)) + } + + let promise = Promise { seal in + DispatchQueue.global(qos: .default).async { + process.waitUntilExit() + + NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { + return seal.reject(aria2cError) + } else { + return seal.reject(Process.PMKError.execution(process: process, standardOutput: "", standardError: "")) + } + } + seal.fulfill(()) + } + } + + return (progress, promise) + } + + public var readLine: (String) -> String? = { prompt in + print(prompt, terminator: "") + return Swift.readLine() + } + public func readLine(prompt: String) -> String? { + readLine(prompt) + } + + public var readSecureLine: (String, Int) -> String? = { prompt, maximumLength in + let buffer = UnsafeMutablePointer.allocate(capacity: maximumLength) + buffer.initialize(repeating: 0, count: maximumLength) + defer { + buffer.deinitialize(count: maximumLength) + buffer.initialize(repeating: 0, count: maximumLength) + buffer.deinitialize(count: maximumLength) + buffer.deallocate() + } + + guard let passwordData = readpassphrase(prompt, buffer, maximumLength, 0) else { + return nil + } + + return String(validatingUTF8: passwordData) + } + /** + Like `readLine()`, but doesn't echo the user's input to the screen. + + - Parameter prompt: Prompt printed on the line preceding user input + - Parameter maximumLength: The maximum length to read, in bytes + + - Returns: The entered password, or nil if an error occurred. + + Buffer is zeroed after use. + + - SeeAlso: [readpassphrase man page](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/readpassphrase.3.html) + */ + public func readSecureLine(prompt: String, maximumLength: Int = 8192) -> String? { + readSecureLine(prompt, maximumLength) + } + + public var env: (String) -> String? = { key in + ProcessInfo.processInfo.environment[key] + } + public func env(_ key: String) -> String? { + env(key) + } + + public var exit: (Int32) -> Void = { Darwin.exit($0) } +} + +public struct Files { + public var fileExistsAtPath: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } + + public func fileExists(atPath path: String) -> Bool { + return fileExistsAtPath(path) + } + + public var moveItem: (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) } + + public func moveItem(at srcURL: URL, to dstURL: URL) throws { + try moveItem(srcURL, dstURL) + } + + public var contentsAtPath: (String) -> Data? = { FileManager.default.contents(atPath: $0) } + + public func contents(atPath path: String) -> Data? { + return contentsAtPath(path) + } + + public var removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) } + + public func removeItem(at URL: URL) throws { + try removeItem(URL) + } + + public var trashItem: (URL) throws -> URL = { try FileManager.default.trashItem(at: $0) } + + @discardableResult + public func trashItem(at URL: URL) throws -> URL { + return try trashItem(URL) + } + + public var createFile: (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) } + + @discardableResult + public func createFile(atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey : Any]? = nil) -> Bool { + return createFile(path, data, attr) + } + + public var createDirectory: (URL, Bool, [FileAttributeKey : Any]?) throws -> Void = FileManager.default.createDirectory(at:withIntermediateDirectories:attributes:) + public func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { + try createDirectory(url, createIntermediates, attributes) + } + + public var installedXcodes = XcodesKit.installedXcodes +} +private func installedXcodes(destination: Path) -> [InstalledXcode] { + ((try? destination.ls()) ?? []) + .filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" } + .map { $0.path } + .compactMap(InstalledXcode.init) +} + +public struct Network { + private static let client = AppleAPI.Client() + + public var dataTask: (URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> = { AppleAPI.Current.network.session.dataTask(.promise, with: $0) } + public func dataTask(with convertible: URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> { + dataTask(convertible) + } + + public var downloadTask: (URLRequestConvertible, URL, Data?) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) } + + public func downloadTask(with convertible: URLRequestConvertible, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, promise: Promise<(saveLocation: URL, response: URLResponse)>) { + return downloadTask(convertible, saveLocation, resumeData) + } + + public var validateSession: () -> Promise = client.validateSession + + public var login: (String, String) -> Promise = { client.login(accountName: $0, password: $1) } + public func login(accountName: String, password: String) -> Promise { + login(accountName, password) + } +} + +public struct Logging { + public var log: (String) -> Void = { print($0) } +} + +public struct Keychain { + private static let keychain = KeychainAccess.Keychain(service: "com.robotsandpencils.xcodes") + + public var getString: (String) throws -> String? = keychain.getString(_:) + public func getString(_ key: String) throws -> String? { + try getString(key) + } + + public var set: (String, String) throws -> Void = keychain.set(_:key:) + public func set(_ value: String, key: String) throws { + try set(value, key) + } + + public var remove: (String) throws -> Void = keychain.remove(_:) + public func remove(_ key: String) throws -> Void { + try remove(key) + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/FileManager+.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/FileManager+.swift new file mode 100644 index 0000000..12c96e1 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/FileManager+.swift @@ -0,0 +1,17 @@ +import Foundation + +extension FileManager { + /** + Moves an item to the trash. + + This implementation exists only to make the existing method more idiomatic by returning the resulting URL instead of setting the value on an inout argument. + + FB6735133: FileManager.trashItem(at:resultingItemURL:) is not an idiomatic Swift API + */ + @discardableResult + func trashItem(at url: URL) throws -> URL { + var resultingItemURL: NSURL! + try trashItem(at: url, resultingItemURL: &resultingItemURL) + return resultingItemURL as URL + } +} \ No newline at end of file diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Foundation.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Foundation.swift new file mode 100644 index 0000000..8ff82ae --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Foundation.swift @@ -0,0 +1,28 @@ +import Foundation + +public extension BidirectionalCollection where Element: Equatable { + func suffix(fromLast delimiter: Element) -> Self.SubSequence { + guard + let lastIndex = lastIndex(of: delimiter), + index(after: lastIndex) < endIndex + else { return suffix(0) } + return suffix(from: index(after: lastIndex)) + } +} + +public extension NumberFormatter { + convenience init(numberStyle: NumberFormatter.Style) { + self.init() + self.numberStyle = numberStyle + } + + func string(from number: N) -> String? { + return string(from: number as! NSNumber) + } +} + +extension Sequence { + func sorted(_ keyPath: KeyPath) -> [Element] { + sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Migration.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Migration.swift new file mode 100644 index 0000000..3c7460a --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Migration.swift @@ -0,0 +1,17 @@ +import Path + +/// Migrates any application support files from Xcodes < v0.4 if application support files from >= v0.4 don't exist +public func migrateApplicationSupportFiles() { + if Current.files.fileExistsAtPath(Path.oldXcodesApplicationSupport.string) { + if Current.files.fileExistsAtPath(Path.xcodesApplicationSupport.string) { + Current.logging.log("Removing old support files...") + try? Current.files.removeItem(Path.oldXcodesApplicationSupport.url) + Current.logging.log("Done") + } + else { + Current.logging.log("Migrating old support files...") + try? Current.files.moveItem(Path.oldXcodesApplicationSupport.url, Path.xcodesApplicationSupport.url) + Current.logging.log("Done") + } + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Models.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Models.swift new file mode 100644 index 0000000..fe0fbb8 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Models.swift @@ -0,0 +1,89 @@ +import Foundation +import Path +import Version + +public struct InstalledXcode: Equatable { + public let path: Path + /// Composed of the bundle short version from Info.plist and the product build version from version.plist + public let version: Version + + public init?(path: Path) { + self.path = path + + let infoPlistPath = path.join("Contents").join("Info.plist") + let versionPlistPath = path.join("Contents").join("version.plist") + guard + let infoPlistData = Current.files.contents(atPath: infoPlistPath.string), + let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData), + let bundleShortVersion = infoPlist.bundleShortVersion, + let bundleVersion = Version(tolerant: bundleShortVersion), + + let versionPlistData = Current.files.contents(atPath: versionPlistPath.string), + let versionPlist = try? PropertyListDecoder().decode(VersionPlist.self, from: versionPlistData) + else { return nil } + + // Installed betas don't include the beta number anywhere, so try to parse it from the filename or fall back to simply "beta" + var prereleaseIdentifiers = bundleVersion.prereleaseIdentifiers + if let filenameVersion = Version(path.basename(dropExtension: true).replacingOccurrences(of: "Xcode-", with: "")) { + prereleaseIdentifiers = filenameVersion.prereleaseIdentifiers + } + else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") { + prereleaseIdentifiers = ["beta"] + } + + self.version = Version(major: bundleVersion.major, + minor: bundleVersion.minor, + patch: bundleVersion.patch, + prereleaseIdentifiers: prereleaseIdentifiers, + buildMetadataIdentifiers: [versionPlist.productBuildVersion].compactMap { $0 }) + } +} + +public struct Xcode: Codable { + public let version: Version + public let url: URL + public let filename: String + public let releaseDate: Date? + + public init(version: Version, url: URL, filename: String, releaseDate: Date?) { + self.version = version + self.url = url + self.filename = filename + self.releaseDate = releaseDate + } +} + +struct Downloads: Codable { + let downloads: [Download] +} + +public struct Download: Codable { + public let name: String + public let files: [File] + public let dateModified: Date + + public struct File: Codable { + public let remotePath: String + } +} + +public struct InfoPlist: Decodable { + public let bundleID: String? + public let bundleShortVersion: String? + public let bundleIconName: String? + + public enum CodingKeys: String, CodingKey { + case bundleID = "CFBundleIdentifier" + case bundleShortVersion = "CFBundleShortVersionString" + case bundleIconName = "CFBundleIconName" + } +} + +public struct VersionPlist: Decodable { + public let productBuildVersion: String + + public enum CodingKeys: String, CodingKey { + case productBuildVersion = "ProductBuildVersion" + } +} + diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Path+.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Path+.swift new file mode 100644 index 0000000..44315a6 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Path+.swift @@ -0,0 +1,8 @@ +import Path + +extension Path { + static let oldXcodesApplicationSupport = Path.applicationSupport/"ca.brandonevans.xcodes" + static let xcodesApplicationSupport = Path.applicationSupport/"com.robotsandpencils.xcodes" + static let cacheFile = xcodesApplicationSupport/"available-xcodes.json" + static let configurationFile = xcodesApplicationSupport/"configuration.json" +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Process.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Process.swift new file mode 100644 index 0000000..dd79217 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Process.swift @@ -0,0 +1,41 @@ +import Foundation +import PromiseKit +import PMKFoundation +import Path + +public typealias ProcessOutput = (status: Int32, out: String, err: String) + +extension Process { + @discardableResult + static func sudo(password: String? = nil, _ executable: Path, workingDirectory: URL? = nil, _ arguments: String...) -> Promise { + var arguments = [executable.string] + arguments + if password != nil { + arguments.insert("-S", at: 0) + } + return run(Path.root.usr.bin.sudo.url, workingDirectory: workingDirectory, input: password, arguments) + } + + @discardableResult + static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> Promise { + return run(executable.url, workingDirectory: workingDirectory, input: input, arguments) + } + + @discardableResult + static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) -> Promise { + let process = Process() + process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent() + process.executableURL = executable + process.arguments = arguments + if let input = input { + let inputPipe = Pipe() + process.standardInput = inputPipe.fileHandleForReading + inputPipe.fileHandleForWriting.write(Data(input.utf8)) + inputPipe.fileHandleForWriting.closeFile() + } + return process.launch(.promise).map { std in + let output = String(data: std.out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let error = String(data: std.err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, output, error) + } + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Promise+.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Promise+.swift new file mode 100644 index 0000000..9398cd7 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Promise+.swift @@ -0,0 +1,40 @@ +import Foundation +import PromiseKit + +/// Attempt and retry a task that fails with resume data up to `maximumRetryCount` times +func attemptResumableTask( + maximumRetryCount: Int = 3, + delayBeforeRetry: DispatchTimeInterval = .seconds(2), + _ body: @escaping (Data?) -> Promise +) -> Promise { + var attempts = 0 + func attempt(with resumeData: Data? = nil) -> Promise { + attempts += 1 + return body(resumeData).recover { error -> Promise in + guard + attempts < maximumRetryCount, + let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data + else { throw error } + + return after(delayBeforeRetry).then(on: nil) { attempt(with: resumeData) } + } + } + return attempt() +} + +/// Attempt and retry a task up to `maximumRetryCount` times +func attemptRetryableTask( + maximumRetryCount: Int = 3, + delayBeforeRetry: DispatchTimeInterval = .seconds(2), + _ body: @escaping () -> Promise +) -> Promise { + var attempts = 0 + func attempt() -> Promise { + attempts += 1 + return body().recover { error -> Promise in + guard attempts < maximumRetryCount else { throw error } + return after(delayBeforeRetry).then(on: nil) { attempt() } + } + } + return attempt() +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/URLRequest+Apple.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/URLRequest+Apple.swift new file mode 100644 index 0000000..efe9afc --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/URLRequest+Apple.swift @@ -0,0 +1,28 @@ +import Foundation + +extension URL { + static let download = URL(string: "https://developer.apple.com/download")! + static let downloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")! + static let downloadXcode = URL(string: "https://developer.apple.com/devcenter/download.action")! +} + +extension URLRequest { + static var download: URLRequest { + return URLRequest(url: .download) + } + + static var downloads: URLRequest { + var request = URLRequest(url: .downloads) + request.httpMethod = "POST" + return request + } + + static func downloadXcode(path: String) -> URLRequest { + var components = URLComponents(url: .downloadXcode, resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "path", value: path)] + var request = URLRequest(url: components.url!) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + request.allHTTPHeaderFields?["Accept"] = "*/*" + return request + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/URLSession+Promise.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/URLSession+Promise.swift new file mode 100644 index 0000000..04ee10e --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/URLSession+Promise.swift @@ -0,0 +1,47 @@ +import Foundation +import PromiseKit +import PMKFoundation + +extension URLSession { + /** + - Parameter convertible: A URL or URLRequest. + - Parameter saveLocation: A URL to move the downloaded file to after it completes. Apple deletes the temporary file immediately after the underyling completion handler returns. + - Parameter resumeData: Data describing the state of a previously cancelled or failed download task. See the Discussion section for `downloadTask(withResumeData:completionHandler:)` https://developer.apple.com/documentation/foundation/urlsession/1411598-downloadtask# + + - Returns: Tuple containing a Progress object for the task and a promise containing the save location and response. + + - Note: We do not create the destination directory for you, because we move the file with FileManager.moveItem which changes its behavior depending on the directory status of the URL you provide. So create your own directory first! + */ + public func downloadTask(with convertible: URLRequestConvertible, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, promise: Promise<(saveLocation: URL, response: URLResponse)>) { + var progress: Progress! + + let promise = Promise<(saveLocation: URL, response: URLResponse)> { seal in + let completionHandler = { (temporaryURL: URL?, response: URLResponse?, error: Error?) in + if let error = error { + seal.reject(error) + } else if let response = response, let temporaryURL = temporaryURL { + do { + try FileManager.default.moveItem(at: temporaryURL, to: saveLocation) + seal.fulfill((saveLocation, response)) + } catch { + seal.reject(error) + } + } else { + seal.reject(PMKError.invalidCallingConvention) + } + } + + let task: URLSessionDownloadTask + if let resumeData = resumeData { + task = downloadTask(withResumeData: resumeData, completionHandler: completionHandler) + } + else { + task = downloadTask(with: convertible.pmkRequest, completionHandler: completionHandler) + } + progress = task.progress + task.resume() + } + + return (progress, promise) + } +} \ No newline at end of file diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Version+.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Version+.swift new file mode 100644 index 0000000..c4ecec3 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Version+.swift @@ -0,0 +1,51 @@ +import Version + +public extension Version { + func isEqualWithoutBuildMetadataIdentifiers(to other: Version) -> Bool { + return major == other.major && + minor == other.minor && + patch == other.patch && + prereleaseIdentifiers == other.prereleaseIdentifiers + } + + /// If release versions, don't compare build metadata because that's not provided in the /downloads/more list + /// if beta versions, compare build metadata because it's available in versions.plist + func isEquivalentForDeterminingIfInstalled(toInstalled installed: Version) -> Bool { + let isBeta = !prereleaseIdentifiers.isEmpty + let otherIsBeta = !installed.prereleaseIdentifiers.isEmpty + + if isBeta && otherIsBeta { + if buildMetadataIdentifiers.isEmpty { + return major == installed.major && + minor == installed.minor && + patch == installed.patch && + prereleaseIdentifiers == installed.prereleaseIdentifiers + } + else { + return major == installed.major && + minor == installed.minor && + patch == installed.patch && + prereleaseIdentifiers == installed.prereleaseIdentifiers && + buildMetadataIdentifiers.map { $0.lowercased() } == installed.buildMetadataIdentifiers.map { $0.lowercased() } + } + } + else if !isBeta && !otherIsBeta { + return major == installed.major && + minor == installed.minor && + patch == installed.patch + } + + return false + } + + var descriptionWithoutBuildMetadata: String { + var base = "\(major).\(minor).\(patch)" + if !prereleaseIdentifiers.isEmpty { + base += "-" + prereleaseIdentifiers.joined(separator: ".") + } + return base + } + + var isPrerelease: Bool { prereleaseIdentifiers.isEmpty == false } + var isNotPrerelease: Bool { prereleaseIdentifiers.isEmpty == true } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Version+Gem.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Version+Gem.swift new file mode 100644 index 0000000..57869ab --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Version+Gem.swift @@ -0,0 +1,45 @@ +import Foundation +import Version + +public extension Version { + /** + Attempts to parse Gem::Version representations. + + E.g.: + 9.2b3 + 9.1.2 + 9.2 + 9 + + Doesn't handle GM prerelease identifier + */ + init?(gemVersion: String) { + let nsrange = NSRange(gemVersion.startIndex.. String in + switch identifier.lowercased() { + case "a": return "Alpha" + case "b": return "Beta" + default: return identifier + } + } + + self = Version(major: major, minor: minor, patch: patch, prereleaseIdentifiers: prereleaseIdentifiers) + } +} \ No newline at end of file diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Version+Xcode.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Version+Xcode.swift new file mode 100644 index 0000000..02e254c --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Version+Xcode.swift @@ -0,0 +1,65 @@ +import Foundation +import Version + +public extension Version { + /** + E.g.: + Xcode 10.2 Beta 4 + Xcode 10.2 GM + Xcode 10.2 GM seed 2 + Xcode 10.2 + Xcode 10.2.1 + 10.2 Beta 4 + 10.2 GM + 10.2 + 10.2.1 + */ + init?(xcodeVersion: String, buildMetadataIdentifier: String? = nil) { + let nsrange = NSRange(xcodeVersion.startIndex.. String? { + let nsrange = range(withName: name) + guard let range = Range(nsrange, in: string) else { return nil } + return String(string[range]) + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/Version.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/Version.swift new file mode 100644 index 0000000..cafdedb --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/Version.swift @@ -0,0 +1,3 @@ +import Version + +public let version = Version("0.12.0")! diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift new file mode 100644 index 0000000..2ade8f1 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift @@ -0,0 +1,733 @@ +import Foundation +import PromiseKit +import Path +import AppleAPI +import Version +import LegibleError + +/// Downloads and installs Xcodes +public final class XcodeInstaller { + static let XcodeTeamIdentifier = "59GAB85EFG" + static let XcodeCertificateAuthority = ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"] + + public enum Error: LocalizedError, Equatable { + case damagedXIP(url: URL) + case failedToMoveXcodeToDestination(Path) + case failedSecurityAssessment(xcode: InstalledXcode, output: String) + case codesignVerifyFailed(output: String) + case unexpectedCodeSigningIdentity(identifier: String, certificateAuthority: [String]) + case unsupportedFileFormat(extension: String) + case missingSudoerPassword + case unavailableVersion(Version) + case noNonPrereleaseVersionAvailable + case noPrereleaseVersionAvailable + case missingUsernameOrPassword + case versionAlreadyInstalled(InstalledXcode) + case invalidVersion(String) + case versionNotInstalled(Version) + + public var errorDescription: String? { + switch self { + case .damagedXIP(let url): + return "The archive \"\(url.lastPathComponent)\" is damaged and can't be expanded." + case .failedToMoveXcodeToDestination(let destination): + return "Failed to move Xcode to the \(destination.string) directory." + case .failedSecurityAssessment(let xcode, let output): + return """ + Xcode \(xcode.version) failed its security assessment with the following output: + \(output) + It remains installed at \(xcode.path) if you wish to use it anyways. + """ + case .codesignVerifyFailed(let output): + return """ + The downloaded Xcode failed code signing verification with the following output: + \(output) + """ + case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority): + return """ + The downloaded Xcode doesn't have the expected code signing identity. + Got: + \(identity) + \(certificateAuthority) + Expected: + \(XcodeInstaller.XcodeTeamIdentifier) + \(XcodeInstaller.XcodeCertificateAuthority) + """ + case .unsupportedFileFormat(let fileExtension): + return "xcodes doesn't (yet) support installing Xcode from the \(fileExtension) file format." + case .missingSudoerPassword: + return "Missing password. Please try again." + case let .unavailableVersion(version): + return "Could not find version \(version.xcodeDescription)." + case .noNonPrereleaseVersionAvailable: + return "No non-prerelease versions available." + case .noPrereleaseVersionAvailable: + return "No prerelease versions available." + case .missingUsernameOrPassword: + return "Missing username or a password. Please try again." + case let .versionAlreadyInstalled(installedXcode): + return "\(installedXcode.version.xcodeDescription) is already installed at \(installedXcode.path)" + case let .invalidVersion(version): + return "\(version) is not a valid version number." + case let .versionNotInstalled(version): + return "\(version.xcodeDescription) is not installed." + } + } + } + + /// A numbered step + enum InstallationStep: CustomStringConvertible { + case downloading(version: String, progress: String) + case unarchiving + case moving(destination: String) + case trashingArchive(archiveName: String) + case checkingSecurity + case finishing + + var description: String { + "(\(stepNumber)/\(stepCount)) \(message)" + } + + var message: String { + switch self { + case .downloading(let version, let progress): + return "Downloading Xcode \(version): \(progress)" + case .unarchiving: + return "Unarchiving Xcode (This can take a while)" + case .moving(let destination): + return "Moving Xcode to \(destination)" + case .trashingArchive(let archiveName): + return "Moving Xcode archive \(archiveName) to the Trash" + case .checkingSecurity: + return "Checking security assessment and code signing" + case .finishing: + return "Finishing installation" + } + } + + var stepNumber: Int { + switch self { + case .downloading: return 1 + case .unarchiving: return 2 + case .moving: return 3 + case .trashingArchive: return 4 + case .checkingSecurity: return 5 + case .finishing: return 6 + } + } + + var stepCount: Int { 6 } + } + + private var configuration: Configuration + private var xcodeList: XcodeList + + public init(configuration: Configuration, xcodeList: XcodeList) { + self.configuration = configuration + self.xcodeList = xcodeList + } + + public enum InstallationType { + case version(String) + case url(String, Path) + case latest + case latestPrerelease + } + + public enum Downloader { + case urlSession + case aria2(Path) + } + + public func install(_ installationType: InstallationType, downloader: Downloader, destination: Path) -> Promise { + return firstly { () -> Promise in + return self.install(installationType, downloader: downloader, destination: destination, attemptNumber: 0) + } + .done { xcode in + Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)") + Current.shell.exit(0) + } + } + + private func install(_ installationType: InstallationType, downloader: Downloader, destination: Path, attemptNumber: Int) -> Promise { + return firstly { () -> Promise<(Xcode, URL)> in + return self.getXcodeArchive(installationType, downloader: downloader, destination: destination) + } + .then { xcode, url -> Promise in + return self.installArchivedXcode(xcode, at: url, to: destination) + } + .recover { error -> Promise in + switch error { + case XcodeInstaller.Error.damagedXIP(let damagedXIPURL): + guard attemptNumber < 1 else { throw error } + + switch installationType { + case .url: + // If the user provided the URL, don't try to recover and leave it up to them. + throw error + default: + // If the XIP was just downloaded, remove it and try to recover. + return firstly { () -> Promise in + Current.logging.log(error.legibleLocalizedDescription) + Current.logging.log("Removing damaged XIP and re-attempting installation.\n") + try Current.files.removeItem(at: damagedXIPURL) + return self.install(installationType, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1) + } + } + default: + throw error + } + } + } + + private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader, destination: Path) -> Promise<(Xcode, URL)> { + return firstly { () -> Promise<(Xcode, URL)> in + switch installationType { + case .latest: + Current.logging.log("Updating...") + + return update() + .then { availableXcodes -> Promise<(Xcode, URL)> in + guard let latestNonPrereleaseXcode = availableXcodes.filter(\.version.isNotPrerelease).sorted(\.version).last else { + throw Error.noNonPrereleaseVersionAvailable + } + Current.logging.log("Latest non-prerelease version available is \(latestNonPrereleaseXcode.version.xcodeDescription)") + + if let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestNonPrereleaseXcode.version) }) { + throw Error.versionAlreadyInstalled(installedXcode) + } + + return self.downloadXcode(version: latestNonPrereleaseXcode.version, downloader: downloader) + } + case .latestPrerelease: + Current.logging.log("Updating...") + + return update() + .then { availableXcodes -> Promise<(Xcode, URL)> in + guard let latestPrereleaseXcode = availableXcodes + .filter({ $0.version.isPrerelease }) + .filter({ $0.releaseDate != nil }) + .sorted(by: { $0.releaseDate! < $1.releaseDate! }) + .last + else { + throw Error.noNonPrereleaseVersionAvailable + } + Current.logging.log("Latest prerelease version available is \(latestPrereleaseXcode.version.xcodeDescription)") + + if let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestPrereleaseXcode.version) }) { + throw Error.versionAlreadyInstalled(installedXcode) + } + + return self.downloadXcode(version: latestPrereleaseXcode.version, downloader: downloader) + } + case .url(let versionString, let path): + guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else { + throw Error.invalidVersion(versionString) + } + let xcode = Xcode(version: version, url: path.url, filename: String(path.string.suffix(fromLast: "/")), releaseDate: nil) + return Promise.value((xcode, path.url)) + case .version(let versionString): + guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else { + throw Error.invalidVersion(versionString) + } + if let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) { + throw Error.versionAlreadyInstalled(installedXcode) + } + return self.downloadXcode(version: version, downloader: downloader) + } + } + } + + private func versionFromXcodeVersionFile() -> Version? { + let xcodeVersionFilePath = Path.cwd.join(".xcode-version") + let version = (try? Data(contentsOf: xcodeVersionFilePath.url)) + .flatMap { String(data: $0, encoding: .utf8) } + .flatMap(Version.init(gemVersion:)) + return version + } + + private func downloadXcode(version: Version, downloader: Downloader) -> Promise<(Xcode, URL)> { + return firstly { () -> Promise in + loginIfNeeded().map { version } + } + .then { version -> Promise in + if self.xcodeList.shouldUpdate { + return self.xcodeList.update().map { _ in version } + } + else { + return Promise.value(version) + } + } + .then { version -> Promise<(Xcode, URL)> in + guard let xcode = self.xcodeList.availableXcodes.first(where: { version.isEqualWithoutBuildMetadataIdentifiers(to: $0.version) }) else { + throw Error.unavailableVersion(version) + } + + // Move to the next line + Current.logging.log("") + let formatter = NumberFormatter(numberStyle: .percent) + var observation: NSKeyValueObservation? + + let promise = self.downloadOrUseExistingArchive(for: xcode, downloader: downloader, 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)!))") + } + }) + + return promise + .get { _ in observation?.invalidate() } + .map { return (xcode, $0) } + } + } + + func loginIfNeeded(withUsername existingUsername: String? = nil) -> Promise { + return firstly { () -> Promise in + return Current.network.validateSession() + } + .recover { error -> Promise in + guard + let username = existingUsername ?? self.findUsername() ?? Current.shell.readLine(prompt: "Apple ID: "), + let password = self.findPassword(withUsername: username) ?? Current.shell.readSecureLine(prompt: "Apple ID Password: ") + else { throw Error.missingUsernameOrPassword } + + return firstly { () -> Promise in + self.login(username, password: password) + } + .recover { error -> Promise in + Current.logging.log(error.legibleLocalizedDescription) + + if case Client.Error.invalidUsernameOrPassword = error { + Current.logging.log("Try entering your password again") + return self.loginIfNeeded(withUsername: username) + } + else { + return Promise(error: error) + } + } + } + } + + public func login(_ username: String, password: String) -> Promise { + return firstly { () -> Promise in + Current.network.login(accountName: username, password: password) + } + .recover { error -> Promise in + + if let error = error as? Client.Error { + switch error { + case .invalidUsernameOrPassword(_): + // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. + try? Current.keychain.remove(username) + default: + break + } + } + + return Promise(error: error) + } + .done { _ in + try? Current.keychain.set(password, key: username) + + if self.configuration.defaultUsername != username { + self.configuration.defaultUsername = username + try? self.configuration.save() + } + } + } + + let xcodesUsername = "XCODES_USERNAME" + let xcodesPassword = "XCODES_PASSWORD" + + func findUsername() -> String? { + if let username = Current.shell.env(xcodesUsername) { + return username + } + else if let username = configuration.defaultUsername { + return username + } + return nil + } + + func findPassword(withUsername username: String) -> String? { + if let password = Current.shell.env(xcodesPassword) { + return password + } + else if let password = try? Current.keychain.getString(username){ + return password + } + return nil + } + + public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, 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 + let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2") + var aria2DownloadIsIncomplete = false + if case .aria2 = downloader, aria2DownloadMetadataPath.exists { + 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).") + return Promise.value(expectedArchivePath.url) + } + else { + let destination = Path.xcodesApplicationSupport/"Xcode-\(xcode.version).\(xcode.filename.suffix(fromLast: "."))" + switch downloader { + case .aria2(let aria2Path): + return downloadXcodeWithAria2( + xcode, + to: destination, + aria2Path: aria2Path, + progressChanged: progressChanged + ) + case .urlSession: + return downloadXcodeWithURLSession( + xcode, + to: destination, + progressChanged: progressChanged + ) + } + } + } + + public func downloadXcodeWithAria2(_ xcode: Xcode, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { + let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: xcode.url) ?? [] + + return attemptRetryableTask(maximumRetryCount: 3) { + let (progress, promise) = Current.shell.downloadWithAria2( + aria2Path, + xcode.url, + destination, + cookies + ) + progressChanged(progress) + return promise.map { _ in destination.url } + } + } + + public func downloadXcodeWithURLSession(_ xcode: Xcode, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { + let resumeDataPath = Path.xcodesApplicationSupport/"Xcode-\(xcode.version).resumedata" + let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string) + + return attemptResumableTask(maximumRetryCount: 3) { resumeData in + let (progress, promise) = Current.network.downloadTask(with: xcode.url, + to: destination.url, + resumingWith: resumeData ?? persistedResumeData) + progressChanged(progress) + return promise.map { $0.saveLocation } + } + .tap { result in + self.persistOrCleanUpResumeData(at: resumeDataPath, for: result) + } + } + + public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path) -> Promise { + let passwordInput = { + Promise { seal in + Current.logging.log("xcodes requires superuser privileges in order to finish installation.") + guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(Error.missingSudoerPassword); return } + seal.fulfill(password + "\n") + } + } + + return firstly { () -> Promise in + let destinationURL = destination.join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url + switch archiveURL.pathExtension { + case "xip": + return unarchiveAndMoveXIP(at: archiveURL, to: destinationURL).map { xcodeURL in + guard + let path = Path(url: xcodeURL), + Current.files.fileExists(atPath: path.string), + let installedXcode = InstalledXcode(path: path) + else { throw Error.failedToMoveXcodeToDestination(destination) } + return installedXcode + } + case "dmg": + throw Error.unsupportedFileFormat(extension: "dmg") + default: + throw Error.unsupportedFileFormat(extension: archiveURL.pathExtension) + } + } + .then { xcode -> Promise in + Current.logging.log(InstallationStep.trashingArchive(archiveName: archiveURL.lastPathComponent).description) + try Current.files.trashItem(at: archiveURL) + Current.logging.log(InstallationStep.checkingSecurity.description) + + return when(fulfilled: self.verifySecurityAssessment(of: xcode), + self.verifySigningCertificate(of: xcode.path.url)) + .map { xcode } + } + .then { xcode -> Promise in + Current.logging.log(InstallationStep.finishing.description) + + return self.enableDeveloperMode(passwordInput: passwordInput).map { xcode } + } + .then { xcode -> Promise in + self.approveLicense(for: xcode, passwordInput: passwordInput).map { xcode } + } + .then { xcode -> Promise in + self.installComponents(for: xcode, passwordInput: passwordInput).map { xcode } + } + } + + public func uninstallXcode(_ versionString: String, destination: Path) -> Promise { + return firstly { () -> Promise<(InstalledXcode, URL)> in + guard let version = Version(xcodeVersion: versionString) else { + throw Error.invalidVersion(versionString) + } + + guard let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) else { + throw Error.versionNotInstalled(version) + } + + return Promise { seal in + seal.fulfill(try Current.files.trashItem(at: installedXcode.path.url)) + }.map { (installedXcode, $0) } + } + .then { (installedXcode, trashURL) -> Promise<(InstalledXcode, URL)> in + // If we just uninstalled the selected Xcode, try to select the latest installed version so things don't accidentally break + Current.shell.xcodeSelectPrintPath() + .then { output -> Promise<(InstalledXcode, URL)> in + if output.out.hasPrefix(installedXcode.path.string), + let latestInstalledXcode = Current.files.installedXcodes(destination).sorted(by: { $0.version < $1.version }).last { + return selectXcodeAtPath(latestInstalledXcode.path.string) + .map { output in + Current.logging.log("Selected \(output.out)") + return (installedXcode, trashURL) + } + } + else { + return Promise.value((installedXcode, trashURL)) + } + } + } + .done { (installedXcode, trashURL) in + Current.logging.log("Xcode \(installedXcode.version.xcodeDescription) moved to Trash: \(trashURL.path)") + Current.shell.exit(0) + } + } + + public func update() -> Promise<[Xcode]> { + return firstly { () -> Promise in + loginIfNeeded() + } + .then { () -> Promise<[Xcode]> in + self.xcodeList.update() + } + } + + public func updateAndPrint(destination: Path) -> Promise { + update() + .then { xcodes -> Promise in + self.printAvailableXcodes(xcodes, installed: Current.files.installedXcodes(destination)) + } + .done { + Current.shell.exit(0) + } + } + + public func printAvailableXcodes(_ xcodes: [Xcode], installed installedXcodes: [InstalledXcode]) -> Promise { + struct ReleasedVersion { + let version: Version + let releaseDate: Date? + } + + var allXcodeVersions = xcodes.map { ReleasedVersion(version: $0.version, releaseDate: $0.releaseDate) } + for installedXcode in installedXcodes { + // If an installed version isn't listed online, add the installed version + if !allXcodeVersions.contains(where: { releasedVersion in + releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) + }) { + allXcodeVersions.append(ReleasedVersion(version: installedXcode.version, releaseDate: nil)) + } + // If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version with build metadata + else if let index = allXcodeVersions.firstIndex(where: { releasedVersion in + releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) && + releasedVersion.version.buildMetadataIdentifiers.isEmpty + }) { + allXcodeVersions[index] = ReleasedVersion(version: installedXcode.version, releaseDate: nil) + } + } + + return Current.shell.xcodeSelectPrintPath() + .done { output in + let selectedInstalledXcodeVersion = installedXcodes.first { output.out.hasPrefix($0.path.string) }.map { $0.version } + + allXcodeVersions + .sorted { first, second -> Bool in + // Sort prereleases by release date, otherwise sort by version + if first.version.isPrerelease, second.version.isPrerelease, let firstDate = first.releaseDate, let secondDate = second.releaseDate { + return firstDate < secondDate + } + return first.version < second.version + } + .forEach { releasedVersion in + var output = releasedVersion.version.xcodeDescription + if installedXcodes.contains(where: { releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) { + if releasedVersion.version == selectedInstalledXcodeVersion { + output += " (Installed, Selected)" + } + else { + output += " (Installed)" + } + } + Current.logging.log(output) + } + } + } + + public func printInstalledXcodes(destination: Path) -> Promise { + Current.shell.xcodeSelectPrintPath() + .done { pathOutput in + Current.files.installedXcodes(destination) + .sorted { $0.version < $1.version } + .forEach { installedXcode in + var output = installedXcode.version.xcodeDescription + if pathOutput.out.hasPrefix(installedXcode.path.string) { + output += " (Selected)" + } + Current.logging.log(output) + } + } + } + + func unarchiveAndMoveXIP(at source: URL, to destination: URL) -> Promise { + return firstly { () -> Promise in + Current.logging.log(InstallationStep.unarchiving.description) + return Current.shell.unxip(source) + .recover { (error) throws -> Promise in + if case Process.PMKError.execution(_, _, let standardError) = error, + standardError?.contains("damaged and can’t be expanded") == true { + throw Error.damagedXIP(url: source) + } + throw error + } + } + .map { output -> URL in + Current.logging.log(InstallationStep.moving(destination: destination.path).description) + + let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") + let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") + if Current.files.fileExists(atPath: xcodeURL.path) { + try Current.files.moveItem(at: xcodeURL, to: destination) + } + else if Current.files.fileExists(atPath: xcodeBetaURL.path) { + try Current.files.moveItem(at: xcodeBetaURL, to: destination) + } + + return destination + } + } + + public func verifySecurityAssessment(of xcode: InstalledXcode) -> Promise { + return Current.shell.spctlAssess(xcode.path.url) + .recover { (error: Swift.Error) throws -> Promise in + var output = "" + if case let Process.PMKError.execution(_, possibleOutput, possibleError) = error { + output = [possibleOutput, possibleError].compactMap { $0 }.joined(separator: "\n") + } + throw Error.failedSecurityAssessment(xcode: xcode, output: output) + } + .asVoid() + } + + func verifySigningCertificate(of url: URL) -> Promise { + return Current.shell.codesignVerify(url) + .recover { error -> Promise in + var output = "" + if case let Process.PMKError.execution(_, possibleOutput, possibleError) = error { + output = [possibleOutput, possibleError].compactMap { $0 }.joined(separator: "\n") + } + throw Error.codesignVerifyFailed(output: output) + } + .map { output -> CertificateInfo in + // codesign prints to stderr + return self.parseCertificateInfo(output.err) + } + .done { cert in + guard + cert.teamIdentifier == XcodeInstaller.XcodeTeamIdentifier, + cert.authority == XcodeInstaller.XcodeCertificateAuthority + else { throw Error.unexpectedCodeSigningIdentity(identifier: cert.teamIdentifier, certificateAuthority: cert.authority) } + } + } + + public struct CertificateInfo { + public var authority: [String] + public var teamIdentifier: String + public var bundleIdentifier: String + } + + public func parseCertificateInfo(_ rawInfo: String) -> CertificateInfo { + var info = CertificateInfo(authority: [], teamIdentifier: "", bundleIdentifier: "") + + for part in rawInfo.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) { + if part.hasPrefix("Authority") { + info.authority.append(part.components(separatedBy: "=")[1]) + } + if part.hasPrefix("TeamIdentifier") { + info.teamIdentifier = part.components(separatedBy: "=")[1] + } + if part.hasPrefix("Identifier") { + info.bundleIdentifier = part.components(separatedBy: "=")[1] + } + } + + return info + } + + func enableDeveloperMode(passwordInput: @escaping () -> Promise) -> Promise { + return firstly { () -> Promise in + Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput) + } + .then { possiblePassword -> Promise in + return Current.shell.devToolsSecurityEnable(possiblePassword).map { _ in possiblePassword } + } + .then { possiblePassword in + return Current.shell.addStaffToDevelopersGroup(possiblePassword).asVoid() + } + } + + func approveLicense(for xcode: InstalledXcode, passwordInput: @escaping () -> Promise) -> Promise { + return firstly { () -> Promise in + Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput) + } + .then { possiblePassword in + return Current.shell.acceptXcodeLicense(xcode, possiblePassword).asVoid() + } + } + + func installComponents(for xcode: InstalledXcode, passwordInput: @escaping () -> Promise) -> Promise { + return firstly { () -> Promise in + Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput) + } + .then { possiblePassword -> Promise in + Current.shell.runFirstLaunch(xcode, possiblePassword).asVoid() + } + .then { () -> Promise<(String, String, String)> in + return when(fulfilled: + Current.shell.getUserCacheDir().map { $0.out }, + Current.shell.buildVersion().map { $0.out }, + Current.shell.xcodeBuildVersion(xcode).map { $0.out } + ) + } + .then { cacheDirectory, macOSBuildVersion, toolsVersion -> Promise in + return Current.shell.touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion).asVoid() + } + } +} + +private extension XcodeInstaller { + func persistOrCleanUpResumeData(at path: Path, for result: Result) { + switch result { + case .fulfilled: + try? Current.files.removeItem(at: path.url) + case .rejected(let error): + guard let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data else { return } + Current.files.createFile(atPath: path.string, contents: resumeData) + } + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/XcodeList.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/XcodeList.swift new file mode 100644 index 0000000..dae4046 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/XcodeList.swift @@ -0,0 +1,103 @@ +import Foundation +import Path +import Version +import PromiseKit +import SwiftSoup + +/// Provides lists of available and installed Xcodes +public final class XcodeList { + public init() { + try? loadCachedAvailableXcodes() + } + + public private(set) var availableXcodes: [Xcode] = [] + + public var shouldUpdate: Bool { + return availableXcodes.isEmpty + } + + public func update() -> Promise<[Xcode]> { + return when(fulfilled: releasedXcodes(), prereleaseXcodes()) + .map { releasedXcodes, prereleaseXcodes in + // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. + // Previously pre-release versions only appeared on developer.apple.com/download. + // /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build. + // If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes. + let xcodes = releasedXcodes.filter { releasedXcode in + prereleaseXcodes.contains { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: releasedXcode.version) } == false + } + prereleaseXcodes + self.availableXcodes = xcodes + try? self.cacheAvailableXcodes(xcodes) + return xcodes + } + } +} + +extension XcodeList { + private func loadCachedAvailableXcodes() throws { + guard let data = Current.files.contents(atPath: Path.cacheFile.string) else { return } + let xcodes = try JSONDecoder().decode([Xcode].self, from: data) + self.availableXcodes = xcodes + } + + private func cacheAvailableXcodes(_ xcodes: [Xcode]) throws { + let data = try JSONEncoder().encode(xcodes) + try FileManager.default.createDirectory(at: Path.cacheFile.url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: Path.cacheFile.url) + } +} + +extension XcodeList { + private func releasedXcodes() -> Promise<[Xcode]> { + return firstly { () -> Promise<(data: Data, response: URLResponse)> in + Current.network.dataTask(with: URLRequest.downloads) + } + .map { (data, response) -> [Xcode] in + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(.downloadsDateModified) + let downloads = try decoder.decode(Downloads.self, from: data) + let xcodes = downloads + .downloads + .filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil } + .compactMap { download -> Xcode? in + let urlPrefix = URL(string: "https://download.developer.apple.com/")! + guard + let xcodeFile = download.files.first(where: { $0.remotePath.hasSuffix("dmg") || $0.remotePath.hasSuffix("xip") }), + let version = Version(xcodeVersion: download.name) + else { return nil } + + let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath) + return Xcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified) + } + return xcodes + } + } + + private func prereleaseXcodes() -> Promise<[Xcode]> { + return firstly { () -> Promise<(data: Data, response: URLResponse)> in + Current.network.dataTask(with: URLRequest.download) + } + .map { (data, _) -> [Xcode] in + try self.parsePrereleaseXcodes(from: data) + } + } + + func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] { + let body = String(data: data, encoding: .utf8)! + let document = try SwiftSoup.parse(body) + + guard + let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(), + let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""), + let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""), + let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion), + let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"), + let url = URL(string: "https://developer.apple.com" + path) + else { return [] } + + let filename = String(path.suffix(fromLast: "/")) + + return [Xcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))] + } +} diff --git a/XcodesMac/XcodesKit/Sources/XcodesKit/XcodeSelect.swift b/XcodesMac/XcodesKit/Sources/XcodesKit/XcodeSelect.swift new file mode 100644 index 0000000..3bc5185 --- /dev/null +++ b/XcodesMac/XcodesKit/Sources/XcodesKit/XcodeSelect.swift @@ -0,0 +1,134 @@ +import Foundation +import PromiseKit +import Path +import Version + +public func selectXcode(shouldPrint: Bool, pathOrVersion: String, destination: Path) -> Promise { + firstly { () -> Promise in + Current.shell.xcodeSelectPrintPath() + } + .then { output -> Promise in + if shouldPrint { + if output.out.isEmpty == false { + Current.logging.log(output.out) + Current.shell.exit(0) + return Promise.value(()) + } + else { + Current.logging.log("No selected Xcode") + Current.shell.exit(0) + return Promise.value(()) + } + } + + if let version = Version(xcodeVersion: pathOrVersion), + let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) { + return selectXcodeAtPath(installedXcode.path.string) + .done { output in + Current.logging.log("Selected \(output.out)") + Current.shell.exit(0) + } + } + else { + return selectXcodeAtPath(pathOrVersion) + .done { output in + Current.logging.log("Selected \(output.out)") + Current.shell.exit(0) + } + .recover { _ in + try selectXcodeInteractively(currentPath: output.out, destination: destination) + .done { output in + Current.logging.log("Selected \(output.out)") + Current.shell.exit(0) + } + } + } + } +} + +public func selectXcodeInteractively(currentPath: String, destination: Path, shouldRetry: Bool) -> Promise { + if shouldRetry { + func selectWithRetry(currentPath: String) -> Promise { + return firstly { + try selectXcodeInteractively(currentPath: currentPath, destination: destination) + } + .recover { error throws -> Promise in + guard case XcodeSelectError.invalidIndex = error else { throw error } + Current.logging.log("\(error.legibleLocalizedDescription)\n") + return selectWithRetry(currentPath: currentPath) + } + } + + return selectWithRetry(currentPath: currentPath) + } + else { + return firstly { + try selectXcodeInteractively(currentPath: currentPath, destination: destination) + } + } +} + +public func selectXcodeInteractively(currentPath: String, destination: Path) throws -> Promise { + let sortedInstalledXcodes = Current.files.installedXcodes(destination).sorted { $0.version < $1.version } + + Current.logging.log("Available Xcode versions:") + + sortedInstalledXcodes + .enumerated() + .forEach { index, installedXcode in + var output = "\(index + 1)) \(installedXcode.version.xcodeDescription)" + if currentPath.hasPrefix(installedXcode.path.string) { + output += " (Selected)" + } + Current.logging.log(output) + } + + let possibleSelectionNumberString = Current.shell.readLine(prompt: "Enter the number of the Xcode to select: ") + guard + let selectionNumberString = possibleSelectionNumberString, + let selectionNumber = Int(selectionNumberString), + sortedInstalledXcodes.indices.contains(selectionNumber - 1) + else { + throw XcodeSelectError.invalidIndex(min: 1, max: sortedInstalledXcodes.count, given: possibleSelectionNumberString) + } + + return selectXcodeAtPath(sortedInstalledXcodes[selectionNumber - 1].path.string) +} + +public func selectXcodeAtPath(_ pathString: String) -> Promise { + firstly { () -> Promise in + guard Current.files.fileExists(atPath: pathString) else { + throw XcodeSelectError.invalidPath(pathString) + } + + let passwordInput = { + Promise { seal in + Current.logging.log("xcodes requires superuser privileges to select an Xcode") + guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(XcodeInstaller.Error.missingSudoerPassword); return } + seal.fulfill(password + "\n") + } + } + + return Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput) + } + .then { possiblePassword in + Current.shell.xcodeSelectSwitch(password: possiblePassword, path: pathString) + } + .then { _ in + Current.shell.xcodeSelectPrintPath() + } +} + +public enum XcodeSelectError: LocalizedError { + case invalidPath(String) + case invalidIndex(min: Int, max: Int, given: String?) + + public var errorDescription: String? { + switch self { + case .invalidPath(let pathString): + return "Not a valid Xcode path: \(pathString)" + case .invalidIndex(let min, let max, let given): + return "Not a valid number. Expecting a whole number between \(min)-\(max), but given \(given ?? "nothing")." + } + } +} diff --git a/XcodesMac/XcodesKit/Tests/LinuxMain.swift b/XcodesMac/XcodesKit/Tests/LinuxMain.swift new file mode 100644 index 0000000..e779a38 --- /dev/null +++ b/XcodesMac/XcodesKit/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import XcodesKitTests + +var tests = [XCTestCaseEntry]() +tests += XcodesKitTests.allTests() +XCTMain(tests) diff --git a/XcodesMac/XcodesKit/Tests/XcodesKitTests/XCTestManifests.swift b/XcodesMac/XcodesKit/Tests/XcodesKitTests/XCTestManifests.swift new file mode 100644 index 0000000..d577375 --- /dev/null +++ b/XcodesMac/XcodesKit/Tests/XcodesKitTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(XcodesKitTests.allTests), + ] +} +#endif diff --git a/XcodesMac/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift b/XcodesMac/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift new file mode 100644 index 0000000..f379c74 --- /dev/null +++ b/XcodesMac/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import XcodesKit + +final class XcodesKitTests: XCTestCase { + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + XCTAssertEqual(XcodesKit().text, "Hello, World!") + } + + static var allTests = [ + ("testExample", testExample), + ] +} diff --git a/XcodesMac/XcodesMac.entitlements b/XcodesMac/XcodesMac.entitlements new file mode 100644 index 0000000..fbad023 --- /dev/null +++ b/XcodesMac/XcodesMac.entitlements @@ -0,0 +1,8 @@ + + + + + keychain-access-groups + + + diff --git a/XcodesMacTests/Info.plist b/XcodesMacTests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/XcodesMacTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/XcodesMacTests/XcodesMacTests.swift b/XcodesMacTests/XcodesMacTests.swift new file mode 100644 index 0000000..0c665f4 --- /dev/null +++ b/XcodesMacTests/XcodesMacTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import XcodesMac + +class XcodesMacTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +}