diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40d15dd1..404125aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,25 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v4 + - name: Checkout local package dependencies + run: | + set -euo pipefail + + checkout_dependency() { + local repo="$1" + local checkout_path="$2" + local branch="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}" + + if [ -n "$branch" ] && git ls-remote --exit-code --heads "https://github.com/${repo}.git" "$branch" >/dev/null; then + git clone --depth 1 --branch "$branch" "https://github.com/${repo}.git" "$checkout_path" + else + git clone --depth 1 "https://github.com/${repo}.git" "$checkout_path" + fi + } + + checkout_dependency XcodesOrg/XcodesLoginKit ../XcodesLoginKit + checkout_dependency XcodesOrg/XcodesKit ../XcodesKit - name: Run tests - env: + env: DEVELOPER_DIR: /Applications/Xcode_16.4.app run: swift test diff --git a/Package.resolved b/Package.resolved index 23eeeb6a..9c531c2f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,29 +1,30 @@ { + "originHash" : "d5f06ff4dc275a59467f579767ee5db0fef2947a7df458b7911fd3809d83f96d", "pins" : [ { - "identity" : "big-num", + "identity" : "asynchttpnetworkservice", "kind" : "remoteSourceControl", - "location" : "https://github.com/adam-fowler/big-num", + "location" : "https://github.com/XcodesOrg/AsyncHTTPNetworkService", "state" : { - "revision" : "5c5511ad06aeb2b97d0868f7394e14a624bfb1c7", - "version" : "2.0.2" + "branch" : "main", + "revision" : "12e225af8b5dc25afcfabfcf582a165b0581ab19" } }, { - "identity" : "data", + "identity" : "big-num", "kind" : "remoteSourceControl", - "location" : "https://github.com/xcodereleases/data", + "location" : "https://github.com/adam-fowler/big-num", "state" : { - "revision" : "fcf527b187817f67c05223676341f3ab69d4214d" + "revision" : "9059dcab8dd001b8143bc512b4477d079e97957f", + "version" : "2.0.3" } }, { - "identity" : "foundation", + "identity" : "data", "kind" : "remoteSourceControl", - "location" : "https://github.com/PromiseKit/Foundation.git", + "location" : "https://github.com/xcodereleases/data", "state" : { - "revision" : "985f17fa69ee0e5b7eb3ff9be87ffc4e05fc0927", - "version" : "3.4.0" + "revision" : "fcf527b187817f67c05223676341f3ab69d4214d" } }, { @@ -45,21 +46,21 @@ } }, { - "identity" : "path.swift", + "identity" : "libfido2swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/mxcl/Path.swift.git", + "location" : "https://github.com/kinoroy/LibFido2Swift", "state" : { - "revision" : "dac007e907a4f4c565cfdc55a9ce148a761a11d5", - "version" : "0.16.3" + "revision" : "b87a93300c5b35307c9f26ae490963196bd927f1", + "version" : "0.1.5" } }, { - "identity" : "promisekit", + "identity" : "path.swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/mxcl/PromiseKit.git", + "location" : "https://github.com/mxcl/Path.swift.git", "state" : { - "revision" : "8a98e31a47854d3180882c8068cc4d9381bf382d", - "version" : "6.22.1" + "revision" : "74ec90bbe50a3376e399286fed48b60db9b91bb1", + "version" : "1.6.0" } }, { @@ -121,10 +122,10 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams", "state" : { - "revision" : "01835dc202670b5bb90d07f3eae41867e9ed29f6", - "version" : "5.0.1" + "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", + "version" : "5.0.6" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 5d336aed..59addf9e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,36 +1,35 @@ -// swift-tools-version:5.6 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "xcodes", platforms: [ - .macOS(.v10_15) + .macOS(.v13), + .iOS(.v17) ], products: [ .executable(name: "xcodes", targets: ["xcodes"]), - .library(name: "XcodesKit", targets: ["XcodesKit"]), - .library(name: "AppleAPI", targets: ["AppleAPI"]), + .library(name: "XcodesCLIKit", targets: ["XcodesCLIKit"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "1.1.4")), - .package(url: "https://github.com/mxcl/Path.swift.git", .upToNextMinor(from: "0.16.0")), + .package(url: "https://github.com/mxcl/Path.swift.git", from: "1.0.0"), .package(url: "https://github.com/mxcl/Version.git", .upToNextMinor(from: "1.0.3")), - .package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMinor(from: "6.22.1")), - .package(url: "https://github.com/PromiseKit/Foundation.git", .upToNextMinor(from: "3.4.0")), .package(url: "https://github.com/scinfu/SwiftSoup.git", .upToNextMinor(from: "2.0.0")), .package(url: "https://github.com/mxcl/LegibleError.git", .upToNextMinor(from: "1.0.1")), .package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", .upToNextMinor(from: "3.2.0")), .package(url: "https://github.com/xcodereleases/data", revision: "fcf527b187817f67c05223676341f3ab69d4214d"), .package(url: "https://github.com/onevcat/Rainbow.git", .upToNextMinor(from: "3.2.0")), - .package(url: "https://github.com/jpsim/Yams", .upToNextMinor(from: "5.0.1")), - .package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main") + .package(path: "../XcodesLoginKit"), + .package(path: "../XcodesKit") ], targets: [ .executableTarget( name: "xcodes", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), - "XcodesKit" + "XcodesCLIKit", + .product(name: "XcodesKit", package: "XcodesKit") ]), .testTarget( name: "xcodesTests", @@ -38,46 +37,30 @@ let package = Package( "xcodes" ]), .target( - name: "XcodesKit", + name: "XcodesCLIKit", dependencies: [ - "AppleAPI", "KeychainAccess", "LegibleError", .product(name: "Path", package: "Path.swift"), - "PromiseKit", - .product(name: "PMKFoundation", package: "Foundation"), "SwiftSoup", "Unxip", "Version", .product(name: "XCModel", package: "data"), - "Rainbow", - "Yams" - ]), + "XcodesLoginKit", + .product(name: "XcodesKit", package: "XcodesKit"), + "Rainbow" + ], + path: "Sources/XcodesKit"), .testTarget( name: "XcodesKitTests", dependencies: [ - "XcodesKit", + "XcodesCLIKit", + .product(name: "XcodesKit", package: "XcodesKit"), "Version" ], resources: [ .copy("Fixtures"), ]), .target(name: "Unxip"), - .target( - name: "AppleAPI", - dependencies: [ - "PromiseKit", - .product(name: "PMKFoundation", package: "Foundation"), - "Rainbow", - .product(name: "SRP", package: "swift-srp") - ]), - .testTarget( - name: "AppleAPITests", - dependencies: [ - "AppleAPI" - ], - resources: [ - .copy("Fixtures"), - ]), ] ) diff --git a/README.md b/README.md index fd281638..0e2bb8f3 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,18 @@ If you have [aria2](https://aria2.github.io) installed (it's available in Homebr Xcode will be installed to /Applications by default, but you can provide the path to a different directory with the `--directory` option or the `XCODES_DIRECTORY` environment variable. All of the xcodes commands support this option, like `select` and `uninstall`, so you can manage Xcode versions that aren't in /Applications. xcodes supports having all of your Xcode versions installed in _one_ directory, wherever that may be. +### Architectures + +The `list` and `runtimes` commands show architecture availability when Apple provides it. Universal downloads are labeled `[Universal]`, Apple Silicon-only downloads are labeled `[Apple Silicon]`, and Intel-only downloads are labeled `[Intel]`. + +You can filter either list by architecture or variant with `--architecture arm64`, `--architecture x86_64`, `--architecture appleSilicon`, or `--architecture universal`. These filters are exact: `arm64` and `appleSilicon` show Apple Silicon-only downloads, `x86_64` shows Intel-only downloads, and `universal` shows downloads that support both architectures. The option can be used multiple times. + +```sh +xcodes list --architecture arm64 +xcodes list --architecture universal +xcodes runtimes --architecture arm64 --architecture universal --include-betas +``` + ### Install Runtimes : Run this command line to display the available runtimes diff --git a/Sources/AppleAPI/Client.swift b/Sources/AppleAPI/Client.swift deleted file mode 100644 index 06386cbe..00000000 --- a/Sources/AppleAPI/Client.swift +++ /dev/null @@ -1,518 +0,0 @@ -import Foundation -import PromiseKit -import PMKFoundation -import Rainbow -import SRP -import Crypto -import CommonCrypto - -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 serviceTemporarilyUnavailable - case noTrustedPhoneNumbers - case notAuthenticated - case invalidHashcash - case missingSecurityCodeInfo - case accountUsesHardwareKey - case srpInvalidPublicKey - case srpError(String) - - 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 .serviceTemporarilyUnavailable: - return "The service is temporarily unavailable. Please try again later." - 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." - case .notAuthenticated: - return "You are already signed out" - case .invalidHashcash: - return "Could not create a hashcash for the session." - case .missingSecurityCodeInfo: - return "Expected security code info but didn't receive any." - case .accountUsesHardwareKey: - return "Account uses a hardware key for authentication but this is not supported yet." - 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 } - } - } - - /// SRPLogin - Secure Remote Password - /// https://tools.ietf.org/html/rfc2945 - /// Forked from https://github.com/adam-fowler/swift-srp that provides the algorithm - public func srpLogin(accountName: String, password: String) -> Promise { - var serviceKey: String! - let client = SRPClient(configuration: SRPConfiguration(.N2048)) - let clientKeys = client.generateKeys() - let a = clientKeys.public - - // Get the Service Key needed from olympus session needed in headers - return firstly { () -> Promise<(data: Data, response: URLResponse)> in - Current.network.dataTask(with: URLRequest.itcServiceKey) - } - .then { (data, _) -> Promise<(serviceKey: String, hashcash: String)> in - struct ServiceKeyResponse: Decodable { - let authServiceKey: String? - } - - let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data) - serviceKey = response.authServiceKey - - /// Load a hashcash of the account name - return self.loadHashcash(accountName: accountName, serviceKey: serviceKey).map { (serviceKey, $0) } - } - .then { (serviceKey, hashcash) -> Promise<(serviceKey: String, hashcash: String, data: Data)> in - /// Call the SRP /init endpoint to start the login - return Current.network.dataTask(with: URLRequest.SRPInit(serviceKey: serviceKey, a: Data(a.bytes).base64EncodedString(), accountName: accountName)).map { (serviceKey, hashcash, $0.data)} - } - .then { (serviceKey, hashcash, data) -> Promise<(data: Data, response: URLResponse)> in - let srpInit = try JSONDecoder().decode(ServerSRPInitResponse.self, from: data) - - guard let decodedB = Data(base64Encoded: srpInit.b) else { - throw Error.srpInvalidPublicKey - } - guard let decodedSalt = Data(base64Encoded: srpInit.salt) else { - throw Error.srpInvalidPublicKey - } - - let iterations = srpInit.iteration - - do { - guard let encryptedPassword = self.pbkdf2(password: password, saltData: decodedSalt, keyByteCount: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), rounds: iterations) else { - throw Error.srpInvalidPublicKey - } - - let sharedSecret = try client.calculateSharedSecret(password: encryptedPassword, salt: [UInt8](decodedSalt), clientKeys: clientKeys, serverPublicKey: .init([UInt8](decodedB))) - - let m1 = client.calculateClientProof(username: accountName, salt: [UInt8](decodedSalt), clientPublicKey: a, serverPublicKey: .init([UInt8](decodedB)), sharedSecret: .init(sharedSecret.bytes)) - let m2 = client.calculateServerProof(clientPublicKey: a, clientProof: m1, sharedSecret: .init([UInt8](sharedSecret.bytes))) - - /// call the /complete endpoint passing in the hashcash, servicekey, and the calculated proof. - return Current.network.dataTask(with: URLRequest.SRPComplete(serviceKey: serviceKey, hashcash: hashcash, accountName: accountName, c: srpInit.c, m1: Data(m1).base64EncodedString(), m2: Data(m2).base64EncodedString())) - } catch { - throw Error.srpError(error.localizedDescription) - } - } - .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 - do { - 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: ", ")) - } - } catch DecodingError.dataCorrupted where httpResponse.statusCode == 503 { - throw Error.serviceTemporarilyUnavailable - } catch { - throw error - } - } - } - - @available(*, deprecated, message: "Please use srpLogin") - 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<(serviceKey: String, hashcash: String)> in - struct ServiceKeyResponse: Decodable { - let authServiceKey: String? - } - - let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data) - serviceKey = response.authServiceKey - - return self.loadHashcash(accountName: accountName, serviceKey: serviceKey).map { (serviceKey, $0) } - } - .then { (serviceKey, hashcash) -> Promise<(data: Data, response: URLResponse)> in - - return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password, hashcash: hashcash)) - } - .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 - do { - 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: ", ")) - } - } catch DecodingError.dataCorrupted where httpResponse.statusCode == 503 { - throw Error.serviceTemporarilyUnavailable - } catch { - throw error - } - } - } - - 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".yellow) - return Promise.value(()) - case .twoFactor: - return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) - case .hardwareKey: - throw Error.accountUsesHardwareKey - 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:".red) - String(data: data, encoding: .utf8).map { Current.logging.log($0) } - return Promise.value(()) - } - } - } - - func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise { - Current.logging.log("Two-factor authentication is enabled for this account.\n") - - // SMS was sent automatically - if authOptions.smsAutomaticallySent { - return firstly { () throws -> Promise<(data: Data, response: URLResponse)> in - guard let securityCode = authOptions.securityCode else { throw Error.missingSecurityCodeInfo } - let code = self.promptForSMSSecurityCode(length: securityCode.length, for: authOptions.trustedPhoneNumbers!.first!) - 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) - } - // SMS wasn't sent automatically because user needs to choose a phone to send to - } else if authOptions.canFallBackToSMS { - return handleWithPhoneNumberSelection(authOptions: authOptions, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - // Code is shown on trusted devices - } else { - let securityCodeLength: Int = authOptions.securityCode?.length ?? 0 - 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 \(securityCodeLength) digit code from one of your trusted devices: - """) ?? "" - - if code == "sms" { - return handleWithPhoneNumberSelection(authOptions: authOptions, 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".red) - 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(authOptions: AuthOptionsResponse, 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 = authOptions.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 - guard let securityCodeLength = authOptions.securityCode?.length else { throw Error.missingSecurityCodeInfo } - return self.promptForSMSSecurityCode(length: securityCodeLength, 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) - } - } - - // Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360 - // On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow - // Without this addition, Apple ID's would get set to locked - func loadHashcash(accountName: String, serviceKey: String) -> Promise { - return firstly{ () -> Promise<(data: Data, response: URLResponse)> in - Current.network.dataTask(with: try URLRequest.federate(account: accountName, serviceKey: serviceKey)) - } - .then { (_ response) -> Promise in - guard let urlResponse = response.response as? HTTPURLResponse else { - throw Client.Error.invalidSession - } - - guard let bitString = urlResponse.allHeaderFields["X-Apple-HC-Bits"] as? String, let bits = UInt(bitString) else { - throw Client.Error.invalidHashcash - } - guard let challenge = urlResponse.allHeaderFields["X-Apple-HC-Challenge"] as? String else { - throw Client.Error.invalidHashcash - } - guard let hashcash = Hashcash().mint(resource: challenge, bits: bits) else { - throw Client.Error.invalidHashcash - } - - return .value(hashcash) - } - } - - private func sha256(data : Data) -> Data { - var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - data.withUnsafeBytes { - _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) - } - return Data(hash) - } - - private func pbkdf2(password: String, saltData: Data, keyByteCount: Int, prf: CCPseudoRandomAlgorithm, rounds: Int) -> Data? { - guard let passwordData = password.data(using: .utf8) else { return nil } - let hashedPasswordData = sha256(data: passwordData) - - var derivedKeyData = Data(repeating: 0, count: keyByteCount) - let derivedCount = derivedKeyData.count - let derivationStatus: Int32 = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in - let keyBuffer: UnsafeMutablePointer = - derivedKeyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - return saltData.withUnsafeBytes { saltBytes -> Int32 in - let saltBuffer: UnsafePointer = saltBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - return hashedPasswordData.withUnsafeBytes { hashedPasswordBytes -> Int32 in - let passwordBuffer: UnsafePointer = hashedPasswordBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - return CCKeyDerivationPBKDF( - CCPBKDFAlgorithm(kCCPBKDF2), - passwordBuffer, - hashedPasswordData.count, - saltBuffer, - saltData.count, - prf, - UInt32(rounds), - keyBuffer, - derivedCount) - } - } - } - return derivationStatus == kCCSuccess ? derivedKeyData : nil - } -} - -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]? - let fsaChallenge: FSAChallenge? - - var kind: Kind { - if trustedDevices != nil { - return .twoStep - } else if trustedPhoneNumbers != nil { - return .twoFactor - } else if fsaChallenge != nil { - return .hardwareKey - } 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 - } - - struct FSAChallenge: Decodable { - let challenge: String - let keyHandles: [String] - let rpId: String - let allowedCredentials: String - } - - enum Kind { - case twoStep, twoFactor, hardwareKey, 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" - } - } -} - -public struct ServerSRPInitResponse: Decodable { - let iteration: Int - let salt: String - let b: String - let c: String -} diff --git a/Sources/AppleAPI/Environment.swift b/Sources/AppleAPI/Environment.swift deleted file mode 100644 index 39773215..00000000 --- a/Sources/AppleAPI/Environment.swift +++ /dev/null @@ -1,41 +0,0 @@ -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/Sources/AppleAPI/Hashcash.swift b/Sources/AppleAPI/Hashcash.swift deleted file mode 100644 index 0b303f04..00000000 --- a/Sources/AppleAPI/Hashcash.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// Hashcash.swift -// -// -// Created by Matt Kiazyk on 2023-02-23. -// - -import Foundation -import CryptoKit -import CommonCrypto - -/* -# This App Store Connect hashcash spec was generously donated by... - # - # __ _ - # __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___ - # / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __| - # | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \ - # \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/ - # |_| |_| |___/ - # - # -*/ -public struct Hashcash { - /// A function to returned a minted hash, using a bit and resource string - /// - /** - X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373 - ^ ^ ^ ^ ^ - | | | | +-- Counter - | | | +-- Resource - | | +-- Date YYMMDD[hhmm[ss]] - | +-- Bits (number of leading zeros) - +-- Version - - We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention. - 1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all. - 2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation. - - Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits - We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits - */ - /// - Parameters: - /// - resource: a string to be used for minting - /// - bits: grabbed from `X-Apple-HC-Bits` header - /// - date: Default uses Date() otherwise used for testing to check. - /// - Returns: A String hash to use in `X-Apple-HC` header on /signin - public func mint(resource: String, - bits: UInt = 10, - date: String? = nil) -> String? { - - let ver = "1" - - var ts: String - if let date = date { - ts = date - } else { - let formatter = DateFormatter() - formatter.dateFormat = "yyMMddHHmmss" - ts = formatter.string(from: Date()) - } - - let challenge = "\(ver):\(bits):\(ts):\(resource):" - - var counter = 0 - - while true { - guard let digest = ("\(challenge):\(counter)").sha1 else { - print("ERROR: Can't generate SHA1 digest") - return nil - } - - if digest == bits { - return "\(challenge):\(counter)" - } - counter += 1 - } - } -} - -extension String { - var sha1: Int? { - - let data = Data(self.utf8) - var digest = [UInt8](repeating: 0, count:Int(CC_SHA1_DIGEST_LENGTH)) - data.withUnsafeBytes { - _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest) - } - let bigEndianValue = digest.withUnsafeBufferPointer { - ($0.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: 1) { $0 }) - }.pointee - let value = UInt32(bigEndian: bigEndianValue) - return value.leadingZeroBitCount - } -} diff --git a/Sources/AppleAPI/URLRequest+Apple.swift b/Sources/AppleAPI/URLRequest+Apple.swift deleted file mode 100644 index 47704efa..00000000 --- a/Sources/AppleAPI/URLRequest+Apple.swift +++ /dev/null @@ -1,180 +0,0 @@ -import Foundation - -extension URL { - static let itcServiceKey = URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")! - // uses the get on signin to get the appropriate headers - 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")! - - static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")! - static let srpComplete = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/complete?isRememberMeEnabled=false")! -} - -extension URLRequest { - static var itcServiceKey: URLRequest { - return URLRequest(url: .itcServiceKey) - } - - static func signIn(serviceKey: String, accountName: String, password: String, hashcash: 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.allHTTPHeaderFields?["X-Apple-HC"] = hashcash - 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) - } - - /// Federate the sign in to get the X-Apple-HC header keys in order to properly mint a hashcash during regular signin - static func federate(account: String, serviceKey: String) throws -> URLRequest { - var request = URLRequest(url: .signIn) - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.httpMethod = "GET" - - return request - } - - static func SRPInit(serviceKey: String, a: String, accountName: String) -> URLRequest { - struct ServerSRPInitRequest: Encodable { - public let a: String - public let accountName: String - public let protocols: [SRPProtocol] - } - - var request = URLRequest(url: .srpInit) - request.httpMethod = "POST" - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - - request.httpBody = try? JSONEncoder().encode(ServerSRPInitRequest(a: a, accountName: accountName, protocols: [.s2k, .s2k_fo])) - return request - } - - static func SRPComplete(serviceKey: String, hashcash: String, accountName: String, c: String, m1: String, m2: String) -> URLRequest { - struct ServerSRPCompleteRequest: Encodable { - let accountName: String - let c: String - let m1: String - let m2: String - let rememberMe: Bool - } - - var request = URLRequest(url: .srpComplete) - request.httpMethod = "POST" - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash - - request.httpBody = try? JSONEncoder().encode(ServerSRPCompleteRequest(accountName: accountName, c: c, m1: m1, m2: m2, rememberMe: false)) - return request - } -} - -public enum SRPProtocol: String, Codable { - case s2k, s2k_fo -} diff --git a/Sources/Unxip/Unxip.swift b/Sources/Unxip/Unxip.swift index d45bc485..7d5515e8 100644 --- a/Sources/Unxip/Unxip.swift +++ b/Sources/Unxip/Unxip.swift @@ -16,8 +16,8 @@ extension RandomAccessCollection { } } -extension AsyncStream.Continuation { - func yieldWithBackoff(_ value: Element) async { +extension AsyncStream.Continuation where Element: Sendable { + func yieldWithBackoff(_ value: sending Element) async { let backoff: UInt64 = 1_000_000 while case .dropped(_) = yield(value) { try? await Task.sleep(nanoseconds: backoff) @@ -25,13 +25,17 @@ extension AsyncStream.Continuation { } } -public struct ConcurrentStream { +public struct ConcurrentStream: Sendable { let batchSize: Int var operations = [@Sendable () async throws -> TaskResult]() var results: AsyncStream { - AsyncStream(bufferingPolicy: .bufferingOldest(batchSize)) { continuation in - Task { + let (stream, continuation) = AsyncStream.makeStream( + of: TaskResult.self, + bufferingPolicy: .bufferingOldest(batchSize) + ) + let task = Task { + do { try await withThrowingTaskGroup(of: (Int, TaskResult).self) { group in var queueIndex = 0 var dequeIndex = 0 @@ -60,8 +64,12 @@ public struct ConcurrentStream { } continuation.finish() } + } catch { + continuation.finish() } } + continuation.onTermination = { _ in task.cancel() } + return stream } init(batchSize: Int = ProcessInfo.processInfo.activeProcessorCount) { @@ -83,32 +91,18 @@ public struct ConcurrentStream { } } -final class Chunk: Sendable { - let buffer: UnsafeBufferPointer - let owned: Bool - - init(buffer: UnsafeBufferPointer, owned: Bool) { - self.buffer = buffer - self.owned = owned - } - - deinit { - if owned { - buffer.deallocate() - } - } +struct Chunk: Sendable { + let bytes: [UInt8] } -struct File { +struct File: Sendable { let dev: Int let ino: Int let mode: Int let name: String - var data = [UnsafeBufferPointer]() - // For keeping the data alive - var chunks = [Chunk]() + var data = [[UInt8]]() - struct Identifier: Hashable { + struct Identifier: Hashable, Sendable { let dev: Int let ino: Int } @@ -235,7 +229,7 @@ extension option { } } -public struct UnxipOptions { +public struct UnxipOptions: Sendable { var input: URL var output: URL? var compress: Bool = true @@ -247,7 +241,7 @@ public struct UnxipOptions { } @available(macOS 11.0, *) -public struct Unxip { +public struct Unxip: Sendable { let options: UnxipOptions public init(options: UnxipOptions) { @@ -277,21 +271,24 @@ public struct Unxip { repeat { decompressedSize = read(UInt64.self, from: &remaining) let compressedSize = read(UInt64.self, from: &remaining) - let _remaining = remaining + let compressedBytes = Array(remaining[fromOffset: 0, size: Int(compressedSize)]) let _decompressedSize = decompressedSize chunkStream.addTask { - let remaining = _remaining let decompressedSize = _decompressedSize if compressedSize == chunkSize { - return Chunk(buffer: UnsafeBufferPointer(rebasing: remaining[fromOffset: 0, size: Int(compressedSize)]), owned: false) + return Chunk(bytes: compressedBytes) } else { let magic = [0xfd] + "7zX".utf8 - precondition(remaining.prefix(magic.count).elementsEqual(magic)) - let buffer = UnsafeMutableBufferPointer.allocate(capacity: Int(decompressedSize)) - precondition(compression_decode_buffer(buffer.baseAddress!, buffer.count, UnsafeBufferPointer(rebasing: remaining).baseAddress!, Int(compressedSize), nil, COMPRESSION_LZMA) == decompressedSize) - return Chunk(buffer: UnsafeBufferPointer(buffer), owned: true) + precondition(compressedBytes.prefix(magic.count).elementsEqual(magic)) + let bytes = [UInt8](unsafeUninitializedCapacity: Int(decompressedSize)) { buffer, count in + count = compressedBytes.withUnsafeBufferPointer { compressedBuffer in + compression_decode_buffer(buffer.baseAddress!, buffer.count, compressedBuffer.baseAddress!, compressedBuffer.count, nil, COMPRESSION_LZMA) + } + precondition(count == Int(decompressedSize)) + } + return Chunk(bytes: bytes) } } remaining = remaining[fromOffset: Int(compressedSize)] @@ -300,9 +297,16 @@ public struct Unxip { return chunkStream } - func files(in chunkStream: ChunkStream) -> AsyncStream where ChunkStream.Element == Chunk { - AsyncStream(bufferingPolicy: .bufferingOldest(ProcessInfo.processInfo.activeProcessorCount)) { continuation in - Task { + func files(in chunkStream: ChunkStream) -> AsyncStream where ChunkStream.Element == Chunk { + let (stream, continuation) = AsyncStream.makeStream( + of: File.self, + bufferingPolicy: .bufferingOldest(ProcessInfo.processInfo.activeProcessorCount) + ) + let task = Task { + defer { + continuation.finish() + } + var iterator = chunkStream.makeAsyncIterator() var chunk = try! await iterator.next()! var position = 0 @@ -310,11 +314,11 @@ public struct Unxip { func read(size: Int) async -> [UInt8] { var result = [UInt8]() while result.count < size { - if position >= chunk.buffer.endIndex { + if position >= chunk.bytes.endIndex { chunk = try! await iterator.next()! position = 0 } - result.append(chunk.buffer[chunk.buffer.startIndex + position]) + result.append(chunk.bytes[chunk.bytes.startIndex + position]) position += 1 } return result @@ -338,30 +342,33 @@ public struct Unxip { let _ = await read(size: 11) // mtime let namesize = readOctal(from: await read(size: 6)) var filesize = readOctal(from: await read(size: 11)) - let name = String(cString: await read(size: namesize)) + var nameBytes = await read(size: namesize) + if nameBytes.last == 0 { + nameBytes.removeLast() + } + let name = String(decoding: nameBytes, as: UTF8.self) var file = File(dev: dev, ino: ino, mode: mode, name: name) while filesize > 0 { - if position >= chunk.buffer.endIndex { + if position >= chunk.bytes.endIndex { chunk = try! await iterator.next()! position = 0 } - let size = min(filesize, chunk.buffer.endIndex - position) - file.chunks.append(chunk) - file.data.append(UnsafeBufferPointer(rebasing: chunk.buffer[fromOffset: position, size: size])) + let size = min(filesize, chunk.bytes.endIndex - position) + file.data.append(Array(chunk.bytes[fromOffset: position, size: size])) filesize -= size position += size } guard file.name != "TRAILER!!!" else { - continuation.finish() return } await continuation.yieldWithBackoff(file) } - } } + continuation.onTermination = { _ in task.cancel() } + return stream } public func parseContent(_ content: UnsafeBufferPointer) async { @@ -454,25 +461,21 @@ public struct Unxip { return } - // pwritev requires the vector count to be positive - if file.data.count == 0 { - return - } - - var vector = file.data.map { - iovec(iov_base: UnsafeMutableRawPointer(mutating: $0.baseAddress), iov_len: $0.count) - } - let total = file.data.map(\.count).reduce(0, +) - var written = 0 - - repeat { - // TODO: handle partial writes smarter - written = pwritev(fd, &vector, CInt(vector.count), 0) - if written < 0 { - warn(-1, "writing chunk to") - break + var offset = 0 + for bytes in file.data { + var chunkOffset = 0 + while chunkOffset < bytes.count { + let written = bytes.withUnsafeBufferPointer { buffer in + pwrite(fd, buffer.baseAddress! + chunkOffset, buffer.count - chunkOffset, off_t(offset)) + } + if written < 0 { + warn(-1, "writing chunk to") + return + } + chunkOffset += written + offset += written } - } while written != total + } } ) default: diff --git a/Sources/XcodesKit/AppleSessionService.swift b/Sources/XcodesKit/AppleSessionService.swift index 6fe57668..7a7166b1 100644 --- a/Sources/XcodesKit/AppleSessionService.swift +++ b/Sources/XcodesKit/AppleSessionService.swift @@ -1,145 +1,47 @@ -import PromiseKit import Foundation -import AppleAPI - -public class AppleSessionService { - - private let xcodesUsername = "XCODES_USERNAME" - private let xcodesPassword = "XCODES_PASSWORD" - - var configuration: Configuration - - public init(configuration: Configuration) { - self.configuration = configuration - } - - private func findUsername() -> String? { - if let username = Current.shell.env(xcodesUsername) { - return username - } - else if let username = configuration.defaultUsername { - return username - } - return nil - } - - private 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 - } - - func validateADCSession(path: String) -> Promise { - return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid() - } - - func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise { - return firstly { () -> Promise in - return Current.network.validateSession() - } - // Don't have a valid session, so we'll need to log in - .recover { error -> Promise in - var possibleUsername = providedUsername ?? self.findUsername() - var hasPromptedForUsername = false - if possibleUsername == nil { - possibleUsername = Current.shell.readLine(prompt: "Apple ID: ") - hasPromptedForUsername = true - } - guard let username = possibleUsername else { throw Error.missingUsernameOrPassword } - - let passwordPrompt: String - if hasPromptedForUsername { - passwordPrompt = "Apple ID Password: " - } else { - // If the user wasn't prompted for their username, also explain which Apple ID password they need to enter - passwordPrompt = "Apple ID Password (\(username)): " - } - var possiblePassword = self.findPassword(withUsername: username) - if possiblePassword == nil || shouldPromptForPassword { - possiblePassword = Current.shell.readSecureLine(prompt: passwordPrompt) - } - guard let password = possiblePassword else { throw Error.missingUsernameOrPassword } - - return firstly { () -> Promise in - self.login(username, password: password) - } - .recover { error -> Promise in - Current.logging.log(error.legibleLocalizedDescription.red) - - if case Client.Error.invalidUsernameOrPassword = error { - Current.logging.log("Try entering your password again") - // Prompt for the password next time to avoid being stuck in a loop of using an incorrect XCODES_PASSWORD environment variable - return self.loginIfNeeded(withUsername: username, shouldPromptForPassword: true) - } - else { - return Promise(error: error) +import os +import XcodesLoginKit + +public typealias AppleSessionService = XcodesLoginKit.AppleSessionService + +public extension XcodesLoginKit.AppleSessionService { + init(configuration: Configuration) { + let configurationStorage = OSAllocatedUnfairLock(initialState: configuration) + self.init(dependencies: .init( + environmentValue: { Current.shell.env($0) }, + defaultUsername: { + configurationStorage.withLock { $0.defaultUsername } + }, + setDefaultUsername: { username in + try configurationStorage.withLock { + $0.defaultUsername = username + try $0.save() } - } - } - } - - 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() - } - } - } - - public func logout() -> Promise { - guard let username = findUsername() else { return Promise(error: Client.Error.notAuthenticated) } - - return Promise { seal in - // Remove cookies in the shared URLSession - AppleAPI.Current.network.session.reset { - seal.fulfill(()) - } - } - .done { - // Remove all keychain items - try Current.keychain.remove(username) - - // Set `defaultUsername` in Configuration to nil - self.configuration.defaultUsername = nil - try self.configuration.save() - } - } -} - -extension AppleSessionService { - enum Error: LocalizedError, Equatable { - case missingUsernameOrPassword - - public var errorDescription: String? { - switch self { - case .missingUsernameOrPassword: - return "Missing username or a password. Please try again." - } - } - + }, + keychainString: { try Current.keychain.getString($0) }, + keychainSet: { try Current.keychain.set($0, key: $1) }, + keychainRemove: { try Current.keychain.remove($0) }, + readLine: { Current.shell.readLine(prompt: $0) }, + readLongLine: { Current.shell.readLongLine(prompt: $0) }, + readSecureLine: { Current.shell.readSecureLine(prompt: $0) }, + validateSession: { try await Current.network.validateSessionAsync() }, + login: { accountName, password in + try await Current.network.loginAsync(accountName: accountName, password: password) + }, + checkIsFederated: { accountName in + try await Current.network.checkIsFederatedAsync(accountName: accountName) + }, + validateFederatedCallbackURL: { callbackURLString in + try await Current.network.validateFederatedCallbackURLAsync(callbackURLString) + }, + openURL: { url in + Process.launchedProcess(launchPath: "/usr/bin/open", arguments: [url.absoluteString]) + }, + signout: { await Current.network.signout() }, + loadData: { request in + try await Current.network.data(for: request) + }, + log: { Current.logging.log($0) } + )) } } diff --git a/Sources/XcodesKit/Aria2CError.swift b/Sources/XcodesKit/Aria2CError.swift deleted file mode 100644 index c6526266..00000000 --- a/Sources/XcodesKit/Aria2CError.swift +++ /dev/null @@ -1,125 +0,0 @@ -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/Sources/XcodesKit/Configuration.swift b/Sources/XcodesKit/Configuration.swift index 9446730f..5f586ac3 100644 --- a/Sources/XcodesKit/Configuration.swift +++ b/Sources/XcodesKit/Configuration.swift @@ -1,7 +1,8 @@ import Foundation import Path +import XcodesKit -public struct Configuration: Codable { +public struct Configuration: Codable, Sendable { public var defaultUsername: String? public init() { @@ -9,13 +10,27 @@ public struct Configuration: Codable { } 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) + guard let configuration = try configurationStore.load(from: Path.configurationFile) else { return } + self = configuration } 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) + try configurationStore.save(self, to: Path.configurationFile) + } + + private var configurationStore: CodableFileStore { + CodableFileStore( + contentsAtPath: { path in Current.files.contents(atPath: path) }, + createDirectory: { url, createIntermediates, attributes in + try Current.files.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates, + attributes: attributes + ) + }, + createFile: { path, data, attributes in + Current.files.createFile(atPath: path, contents: data, attributes: attributes) + } + ) } } diff --git a/Sources/XcodesKit/DataSource.swift b/Sources/XcodesKit/DataSource.swift index 4f01ccc5..e433ccfa 100644 --- a/Sources/XcodesKit/DataSource.swift +++ b/Sources/XcodesKit/DataSource.swift @@ -1,6 +1,3 @@ -import Foundation +import XcodesKit -public enum DataSource: String, CaseIterable { - case apple - case xcodeReleases -} +public typealias DataSource = XcodesKit.DataSource diff --git a/Sources/XcodesKit/DateFormatter+.swift b/Sources/XcodesKit/DateFormatter+.swift deleted file mode 100644 index a9eb59e1..00000000 --- a/Sources/XcodesKit/DateFormatter+.swift +++ /dev/null @@ -1,17 +0,0 @@ -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/Sources/XcodesKit/Downloader.swift b/Sources/XcodesKit/Downloader.swift index 9423bec8..d98eb967 100644 --- a/Sources/XcodesKit/Downloader.swift +++ b/Sources/XcodesKit/Downloader.swift @@ -1,9 +1,8 @@ -import PromiseKit import Foundation -import Path -import AppleAPI +@preconcurrency import Path +import XcodesKit -public enum Downloader { +public enum Downloader: Sendable { case urlSession case aria2(Path) @@ -15,7 +14,7 @@ public enum Downloader { self = .aria2(aria2Path) } - func download(url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { + func download(url: URL, to destination: Path, progressChanged: @escaping @Sendable (Progress) -> Void) async throws -> URL { switch self { case .urlSession: if Current.shell.isatty() { @@ -23,62 +22,102 @@ public enum Downloader { // Add 1 extra line as we are overwriting with download progress Current.logging.log("") } - return withUrlSession(url: url, to: destination, progressChanged: progressChanged) + return try await withUrlSession(url: url, to: destination, progressChanged: progressChanged) case .aria2(let aria2Path): if Current.shell.isatty() { Current.logging.log("Downloading with aria2 (\(aria2Path))".green) // Add 1 extra line as we are overwriting with download progress Current.logging.log("") } - return withAria(aria2Path: aria2Path, url: url, to: destination, progressChanged: progressChanged) + return try await withAria(aria2Path: aria2Path, url: url, to: destination, progressChanged: progressChanged) } } - private func withAria(aria2Path: Path, url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { - let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: url) ?? [] - return attemptRetryableTask(maximumRetryCount: 3) { - let (progress, promise) = Current.shell.downloadWithAria2( - aria2Path, - url, - destination, - cookies - ) - progressChanged(progress) - return promise.map { _ in destination.url } + var aria2Path: Path? { + if case let .aria2(path) = self { + return path } + return nil } - private func withUrlSession(url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise { - let resumeDataPath = destination.parent/(destination.basename() + ".resumedata") - let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string) + private func withAria(aria2Path: Path, url: URL, to destination: Path, progressChanged: @escaping @Sendable (Progress) -> Void) async throws -> URL { + try await archiveDownloadStrategyService(aria2Path: aria2Path).download( + url: url, + destination: destination, + downloader: .aria2, + resumeDataPath: resumeDataPath(for: destination), + progressChanged: progressChanged + ) + } - return attemptResumableTask(maximumRetryCount: 3) { resumeData in - let (progress, promise) = Current.network.downloadTask(with: url, - to: destination.url, - resumingWith: resumeData ?? persistedResumeData) - progressChanged(progress) - return promise.map { result in - /// If the operation is unauthorized, the download page redirects to https://developer.apple.com/unauthorized/ - /// with 200 status. After that the html page is downloaded as a xip and subsequent unxipping fails - guard result.response.url?.lastPathComponent != "unauthorized" else { - throw XcodeInstaller.Error.unauthorized - } - - return result.saveLocation - } - } - .tap { result in - self.persistOrCleanUpResumeData(at: resumeDataPath, for: result) + private func withUrlSession(url: URL, to destination: Path, progressChanged: @escaping @Sendable (Progress) -> Void) async throws -> URL { + try await archiveDownloadStrategyService().download( + url: url, + destination: destination, + downloader: .urlSession, + resumeDataPath: resumeDataPath(for: destination), + progressChanged: progressChanged + ) + } + + private func resumeDataPath(for destination: Path) -> Path { + destination.parent/(destination.basename() + ".resumedata") + } + + private static func shouldRetryDownloadError(_ error: Error) -> Bool { + if case XcodeInstaller.Error.unauthorized = error { + return false } + + return true } - private 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) + private var archiveDownloadService: ArchiveDownloadService { + ArchiveDownloadService( + aria2Download: Current.shell.downloadWithAria2, + urlSessionDownload: { url, destination, resumeData in + Current.network.downloadTask( + with: URLRequest(url: url), + to: destination, + resumingWith: resumeData + ) + }, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + createFile: { path, data in + Current.files.createFile(atPath: path, contents: data) + }, + removeItem: { try Current.files.removeItem(at: $0) }, + shouldRetry: { Self.shouldRetryDownloadError($0) }, + validateResponse: { response in + try ArchiveDownloadService.validateDeveloperDownloadResponse( + response, + unauthorizedError: { XcodeInstaller.Error.unauthorized } + ) + } + ) + } + + private func archiveDownloadStrategyService(aria2Path: Path? = nil) -> ArchiveDownloadStrategyService { + ArchiveDownloadStrategyService( + archiveDownloadService: archiveDownloadService, + aria2Path: { + guard let aria2Path else { + throw XcodesKitError("aria2 path is unavailable.") + } + return aria2Path + }, + cookiesForURL: { Current.network.session.configuration.httpCookieStorage?.cookies(for: $0) ?? [] } + ) + } +} + +extension XcodeArchiveDownloader { + init(_ downloader: Downloader) { + switch downloader { + case .aria2: + self = .aria2 + case .urlSession: + self = .urlSession } } } diff --git a/Sources/XcodesKit/Entry+.swift b/Sources/XcodesKit/Entry+.swift deleted file mode 100644 index bdf85f73..00000000 --- a/Sources/XcodesKit/Entry+.swift +++ /dev/null @@ -1,20 +0,0 @@ -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/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 393e19c9..a443f7a1 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -1,9 +1,9 @@ import Foundation -import PromiseKit -import PMKFoundation -import Path -import AppleAPI -import KeychainAccess +@preconcurrency import Path +@preconcurrency import KeychainAccess +import XcodesKit +import XcodesLoginKit +import os /** Lightweight dependency injection using global mutable state :P @@ -12,59 +12,95 @@ import KeychainAccess - SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable - SeeAlso: https://vimeo.com/291588126 */ -public struct Environment { +public struct Environment: Sendable { public var shell = Shell() public var files = Files() public var network = Network() public var logging = Logging() public var keychain = Keychain() - public var fastlaneCookieParser = FastlaneCookieParser() + + public init() { + self.shell = Shell() + self.files = Files() + self.network = Network() + self.logging = Logging() + self.keychain = Keychain() + } + + public init(shell: Shell, files: Files, network: Network, logging: Logging, keychain: Keychain) { + self.shell = shell + self.files = files + self.network = network + self.logging = logging + self.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 mountDmg: (URL) -> Promise = { Process.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path) } - public var unmountDmg: (URL) -> Promise = { Process.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) } - public var expandPkg: (URL, URL) -> Promise = { Process.run(Path.root.usr.sbin.join("pkgutil"), "--expand", $0.path, $1.path) } - public var createPkg: (URL, URL) -> Promise = { Process.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path) } - public var installPkg: (URL, String) -> Promise = { Process.run(Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) } - public var installRuntimeImage: (URL) -> Promise = { Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $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 installedRuntimes: () -> Promise = { Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j") } - - 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 } +private let currentEnvironment = CurrentEnvironmentStorage(Environment()) + +public var Current: Environment { + get { currentEnvironment.value } + set { currentEnvironment.value = newValue } +} + +private final class CurrentEnvironmentStorage: Sendable { + private let environment: OSAllocatedUnfairLock + + var value: Environment { + get { + environment.withLock { $0 } } - .recover { _ -> Promise in - return passwordInput().map(Optional.init) + set { + environment.withLock { $0 = newValue } } } - public func authenticateSudoerIfNecessary(passwordInput: @escaping () -> Promise) -> Promise { - authenticateSudoerIfNecessary(passwordInput) + + init(_ environment: Environment) { + self.environment = OSAllocatedUnfairLock(initialState: environment) } +} - 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 struct Shell: Sendable { + private static let shared = XcodesShell() + + public var unxip = Shell.shared.unxip + public var mountDmg = Shell.shared.mountDmg + public var unmountDmg = Shell.shared.unmountDmg + public var expandPkg = Shell.shared.expandPkg + public var createPkg = Shell.shared.createPkg + public var installPkg = Shell.shared.installPkg + public var installRuntimeImage = Shell.shared.installRuntimeImage + public var spctlAssess = Shell.shared.spctlAssess + public var codesignVerify = Shell.shared.codesignVerify + public var devToolsSecurityEnable: @Sendable (String?) async throws -> ProcessOutput = { try await Process.sudoAsync(password: $0, Path.root.usr.sbin.DevToolsSecurity, "-enable") } + public var addStaffToDevelopersGroup: @Sendable (String?) async throws -> ProcessOutput = { try await Process.sudoAsync(password: $0, Path.root.usr.sbin.dseditgroup, "-o", "edit", "-t", "group", "-a", "staff", "_developer") } + public var acceptXcodeLicense: @Sendable (InstalledXcode, String?) async throws -> ProcessOutput = { try await Process.sudoAsync(password: $1, $0.path.join("/Contents/Developer/usr/bin/xcodebuild"), "-license", "accept") } + public var runFirstLaunch: @Sendable (InstalledXcode, String?) async throws -> ProcessOutput = { try await Process.sudoAsync(password: $1, $0.path.join("/Contents/Developer/usr/bin/xcodebuild"),"-runFirstLaunch") } + public var buildVersion = Shell.shared.buildVersion + public var xcodeBuildVersion = Shell.shared.xcodeBuildVersion + public var archs = Shell.shared.archs + public var getUserCacheDir = Shell.shared.getUserCacheDir + public var touchInstallCheck = Shell.shared.touchInstallCheck + public var installedRuntimes = Shell.shared.installedRuntimes + + public var validateSudoAuthentication: @Sendable () async throws -> ProcessOutput = { try await Process.runAsync(Path.root.usr.bin.sudo, "-nv") } + public func authenticateSudoerIfNecessaryAsync(passwordInput: @escaping @Sendable () async throws -> String) async throws -> String? { + do { + _ = try await validateSudoAuthentication() + return nil + } catch { + return try await passwordInput() + } } - public var isRoot: () -> Bool = { NSUserName() == "root" } + + public var xcodeSelectPrintPath = Shell.shared.xcodeSelectPrintPath + + public var xcodeSelectSwitch = Shell.shared.xcodeSelectSwitch + public var isRoot: @Sendable () -> Bool = { NSUserName() == "root" } + public var machineArchitecture: @Sendable () -> String? = { HostHardware.currentMachineHardwareName() } /// Returns the path of an executable within the directories in the PATH environment variable. - public var findExecutable: (_ executableName: String) -> Path? = { executableName in + public var findExecutable: @Sendable (_ executableName: String) -> Path? = { executableName in guard let path = ProcessInfo.processInfo.environment["PATH"] else { return nil } for directory in path.components(separatedBy: ":") { @@ -76,97 +112,17 @@ public struct Shell { return nil } - public var downloadWithAria2: (Path, URL, Path, [HTTPCookie]) -> (Progress, Promise) = { aria2Path, url, destination, cookies in - precondition(Thread.isMainThread, "Aria must be called on the main queue") - 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) - - // We hold on to the unauthorized status - // So that we can properly throw error from inside the promise - var unauthorized = false - - 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) - - /// If the operation is unauthorized, the download page redirects to https://developer.apple.com/unauthorized/ - /// with 200 status. After that the html page is downloaded as a xip and subsequent unxipping fails - if !unauthorized && string.contains("Redirecting to https://developer.apple.com/unauthorized/") { - unauthorized = true - } - - 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: "")) - } - } - guard !unauthorized else { - return seal.reject(XcodeInstaller.Error.unauthorized) - } - seal.fulfill(()) - } - } - - return (progress, promise) + public var downloadWithAria2: @Sendable (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream = { aria2Path, url, destination, cookies in + Aria2DownloadService().download( + aria2Path: aria2Path, + url: url, + destination: destination, + cookies: cookies, + unauthorizedError: { XcodeInstaller.Error.unauthorized } + ) } - public var readLine: (String) -> String? = { prompt in + public var readLine: @Sendable (String) -> String? = { prompt in print(prompt, terminator: "") return Swift.readLine() } @@ -174,7 +130,26 @@ public struct Shell { readLine(prompt) } - public var readSecureLine: (String, Int) -> String? = { prompt, maximumLength in + public var readLongLine: @Sendable (String) -> String? = { prompt in + print(prompt, terminator: "") + fflush(stdout) + return withRawTerminalMode(echo: true) { + var result = Data() + var byte: UInt8 = 0 + let fd = fileno(stdin) + while read(fd, &byte, 1) == 1 { + if byte == 0x0A || byte == 0x0D { break } + result.append(byte) + } + print("") + return String(data: result, encoding: .utf8) + } + } + public func readLongLine(prompt: String) -> String? { + readLongLine(prompt) + } + + public var readSecureLine: @Sendable (String, Int) -> String? = { prompt, maximumLength in let buffer = UnsafeMutablePointer.allocate(capacity: maximumLength) buffer.initialize(repeating: 0, count: maximumLength) defer { @@ -188,7 +163,7 @@ public struct Shell { return nil } - return String(validatingUTF8: passwordData) + return String(validatingCString: passwordData) } /** Like `readLine()`, but doesn't echo the user's input to the screen. @@ -206,124 +181,226 @@ public struct Shell { readSecureLine(prompt, maximumLength) } - public var env: (String) -> String? = { key in + public var env: @Sendable (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 var exit: @Sendable (Int32) -> Void = { Darwin.exit($0) } + + public var isatty: @Sendable () -> Bool = { Foundation.isatty(fileno(stdout)) != 0 } +} + +private func withRawTerminalMode(echo: Bool, _ body: () -> T) -> T { + let fd = fileno(stdin) + var original = termios() + tcgetattr(fd, &original) + + var raw = original + raw.c_lflag &= ~UInt(ICANON) + if echo { + raw.c_lflag |= UInt(ECHO) + } else { + raw.c_lflag &= ~UInt(ECHO) + } + raw.c_cc.4 = 1 + raw.c_cc.5 = 0 + tcsetattr(fd, TCSANOW, &raw) + defer { tcsetattr(fd, TCSANOW, &original) } - public var isatty: () -> Bool = { Foundation.isatty(fileno(stdout)) != 0 } + return body() } -public struct Files { - public var fileExistsAtPath: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } +public struct Files: Sendable { + private static let sharedShell = XcodesShell() + + public var fileExistsAtPath: @Sendable (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } public func fileExists(atPath path: String) -> Bool { return fileExistsAtPath(path) } - public var attributesOfItemAtPath: (String) throws -> [FileAttributeKey: Any] = { try FileManager.default.attributesOfItem(atPath: $0) } + public var attributesOfItemAtPath: @Sendable (String) throws -> [FileAttributeKey: Any] = { try FileManager.default.attributesOfItem(atPath: $0) } public func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { return try attributesOfItemAtPath(path) } - public var moveItem: (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) } + public var moveItem: @Sendable (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 var contentsAtPath: @Sendable (String) -> Data? = { FileManager.default.contents(atPath: $0) } public func contents(atPath path: String) -> Data? { return contentsAtPath(path) } - public var write: (Data, URL) throws -> Void = { try $0.write(to: $1) } + public var write: @Sendable (Data, URL) throws -> Void = { try $0.write(to: $1) } public func write(_ data: Data, to url: URL) throws { try write(data, url) } - public var removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) } + public var removeItem: @Sendable (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) } + public var trashItem: @Sendable (URL) throws -> URL = { try FileManager.default.xcodesTrashItem(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) } + public var createFile: @Sendable (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 var createDirectory: @Sendable (URL, Bool, [FileAttributeKey : Any]?) throws -> Void = { url, createIntermediates, attributes in + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: createIntermediates, attributes: attributes) + } public func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { try createDirectory(url, createIntermediates, attributes) } - public var contentsOfDirectory: (URL) throws -> [URL] = { try FileManager.default.contentsOfDirectory(at: $0, includingPropertiesForKeys: nil, options: []) } + public var contentsOfDirectory: @Sendable (URL) throws -> [URL] = { try FileManager.default.contentsOfDirectory(at: $0, includingPropertiesForKeys: nil, options: []) } - public var installedXcodes: (Path) -> [InstalledXcode] = { directory in - return ((try? directory.ls()) ?? []) - .filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" } - .map { $0.path } - .compactMap(InstalledXcode.init) + public var installedXcodes: @Sendable (Path) -> [InstalledXcode] = { directory in + InstalledXcodeDiscoveryService( + listDirectory: { $0.ls() }, + contentsAtPath: { path in FileManager.default.contents(atPath: path) }, + loadArchitectures: Files.sharedShell.archs + ).installedXcodes(in: directory) } } -public struct Network { - private static let client = AppleAPI.Client() +public struct Network: Sendable { + public private(set) var loginClient: XcodesLoginKit.Client + + public var session: URLSession { + get { loginClient.urlSession } + set { + let loginClient = XcodesLoginKit.Client(urlSession: newValue) + self.loginClient = loginClient + loadData = { try await loginClient.urlSession.data(for: $0) } + downloadTask = { loginClient.urlSession.downloadTask(with: $0, to: $1, resumingWith: $2) } + validateSession = { _ = try await loginClient.validateSession() } + login = { accountName, password in + _ = try await loginClient.srpLogin(accountName: accountName, password: password) + } + checkIsFederated = { accountName in + try await loginClient.checkIsFederated(accountName: accountName) + } + validateFederatedCallbackURL = { callbackURLString in + _ = try await loginClient.validateFederatedCallbackURLString(callbackURLString) + } + signoutAction = { loginClient.signout() } + } + } + + public var loadData: @Sendable (URLRequest) async throws -> (data: Data, response: URLResponse) + + public func data(for request: URLRequest) async throws -> (data: Data, response: URLResponse) { + try await loadData(request) + } + + public var downloadTask: @Sendable (URLRequest, URL, Data?) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>) + + public func downloadTask(with request: URLRequest, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, task: Task<(saveLocation: URL, response: URLResponse), Error>) { + return downloadTask(request, saveLocation, resumeData) + } + + public var validateSession: @Sendable () async throws -> Void + + public func validateSessionAsync() async throws { + try await validateSession() + } + + public var login: @Sendable (String, String) async throws -> Void + + public func loginAsync(accountName: String, password: String) async throws { + try await login(accountName, password) + } + + public var checkIsFederated: @Sendable (String) async throws -> FederationResponse - 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 func checkIsFederatedAsync(accountName: String) async throws -> FederationResponse { + try await checkIsFederated(accountName) } - public var downloadTask: (URLRequestConvertible, URL, Data?) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) } + public var validateFederatedCallbackURL: @Sendable (String) async throws -> Void - 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 func validateFederatedCallbackURLAsync(_ callbackURLString: String) async throws { + try await validateFederatedCallbackURL(callbackURLString) } - public var validateSession: () -> Promise = client.validateSession + public var signoutAction: @Sendable () -> Void + + public func signout() async { + signoutAction() + } - public var login: (String, String) -> Promise = { client.srpLogin(accountName: $0, password: $1) } - public func login(accountName: String, password: String) -> Promise { - login(accountName, password) + public init( + session: URLSession? = nil, + loadData: (@Sendable (URLRequest) async throws -> (data: Data, response: URLResponse))? = nil, + downloadTask: (@Sendable (URLRequest, URL, Data?) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>))? = nil, + validateSession: (@Sendable () async throws -> Void)? = nil, + login: (@Sendable (String, String) async throws -> Void)? = nil, + checkIsFederated: (@Sendable (String) async throws -> FederationResponse)? = nil, + validateFederatedCallbackURL: (@Sendable (String) async throws -> Void)? = nil, + signoutAction: (@Sendable () -> Void)? = nil + ) { + let loginClient: XcodesLoginKit.Client + if let session { + loginClient = XcodesLoginKit.Client(urlSession: session) + } else { + loginClient = XcodesLoginKit.Client() + } + self.loginClient = loginClient + self.loadData = loadData ?? { try await loginClient.urlSession.data(for: $0) } + self.downloadTask = downloadTask ?? { loginClient.urlSession.downloadTask(with: $0, to: $1, resumingWith: $2) } + self.validateSession = validateSession ?? { _ = try await loginClient.validateSession() } + self.login = login ?? { accountName, password in + _ = try await loginClient.srpLogin(accountName: accountName, password: password) + } + self.checkIsFederated = checkIsFederated ?? { accountName in + try await loginClient.checkIsFederated(accountName: accountName) + } + self.validateFederatedCallbackURL = validateFederatedCallbackURL ?? { callbackURLString in + _ = try await loginClient.validateFederatedCallbackURLString(callbackURLString) + } + self.signoutAction = signoutAction ?? { loginClient.signout() } } } -public struct Logging { - public var log: (String) -> Void = { print($0) } +public struct Logging: Sendable { + public var log: @Sendable (String) -> Void = { print($0) } } -public struct Keychain { +public struct Keychain: Sendable { private static let keychain = KeychainAccess.Keychain(service: "com.robotsandpencils.xcodes") - public var getString: (String) throws -> String? = keychain.getString(_:) + public var getString: @Sendable (String) throws -> String? = { try keychain.getString($0) } public func getString(_ key: String) throws -> String? { try getString(key) } - public var set: (String, String) throws -> Void = keychain.set(_:key:) + public var set: @Sendable (String, String) throws -> Void = { try keychain.set($0, key: $1) } public func set(_ value: String, key: String) throws { try set(value, key) } - public var remove: (String) throws -> Void = keychain.remove(_:) + public var remove: @Sendable (String) throws -> Void = { try keychain.remove($0) } public func remove(_ key: String) throws -> Void { try remove(key) } diff --git a/Sources/XcodesKit/FastlaneCookieParser.swift b/Sources/XcodesKit/FastlaneCookieParser.swift deleted file mode 100644 index 65f95291..00000000 --- a/Sources/XcodesKit/FastlaneCookieParser.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import Yams - -public class FastlaneCookieParser { - public func parse(cookieString: String) throws -> [HTTPCookie] { - let fixed = cookieString.replacingOccurrences(of: "\\n", with: "\n") - let cookies = try YAMLDecoder().decode([FastlaneCookie].self, from: fixed) - return cookies.compactMap(\.httpCookie) - } -} - -struct FastlaneCookie: Decodable { - - enum CodingKeys: String, CodingKey { - case name - case value - case domain - case forDomain = "for_domain" - case path - case secure - case expires - case maxAge = "max_age" - case createdAt = "created_at" - case accessedAt = "accessed_at" - } - - let name: String - let value: String - let domain: String - let forDomain: Bool - let path: String - let secure: Bool - let expires: Date? - let maxAge: Int? - let createdAt: Date - let accessedAt: Date -} - -protocol HTTPCookieConvertible { - var httpCookie: HTTPCookie? { get } -} - -extension FastlaneCookie: HTTPCookieConvertible { - var httpCookie: HTTPCookie? { - - var properties: [HTTPCookiePropertyKey: Any] = [ - .name: self.name, - .value: self.value, - .domain: self.domain, - .path: self.path, - .secure: self.secure, - ] - - if forDomain { - properties[.domain] = ".\(self.domain)" - } else { - properties[.domain] = "\(self.domain)" - } - - if let expires = self.expires { - properties[.expires] = expires - } - - if let maxAge = self.maxAge { - properties[.maximumAge] = maxAge - } - - return HTTPCookie(properties: properties) - } -} diff --git a/Sources/XcodesKit/FastlaneSessionManager.swift b/Sources/XcodesKit/FastlaneSessionManager.swift index e4c2de79..6d80b1c5 100644 --- a/Sources/XcodesKit/FastlaneSessionManager.swift +++ b/Sources/XcodesKit/FastlaneSessionManager.swift @@ -1,60 +1,27 @@ import Foundation -import AppleAPI import Path +import XcodesLoginKit -public class FastlaneSessionManager { - - public enum Constants { - public static let fastlaneSessionEnvVarName = "FASTLANE_SESSION" - public static let fastlaneSpaceshipDir = Path.environmentHome.url - .appendingPathComponent(".fastlane") - .appendingPathComponent("spaceship") - } +public final class FastlaneSessionManager: Sendable { + public typealias Constants = XcodesLoginKit.FastlaneSessionLoader.Constants public init() {} - public func setupFastlaneAuth(fastlaneUser: String) { - // Use ephemeral session so that cookies don't conflict with normal usage - AppleAPI.Current.network.session = URLSession(configuration: .ephemeral) - switch fastlaneUser { - case Constants.fastlaneSessionEnvVarName: - importFastlaneCookiesFromEnv() - default: - importFastlaneCookiesFromFile(fastlaneUser: fastlaneUser) - } - } + private let loginKitManager = XcodesLoginKit.FastlaneSessionLoader() - private func importFastlaneCookiesFromEnv() { - guard let cookieString = Current.shell.env(Constants.fastlaneSessionEnvVarName) else { - Current.logging.log("\(Constants.fastlaneSessionEnvVarName) not set".red) - return - } - do { - let cookies = try Current.fastlaneCookieParser.parse(cookieString: cookieString) - cookies.forEach(AppleAPI.Current.network.session.configuration.httpCookieStorage!.setCookie) - } catch { - Current.logging.log("Failed to parse cookies from \(Constants.fastlaneSessionEnvVarName)".red) - return - } - } - - private func importFastlaneCookiesFromFile(fastlaneUser: String) { - let cookieFilePath = Constants - .fastlaneSpaceshipDir - .appendingPathComponent(fastlaneUser) - .appendingPathComponent("cookie") - guard - let cookieString = try? String(contentsOf: cookieFilePath) - else { - Current.logging.log("Could not read cookies from \(cookieFilePath)".red) - return - } + public func setupFastlaneAuth(fastlaneUser: String) { do { - let cookies = try Current.fastlaneCookieParser.parse(cookieString: cookieString) - cookies.forEach(AppleAPI.Current.network.session.configuration.httpCookieStorage!.setCookie) + Current.network.session = try loginKitManager.session( + fastlaneUser: fastlaneUser, + environmentValue: Current.shell.env + ) + } catch XcodesLoginKit.FastlaneSessionLoader.Error.missingEnvironmentVariable(let variableName) { + Current.logging.log("\(variableName) not set".red) } catch { - Current.logging.log("Failed to parse cookies from \(cookieFilePath)".red) - return + let source = fastlaneUser == Constants.fastlaneSessionEnvVarName + ? Constants.fastlaneSessionEnvVarName + : XcodesLoginKit.FastlaneSessionLoader.cookieFileURL(fastlaneUser: fastlaneUser).path + Current.logging.log("Failed to parse cookies from \(source)".red) } } } diff --git a/Sources/XcodesKit/FileManager+.swift b/Sources/XcodesKit/FileManager+.swift deleted file mode 100644 index 12c96e16..00000000 --- a/Sources/XcodesKit/FileManager+.swift +++ /dev/null @@ -1,17 +0,0 @@ -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/Sources/XcodesKit/Foundation.swift b/Sources/XcodesKit/Foundation.swift deleted file mode 100644 index 2b80acbf..00000000 --- a/Sources/XcodesKit/Foundation.swift +++ /dev/null @@ -1,39 +0,0 @@ -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] }) - } -} - -extension NSRegularExpression { - func firstString(in string: String, options: NSRegularExpression.MatchingOptions = []) -> String? { - let range = NSRange(location: 0, length: string.utf16.count) - guard let firstMatch = firstMatch(in: string, options: options, range: range), - let resultRange = Range(firstMatch.range, in: string) else { - return nil - } - return String(string[resultRange]) - } -} diff --git a/Sources/XcodesKit/Migration.swift b/Sources/XcodesKit/Migration.swift index 3c7460af..18b35804 100644 --- a/Sources/XcodesKit/Migration.swift +++ b/Sources/XcodesKit/Migration.swift @@ -1,17 +1,27 @@ import Path +import XcodesKit /// 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") - } + let migrationService = ApplicationSupportMigrationService( + fileExists: { path in Current.files.fileExists(atPath: path) }, + moveItem: { source, destination in + try Current.files.moveItem(at: source, to: destination) + }, + removeItem: { url in try Current.files.removeItem(at: url) } + ) + + switch migrationService.migrate( + oldSupportPath: Path.oldXcodesApplicationSupport, + newSupportPath: Path.xcodesApplicationSupport + ) { + case .removedOldSupportFiles: + Current.logging.log("Removing old support files...") + Current.logging.log("Done") + case .migratedOldSupportFiles: + Current.logging.log("Migrating old support files...") + Current.logging.log("Done") + case .noMigrationNeeded: + break } } diff --git a/Sources/XcodesKit/Models+FirstWithVersion.swift b/Sources/XcodesKit/Models+FirstWithVersion.swift deleted file mode 100644 index e18bb396..00000000 --- a/Sources/XcodesKit/Models+FirstWithVersion.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import Version - -/// Returns the first XcodeType that unambiguously has the same version as `version`. -/// -/// If there's an equivalent match that takes prerelease identifiers into account, that's returned. -/// Otherwise, if a version without prerelease or build metadata identifiers is provided, and there's a single match based on only the major, minor and patch numbers, that's returned. -/// If there are multiple matches, or no matches, nil is returned. -public func findXcode(version: Version, in xcodes: [XcodeType], versionKeyPath: KeyPath) -> XcodeType? { - // Look for the equivalent provided version first - if let equivalentXcode = xcodes.first(where: { $0[keyPath: versionKeyPath].isEquivalent(to: version) }) { - return equivalentXcode - } - // If a version without prerelease or build identifiers is provided, then ignore all identifiers this time. - // There must be exactly one match. - else if version.prereleaseIdentifiers.isEmpty && version.buildMetadataIdentifiers.isEmpty, - xcodes.filter({ $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) }).count == 1 { - let matchedXcode = xcodes.first(where: { $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) })! - return matchedXcode - } else { - return nil - } -} - -public extension Array where Element == Xcode { - /// Returns the first Xcode that unambiguously has the same version as `version`. - /// - /// If there's an exact match that takes prerelease identifiers into account, that's returned. - /// Otherwise, if a version without prerelease or build metadata identifiers is provided, and there's a single match based on only the major, minor and patch numbers, that's returned. - /// If there are multiple matches, or no matches, nil is returned. - func first(withVersion version: Version) -> Xcode? { - findXcode(version: version, in: self, versionKeyPath: \.version) - } -} - -public extension Array where Element == InstalledXcode { - /// Returns the first InstalledXcode that unambiguously has the same version as `version`. - /// - /// If there's an exact match that takes prerelease identifiers into account, that's returned. - /// Otherwise, if a version without prerelease or build metadata identifiers is provided, and there's a single match based on only the major, minor and patch numbers, that's returned. - /// If there are multiple matches, or no matches, nil is returned. - func first(withVersion version: Version) -> InstalledXcode? { - findXcode(version: version, in: self, versionKeyPath: \.version) - } -} - -extension Version { - func isEqualWithoutAllIdentifiers(to other: Version) -> Bool { - return major == other.major && - minor == other.minor && - patch == other.patch - } -} diff --git a/Sources/XcodesKit/Models+Runtimes.swift b/Sources/XcodesKit/Models+Runtimes.swift deleted file mode 100644 index 7ae89030..00000000 --- a/Sources/XcodesKit/Models+Runtimes.swift +++ /dev/null @@ -1,157 +0,0 @@ -import Foundation - -struct DownloadableRuntimesResponse: Decodable { - let sdkToSimulatorMappings: [SDKToSimulatorMapping] - let sdkToSeedMappings: [SDKToSeedMapping] - let refreshInterval: Int - let downloadables: [DownloadableRuntime] - let version: String -} - -public struct DownloadableRuntime: Decodable { - let category: Category - let simulatorVersion: SimulatorVersion - let source: String? - let architectures: [String]? - let dictionaryVersion: Int - let contentType: ContentType - let platform: Platform - let identifier: String - let version: String - let fileSize: Int - let hostRequirements: HostRequirements? - let name: String - let authentication: Authentication? - - var betaNumber: Int? { - enum Regex { static let shared = try! NSRegularExpression(pattern: "b[0-9]+") } - guard var foundString = Regex.shared.firstString(in: identifier) else { return nil } - foundString.removeFirst() - return Int(foundString)! - } - - var completeVersion: String { - makeVersion(for: simulatorVersion.version, betaNumber: betaNumber) - } - - var visibleIdentifier: String { - return platform.shortName + " " + completeVersion + (architectures != nil ? " \(architectures?.joined(separator: "|") ?? "")" : "") - } -} - -func makeVersion(for osVersion: String, betaNumber: Int?) -> String { - let betaSuffix = betaNumber.flatMap { "-beta\($0)" } ?? "" - return osVersion + betaSuffix -} - -struct SDKToSeedMapping: Decodable { - let buildUpdate: String - let platform: DownloadableRuntime.Platform - let seedNumber: Int -} - -struct SDKToSimulatorMapping: Decodable { - let sdkBuildUpdate: String - let simulatorBuildUpdate: String - let sdkIdentifier: String - let downloadableIdentifiers: [String]? -} - -extension DownloadableRuntime { - struct SimulatorVersion: Decodable { - let buildUpdate: String - let version: String - } - - struct HostRequirements: Decodable { - let maxHostVersion: String? - let excludedHostArchitectures: [String]? - let minHostVersion: String? - let minXcodeVersion: String? - } - - enum Authentication: String, Decodable { - case virtual = "virtual" - } - - enum Category: String, Decodable { - case simulator = "simulator" - } - - enum ContentType: String, Decodable { - case diskImage = "diskImage" - case package = "package" - case cryptexDiskImage = "cryptexDiskImage" - case patchableCryptexDiskImage = "patchableCryptexDiskImage" - } - - enum Platform: String, Decodable { - case iOS = "com.apple.platform.iphoneos" - case macOS = "com.apple.platform.macosx" - case watchOS = "com.apple.platform.watchos" - case tvOS = "com.apple.platform.appletvos" - case visionOS = "com.apple.platform.xros" - - var order: Int { - switch self { - case .iOS: return 1 - case .macOS: return 2 - case .watchOS: return 3 - case .tvOS: return 4 - case .visionOS: return 5 - } - } - - var shortName: String { - switch self { - case .iOS: return "iOS" - case .macOS: return "macOS" - case .watchOS: return "watchOS" - case .tvOS: return "tvOS" - case .visionOS: return "visionOS" - } - } - } -} - -public struct InstalledRuntime: Decodable { - let build: String - let deletable: Bool - let identifier: UUID - let kind: Kind - let lastUsedAt: Date? - let path: String - let platformIdentifier: Platform - let runtimeBundlePath: String - let runtimeIdentifier: String - let signatureState: String - let state: String - let version: String - let sizeBytes: Int? -} - -extension InstalledRuntime { - enum Kind: String, Decodable { - case bundled = "Bundled with Xcode" - case cryptexDiskImage = "Cryptex Disk Image" - case diskImage = "Disk Image" - case legacyDownload = "Legacy Download" - case patchableCryptexDiskImage = "Patchable Cryptex Disk Image" - } - - enum Platform: String, Decodable { - case tvOS = "com.apple.platform.appletvsimulator" - case iOS = "com.apple.platform.iphonesimulator" - case watchOS = "com.apple.platform.watchsimulator" - case visionOS = "com.apple.platform.xrsimulator" - - var asPlatformOS: DownloadableRuntime.Platform { - switch self { - case .watchOS: return .watchOS - case .iOS: return .iOS - case .tvOS: return .tvOS - case .visionOS: return .visionOS - } - } - } -} diff --git a/Sources/XcodesKit/Models.swift b/Sources/XcodesKit/Models.swift index 4a388314..217041c0 100644 --- a/Sources/XcodesKit/Models.swift +++ b/Sources/XcodesKit/Models.swift @@ -1,97 +1,7 @@ import Foundation import Path import Version +import XcodesKit -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 - - init(path: Path, version: Version) { - self.path = path - self.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, Equatable { - public let version: Version - public let url: URL - public let filename: String - public let releaseDate: Date? - - public var downloadPath: String { - return url.path - } - - 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" - } -} +public typealias InstalledXcode = XcodesKit.InstalledXcode +public typealias Xcode = XcodesKit.AvailableXcode diff --git a/Sources/XcodesKit/Path+.swift b/Sources/XcodesKit/Path+.swift index eba6dd73..d8014744 100644 --- a/Sources/XcodesKit/Path+.swift +++ b/Sources/XcodesKit/Path+.swift @@ -1,23 +1,18 @@ import Path import Foundation +import XcodesKit extension Path { // Get Home even if we are running as root - static let environmentHome = ProcessInfo.processInfo.environment["HOME"].flatMap(Path.init) ?? .home + static let environmentHome = XcodesPathResolver.cliHome() static let environmentApplicationSupport = environmentHome/"Library/Application Support" static let environmentCaches = environmentHome/"Library/Caches" - public static let environmentDownloads = environmentHome/"Downloads" + public static let environmentDownloads = XcodesPathResolver.cliDownloads(home: environmentHome) - static let oldXcodesApplicationSupport = environmentApplicationSupport/"ca.brandonevans.xcodes" - static let xcodesApplicationSupport = environmentApplicationSupport/"com.robotsandpencils.xcodes" - static let xcodesCaches = environmentCaches/"com.robotsandpencils.xcodes" - static let cacheFile = xcodesApplicationSupport/"available-xcodes.json" - static let configurationFile = xcodesApplicationSupport/"configuration.json" - - @discardableResult - func setCurrentUserAsOwner() -> Path { - let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName() - try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string) - return self - } + static let oldXcodesApplicationSupport = XcodesPathResolver.cliOldApplicationSupport(home: environmentHome) + static let xcodesApplicationSupport = XcodesPathResolver.cliApplicationSupport(home: environmentHome) + static let xcodesCaches = XcodesPathResolver.cliCaches(home: environmentHome) + static let cacheFile = XcodesPathResolver.cliAvailableXcodesCacheFile(applicationSupport: xcodesApplicationSupport) + static let runtimeCacheFile = XcodesPathResolver.downloadableRuntimesCacheFile(in: xcodesApplicationSupport) + static let configurationFile = XcodesPathResolver.cliConfigurationFile(applicationSupport: xcodesApplicationSupport) } diff --git a/Sources/XcodesKit/Process.swift b/Sources/XcodesKit/Process.swift index dd792173..ac9a0b24 100644 --- a/Sources/XcodesKit/Process.swift +++ b/Sources/XcodesKit/Process.swift @@ -1,41 +1,27 @@ import Foundation -import PromiseKit -import PMKFoundation import Path +import XcodesKit -public typealias ProcessOutput = (status: Int32, out: String, err: String) +public typealias ProcessOutput = XcodesKit.ProcessOutput +public typealias ProcessExecutionError = XcodesKit.ProcessExecutionError extension Process { @discardableResult - static func sudo(password: String? = nil, _ executable: Path, workingDirectory: URL? = nil, _ arguments: String...) -> Promise { + static func sudoAsync(password: String? = nil, _ executable: P, workingDirectory: URL? = nil, _ arguments: String...) async throws -> ProcessOutput { 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) + } + return try await runAsync(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) + static func runAsync(_ executable: P, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { + try await runAsync(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) - } + static func runAsync(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { + try await XcodesProcess.run(executable, workingDirectory: workingDirectory, input: input, arguments) } } diff --git a/Sources/XcodesKit/Promise+.swift b/Sources/XcodesKit/Promise+.swift deleted file mode 100644 index b6667e6f..00000000 --- a/Sources/XcodesKit/Promise+.swift +++ /dev/null @@ -1,51 +0,0 @@ -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 } - - // Don't retry unauthorized errors because it won't change the outcome - if case XcodeInstaller.Error.unauthorized = error { - 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 } - - // Don't retry unauthorized errors because it won't change the outcome - if case XcodeInstaller.Error.unauthorized = error { - throw error - } - - return after(delayBeforeRetry).then(on: nil) { attempt() } - } - } - return attempt() -} diff --git a/Sources/XcodesKit/PromiseKit+Async.swift b/Sources/XcodesKit/PromiseKit+Async.swift deleted file mode 100644 index a4b71966..00000000 --- a/Sources/XcodesKit/PromiseKit+Async.swift +++ /dev/null @@ -1,23 +0,0 @@ -import PromiseKit - -extension Promise { - func async() async throws -> T { - return try await withCheckedThrowingContinuation { continuation in - done { value in - continuation.resume(returning: value) - }.catch { error in - continuation.resume(throwing: error) - } - } - } -} - -extension Guarantee { - func async() async -> T { - return await withCheckedContinuation { continuation in - done { value in - continuation.resume(returning: value) - } - } - } -} diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index 136a805e..614aff1f 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -1,355 +1,359 @@ -import PromiseKit import Foundation -import Version -import Path -import AppleAPI +@preconcurrency import Version +@preconcurrency import Path +import Rainbow +import XcodesKit -public class RuntimeInstaller { +public final class RuntimeInstaller: Sendable { + public typealias XcodebuildRuntimeInstall = @Sendable (DownloadableRuntime, String?, @escaping RuntimeXcodebuildInstallService.ProgressChanged) async throws -> Void + public typealias SelectedXcodeVersion = @Sendable () async throws -> Version? public let sessionService: AppleSessionService public let runtimeList: RuntimeList + private let xcodebuildRuntimeInstall: XcodebuildRuntimeInstall + private let selectedXcodeVersion: SelectedXcodeVersion + private var runtimeService: RuntimeService { + runtimeList.runtimeService + } - public init(runtimeList: RuntimeList, sessionService: AppleSessionService) { + public init( + runtimeList: RuntimeList, + sessionService: AppleSessionService, + xcodebuildRuntimeInstall: @escaping XcodebuildRuntimeInstall = { runtime, architecture, progressChanged in + try await RuntimeXcodebuildInstallService().downloadAndInstall( + runtime: runtime, + architecture: architecture, + progressChanged: progressChanged + ) + }, + selectedXcodeVersion: SelectedXcodeVersion? = nil + ) { self.runtimeList = runtimeList self.sessionService = sessionService + self.xcodebuildRuntimeInstall = xcodebuildRuntimeInstall + self.selectedXcodeVersion = selectedXcodeVersion ?? RuntimeInstaller.selectedXcodeVersionFromXcodebuild } - public func printAvailableRuntimes(includeBetas: Bool) async throws { - let downloadablesResponse = try await runtimeList.downloadableRuntimes() - var installed = try await runtimeList.installedRuntimes() - - var mappedRuntimes: [PrintableRuntime] = [] - - downloadablesResponse.downloadables.forEach { downloadable in - let matchingInstalledRuntimes = installed.removeAll { $0.build == downloadable.simulatorVersion.buildUpdate } - if !matchingInstalledRuntimes.isEmpty { - matchingInstalledRuntimes.forEach { - mappedRuntimes.append(PrintableRuntime(platform: downloadable.platform, - betaNumber: downloadable.betaNumber, - version: downloadable.simulatorVersion.version, - build: downloadable.simulatorVersion.buildUpdate, - kind: $0.kind, - architectures: downloadable.architectures)) - } - } else { - mappedRuntimes.append(PrintableRuntime(platform: downloadable.platform, - betaNumber: downloadable.betaNumber, - version: downloadable.simulatorVersion.version, - build: downloadable.simulatorVersion.buildUpdate, - architectures: downloadable.architectures)) - } + public func printAvailableRuntimes(includeBetas: Bool, architectures: [ArchitectureFilter] = []) async throws { + let presentationService = RuntimeListPresentationService() + let downloadableRuntimeList = try await runtimeList.updateDownloadableRuntimeList() + let installedRuntimes = try await runtimeList.installedRuntimes() + let machineArchitecture = Current.shell.machineArchitecture() + let effectiveArchitectures = architectures.isEmpty + ? [ArchitectureFilter].defaultForMachine(machineHardwareName: machineArchitecture) + : architectures + + for (platform, runtimes) in presentationService.rows( + downloadableRuntimes: downloadableRuntimeList.runtimes, + installedRuntimes: installedRuntimes, + includeBetas: includeBetas, + sdkToSeedMappings: downloadableRuntimeList.sdkToSeedMappings, + architectures: effectiveArchitectures + ) { + Current.logging.log("-- \(platform.shortName) --") + runtimes.forEach { Current.logging.log(line(for: $0)) } } - - installed.forEach { runtime in - let resolvedBetaNumber = downloadablesResponse.sdkToSeedMappings.first { - $0.buildUpdate == runtime.build - }?.seedNumber - - var result = PrintableRuntime(platform: runtime.platformIdentifier.asPlatformOS, - betaNumber: resolvedBetaNumber, - version: runtime.version, - build: runtime.build, - kind: runtime.kind, - architectures: nil) - - mappedRuntimes.indices { - result.visibleIdentifier == $0.visibleIdentifier - }.forEach { index in - result.hasDuplicateVersion = true - mappedRuntimes[index].hasDuplicateVersion = true - } - - mappedRuntimes.append(result) + Current.logging.log("\nNote: Bundled runtimes are indicated for the currently selected Xcode, more bundled runtimes may exist in other Xcode(s)") + if architectures.isEmpty { + let defaultVariant = ArchitectureVariant.defaultForMachine(machineHardwareName: machineArchitecture) + let machineDescription = machineArchitecture ?? "unknown" + let betaOption = includeBetas ? "Switch architecture" : "Include beta runtimes with `--include-betas`, or switch architecture" + Current.logging.log("\nShowing runtimes for this Mac by default: \(defaultVariant.displayString) (\(machineDescription)). \(betaOption) with `--architecture arm64`, `--architecture x86_64`, `--architecture appleSilicon`, or `--architecture universal`.") } + } - for (platform, runtimes) in Dictionary(grouping: mappedRuntimes, by: \.platform).sorted(\.key.order) { - Current.logging.log("-- \(platform.shortName) --") - let sortedRuntimes = runtimes.sorted { first, second in - let firstVersion = Version(tolerant: first.completeVersion)! - let secondVersion = Version(tolerant: second.completeVersion)! - if firstVersion == secondVersion { - return first.build.compare(second.build, options: .numeric) == .orderedAscending - } - return firstVersion < secondVersion - } - - for runtime in sortedRuntimes { - if !includeBetas && runtime.betaNumber != nil && runtime.kind == nil { - continue - } - var str = runtime.visibleIdentifier - if runtime.hasDuplicateVersion { - str += " (\(runtime.build))" - } - if let kind = runtime.kind { - switch kind { - case .bundled: - str += " (Bundled with selected Xcode)" - case .legacyDownload, .diskImage, .cryptexDiskImage, .patchableCryptexDiskImage: - str += " (Installed)" - } - } - Current.logging.log(str) + private func line(for row: RuntimeListPresentationService.RuntimeRow) -> String { + var string = row.visibleIdentifier + if row.hasDuplicateVersion { + string += " (\(row.build))" + } + if let kind = row.kind { + switch kind { + case .bundled: + string += " (\("Bundled with selected Xcode".green))" + case .legacyDownload, .diskImage, .cryptexDiskImage, .patchableCryptexDiskImage: + string += " (\("Installed".blue))" } } - Current.logging.log("\nNote: Bundled runtimes are indicated for the currently selected Xcode, more bundled runtimes may exist in other Xcode(s)") + return string } - public func downloadRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader) async throws { - let matchedRuntime = try await getMatchingRuntime(identifier: identifier) + public func downloadRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, architectures: [ArchitectureFilter] = []) async throws { + let matchedRuntime = try await getMatchingRuntime(identifier: identifier, architectures: architectures) + guard matchedRuntime.url != nil else { + throw Error.missingRuntimeSource(matchedRuntime.visibleIdentifier) + } + let runtimeName = downloadName(for: matchedRuntime, architectures: architectures) - _ = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) + _ = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader, runtimeName: runtimeName) } - public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool) async throws { - let matchedRuntime = try await getMatchingRuntime(identifier: identifier) + public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool, architectures: [ArchitectureFilter] = []) async throws { + let matchedRuntime = try await getMatchingRuntime(identifier: identifier, architectures: architectures) - let deleteIfNeeded: (URL) -> Void = { dmgUrl in - if shouldDelete { - Current.logging.log("Deleting Archive") - try? Current.files.removeItem(at: dmgUrl) - } + let method = try await installMethod(for: matchedRuntime) + let runtimeName = downloadName(for: matchedRuntime, architectures: architectures) + + switch method { + case .archive: + try await downloadAndInstallArchiveRuntime( + matchedRuntime, + to: destinationDirectory, + with: downloader, + deleteArchive: shouldDelete, + runtimeName: runtimeName + ) + case let .xcodebuild(architecture): + try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime, architecture: architecture, runtimeName: runtimeName) } + } - switch matchedRuntime.contentType { + private func downloadAndInstallArchiveRuntime( + _ runtime: DownloadableRuntime, + to destinationDirectory: Path, + with downloader: Downloader, + deleteArchive: Bool, + runtimeName: String + ) async throws { + switch runtime.contentType { case .package: guard Current.shell.isRoot() else { throw Error.rootNeeded } - let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) - try await installFromPackage(dmgUrl: dmgUrl, runtime: matchedRuntime) - deleteIfNeeded(dmgUrl) + let dmgUrl = try await downloadOrUseExistingArchive(runtime: runtime, to: destinationDirectory, downloader: downloader, runtimeName: runtimeName) + try await installFromPackage(dmgUrl: dmgUrl, runtime: runtime) + deleteArchiveIfNeeded(dmgUrl, shouldDelete: deleteArchive) case .diskImage: - let dmgUrl = try await downloadOrUseExistingArchive(runtime: matchedRuntime, to: destinationDirectory, downloader: downloader) - try await installFromImage(dmgUrl: dmgUrl) - deleteIfNeeded(dmgUrl) + let dmgUrl = try await downloadOrUseExistingArchive(runtime: runtime, to: destinationDirectory, downloader: downloader, runtimeName: runtimeName) + try await runtimeArchiveInstallService.install( + runtime: runtime, + archiveURL: dmgUrl, + deleteArchive: deleteArchive, + stepChanged: { step in + switch step { + case .installing: + Current.logging.log("Installing Runtime") + case .trashingArchive: + Current.logging.log("Deleting Archive") + case .downloading: + break + } + } + ) case .cryptexDiskImage, .patchableCryptexDiskImage: - try await downloadAndInstallUsingXcodeBuild(runtime: matchedRuntime) + throw XcodesKitError("Installing via \(runtime.contentType.rawValue) not support. Please install manually.") } } - private func getMatchingRuntime(identifier: String) async throws -> DownloadableRuntime { - let downloadables = try await runtimeList.downloadableRuntimes().downloadables - guard let runtime = downloadables.first(where: { $0.visibleIdentifier == identifier || $0.simulatorVersion.buildUpdate == identifier }) else { + private func getMatchingRuntime(identifier: String, architectures: [ArchitectureFilter] = []) async throws -> DownloadableRuntime { + let downloadables = try await runtimeList.downloadableRuntimes() + let matchingRuntimes = downloadables.filter { + $0.visibleIdentifier == identifier || $0.simulatorVersion.buildUpdate == identifier + } + guard let runtime = preferredRuntime(from: matchingRuntimes, architectures: architectures) else { throw Error.unavailableRuntime(identifier) } return runtime } - private func installFromImage(dmgUrl: URL) async throws { - Current.logging.log("Installing Runtime") - try await Current.shell.installRuntimeImage(dmgUrl).asVoid().async() + private func preferredRuntime(from runtimes: [DownloadableRuntime], architectures: [ArchitectureFilter] = []) -> DownloadableRuntime? { + guard runtimes.count > 1 else { return runtimes.first } + + let machineArchitecture = Current.shell.machineArchitecture() + let defaultFilters = architectures.isEmpty + ? [ArchitectureFilter].defaultForMachine(machineHardwareName: machineArchitecture) + : architectures + return runtimes.first { runtime in + guard let architectures = runtime.architectures else { return false } + return defaultFilters.contains { $0.matches(architectures) } + } ?? runtimes.matchingArchitectureFilters(defaultFilters).first ?? runtimes.first } - private func installFromPackage(dmgUrl: URL, runtime: DownloadableRuntime) async throws { - Current.logging.log("Mounting DMG") - // 1-Mount DMG and get the mounted path - let mountedUrl = try await mountDMG(dmgUrl: dmgUrl) - // 2-Get the first path under the mounted path, should be a .pkg - let pkgPath = try! Path(url: mountedUrl)!.ls().first!.path - // 3-Create a caches directory (if it doesn't exist), and - // 4-Set its ownership to the current user (important because under sudo it would be owned by root) - try Path.xcodesCaches.mkdir().setCurrentUserAsOwner() - let expandedPkgPath = Path.xcodesCaches/runtime.identifier - try? Current.files.removeItem(at: expandedPkgPath.url) - // 5-Expand (not install) the pkg to temporary path - try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url).asVoid().async() - try await unmountDMG(mountedURL: mountedUrl) - let packageInfoPath = expandedPkgPath/"PackageInfo" - // 6-Get the `PackageInfo` file contents from the expanded pkg - let packageInfoContentsData = Current.files.contents(atPath: packageInfoPath.string)! - var packageInfoContents = String(data: packageInfoContentsData, encoding: .utf8)! - let runtimeFileName = "\(runtime.visibleIdentifier).simruntime" - let runtimeDestination = Path("/Library/Developer/CoreSimulator/Profiles/Runtimes/\(runtimeFileName)")! - packageInfoContents = packageInfoContents.replacingOccurrences(of: " String { + guard architectures.isEmpty, let runtimeArchitectures = runtime.architectures, runtimeArchitectures.isEmpty == false else { + return runtime.visibleIdentifier + } + return "\(runtime.visibleIdentifier) - \(architectureDescription(runtimeArchitectures))" } - private func mountDMG(dmgUrl: URL) async throws -> URL { - let resultPlist = try await Current.shell.mountDmg(dmgUrl).async() - let dict = try? (PropertyListSerialization.propertyList(from: resultPlist.out.data(using: .utf8)!, format: nil) as? NSDictionary) - let systemEntities = dict?["system-entities"] as? NSArray - guard let path = systemEntities?.compactMap ({ ($0 as? NSDictionary)?["mount-point"] as? String }).first else { - throw Error.failedMountingDMG + private func architectureDescription(_ architectures: [Architecture]) -> String { + if architectures.isUniversal { + return "\(ArchitectureVariant.universal.displayString) (\(architectures.map(\.rawValue).joined(separator: ", ")))" } - return URL(fileURLWithPath: path) + if architectures.isAppleSilicon { + return "\(ArchitectureVariant.appleSilicon.displayString) (\(Architecture.arm64.rawValue))" + } + return architectures.map { "\($0.displayString) (\($0.rawValue))" }.joined(separator: ", ") } - private func unmountDMG(mountedURL: URL) async throws { - try await Current.shell.unmountDmg(mountedURL).asVoid().async() + private var runtimeArchiveInstallService: RuntimeArchiveInstallService { + let runtimeService = self.runtimeService + return RuntimeArchiveInstallService( + installDiskImage: { url in + try await runtimeService.installRuntimeImage(dmgURL: url) + }, + removeArchive: { url in + try? Current.files.removeItem(at: url) + } + ) } - @MainActor - public func downloadOrUseExistingArchive(runtime: DownloadableRuntime, to destinationDirectory: Path, downloader: Downloader) async throws -> URL { - guard let source = runtime.source else { - throw Error.missingRuntimeSource(runtime.visibleIdentifier) - } - let url = URL(string: source)! - let destination = destinationDirectory/url.lastPathComponent - let aria2DownloadMetadataPath = destination.parent/(destination.basename() + ".aria2") - var aria2DownloadIsIncomplete = false - if case .aria2 = downloader, aria2DownloadMetadataPath.exists { - aria2DownloadIsIncomplete = true - } + private func deleteArchiveIfNeeded(_ archiveURL: URL, shouldDelete: Bool) { + guard shouldDelete else { return } + Current.logging.log("Deleting Archive") + try? Current.files.removeItem(at: archiveURL) + } + private func installFromPackage(dmgUrl: URL, runtime: DownloadableRuntime) async throws { + Current.logging.log("Mounting DMG") + try await runtimePackageInstallService.installPackageRuntime( + from: dmgUrl, + runtime: runtime, + cachesDirectory: .xcodesCaches + ) + } + + private var runtimePackageInstallService: RuntimePackageInstallService { + let runtimeService = self.runtimeService + return RuntimePackageInstallService( + mountDMG: { try await runtimeService.mountDMG(dmgUrl: $0) }, + unmountDMG: { try await runtimeService.unmountDMG(mountedURL: $0) }, + prepareDirectory: { path in try path.mkdir().setCurrentUserAsOwner() }, + expandPkg: { try await Current.shell.expandPkg($0, $1) }, + createPkg: { try await Current.shell.createPkg($0, $1) }, + installPkg: { packageURL, target in + Current.logging.log("Installing Runtime") + return try await Current.shell.installPkg(packageURL, target) + }, + contentsAtPath: { Current.files.contents(atPath: $0) }, + writeData: { try Current.files.write($0, to: $1) }, + removeItem: { try Current.files.removeItem(at: $0) } + ) + } + + public func downloadOrUseExistingArchive(runtime: DownloadableRuntime, to destinationDirectory: Path, downloader: Downloader, runtimeName: String? = nil) async throws -> URL { + let runtimeName = runtimeName ?? runtime.visibleIdentifier if Current.shell.isatty() { // Move to the next line so that the escape codes below can move up a line and overwrite it with download progress Current.logging.log("") } else { - Current.logging.log("Downloading Runtime \(runtime.visibleIdentifier)") + Current.logging.log("Downloading Runtime \(runtimeName)") } - if Current.files.fileExistsAtPath(destination.string), aria2DownloadIsIncomplete == false { - Current.logging.log("Found existing Runtime that will be used, at \(destination).") - return destination.url - } - if runtime.authentication == .virtual { - try await sessionService.validateADCSession(path: url.path).async() - } - let formatter = NumberFormatter(numberStyle: .percent) - var observation: NSKeyValueObservation? - let result = try await downloader.download(url: url, to: destination, progressChanged: { progress in - observation?.invalidate() - observation = progress.observe(\.fractionCompleted) { progress, _ in + let observation = ProgressObservation() + let result = try await runtimeArchiveService(downloader: downloader).archiveURL( + for: runtime, + destinationDirectory: destinationDirectory, + downloader: XcodeArchiveDownloader(downloader) + ) { progress in + observation.observe(progress) { progress in guard Current.shell.isatty() else { return } // These escape codes move up a line and then clear to the end - Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)") + let progressString = NumberFormatter.localizedString(from: NSNumber(value: progress.fractionCompleted), number: .percent) + Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtimeName): \(progressString)") } - }).async() - observation?.invalidate() - destination.setCurrentUserAsOwner() + } + observation.invalidate() + Path(url: result)?.setCurrentUserAsOwner() return result } + private func runtimeArchiveService(downloader: Downloader) -> RuntimeArchiveService { + let runtimeArchiveDownloadStrategyService = runtimeArchiveDownloadStrategyService(downloader: downloader) + return RuntimeArchiveService( + fileExists: { Current.files.fileExistsAtPath($0.string) }, + download: { runtime, url, destination, _, progressChanged in + try await runtimeArchiveDownloadStrategyService.download( + runtime: runtime, + url: url, + destination: destination, + downloader: XcodeArchiveDownloader(downloader), + progressChanged: progressChanged + ) + } + ) + } + + private func runtimeArchiveDownloadStrategyService(downloader: Downloader) -> RuntimeArchiveDownloadStrategyService { + let sessionService = self.sessionService + return RuntimeArchiveDownloadStrategyService( + validateDownloadPath: { path in + try await sessionService.validateADCSession(path: path) + }, + aria2Path: { + guard let aria2Path = downloader.aria2Path else { + throw XcodesKitError("aria2 path is unavailable.") + } + return aria2Path + }, + cookiesForURL: { Current.network.session.configuration.httpCookieStorage?.cookies(for: $0) ?? [] }, + urlSessionDownload: { url, destination, progressChanged in + try await downloader.download(url: url, to: destination, progressChanged: progressChanged) + }, + missingDownloadPathError: { Error.missingRuntimeSource($0.visibleIdentifier) } + ) + } + // MARK: Xcode 16.1 Runtime installation helpers /// Downloads and installs the runtime using xcodebuild, requires Xcode 16.1+ to download a runtime using a given directory /// - Parameters: /// - runtime: The runtime to download and install to identify the platform and version numbers - private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime) async throws { - - // Make sure that we are using a version of xcode that supports this - try await ensureSelectedXcodeVersionForDownload() - - // Kick off the download/install process and get an async stream of the progress - let downloadStream = createXcodebuildDownloadStream(runtime: runtime) + private func downloadAndInstallUsingXcodeBuild(runtime: DownloadableRuntime, architecture: String?, runtimeName: String) async throws { + if Current.shell.isatty() { + // Reserve the line that the progress renderer rewrites. + Current.logging.log("") + } - // Observe the progress and update the console from it - for try await progress in downloadStream { - let formatter = NumberFormatter(numberStyle: .percent) - guard Current.shell.isatty() else { return } - // These escape codes move up a line and then clear to the end - Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtime.visibleIdentifier): \(formatter.string(from: progress.fractionCompleted)!)") + do { + try await xcodebuildRuntimeInstall(runtime, architecture) { progress in + let formatter = NumberFormatter(numberStyle: .percent) + guard Current.shell.isatty() else { return } + // These escape codes move up a line and then clear to the end + Current.logging.log("\u{1B}[1A\u{1B}[KDownloading Runtime \(runtimeName): \(formatter.string(from: progress.fractionCompleted)!)") + } + } catch let error { + guard try await duplicateRuntimeIsAlreadyInstalled(error, runtime: runtime) else { + throw error + } + Current.logging.log("Runtime \(runtimeName) is already installed") } } - /// Checks the existing `xcodebuild -version` to ensure that the version is appropriate to use for downloading the cryptex style 16.1+ downloads - /// otherwise will throw an error - private func ensureSelectedXcodeVersionForDownload() async throws { - let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild") - let versionString = try await Process.run(xcodeBuildPath, "-version").async() - let versionPattern = #"Xcode (\d+\.\d+)"# - let versionRegex = try NSRegularExpression(pattern: versionPattern) - - // parse out the version string (e.g. 16.1) from the xcodebuild version command and convert it to a `Version` - guard let match = versionRegex.firstMatch(in: versionString.out, range: NSRange(versionString.out.startIndex..., in: versionString.out)), - let versionRange = Range(match.range(at: 1), in: versionString.out), - let version = Version(tolerant: String(versionString.out[versionRange])) else { - throw Error.noXcodeSelectedFound + private func installMethod(for runtime: DownloadableRuntime) async throws -> RuntimeInstallMethod { + guard runtime.contentType == .cryptexDiskImage else { + return try RuntimeInstallPolicy().installMethod(for: runtime, selectedXcodeVersion: nil) } - // actually compare the version against version 16.1 to ensure it's equal or greater - guard version >= Version(16, 1, 0) else { - throw Error.xcode16_1OrGreaterRequired(version) + guard let version = try await selectedXcodeVersion() else { + throw Error.noXcodeSelectedFound } - // If we made it here, we're gucci and 16.1 or greater is selected + return try RuntimeInstallPolicy().installMethod(for: runtime, selectedXcodeVersion: version) } - // Creates and invokes the xcodebuild install command and converts it to a stream of Progress - private func createXcodebuildDownloadStream(runtime: DownloadableRuntime) -> AsyncThrowingStream { - let platform = runtime.platform.shortName - let version = runtime.simulatorVersion.buildUpdate - - return AsyncThrowingStream { continuation in - Task { - // Assume progress will not have data races, so we manually opt-out isolation checks. - let progress = Progress() - progress.kind = .file - progress.fileOperationKind = .downloading - - let process = Process() - let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url - - process.executableURL = xcodeBuildPath - process.arguments = [ - "-downloadPlatform", - "\(platform)", - "-buildVersion", - "\(version)" - ] - - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - 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) - progress.updateFromXcodebuild(text: string) - continuation.yield(progress) - } - - stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - - continuation.onTermination = { @Sendable _ in - process.terminate() - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - } - - do { - try process.run() - } catch { - continuation.finish(throwing: error) - } + private func duplicateRuntimeIsAlreadyInstalled(_ error: Swift.Error, runtime: DownloadableRuntime) async throws -> Bool { + guard isDuplicateRuntimeInstallError(error) else { return false } - process.waitUntilExit() + let installedRuntimes = try await runtimeService.localInstalledRuntimes() + return RuntimeInstallationLookupService().coreSimulatorImage( + for: runtime, + in: installedRuntimes + ) != nil + } - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) + private func isDuplicateRuntimeInstallError(_ error: Swift.Error) -> Bool { + guard let error = error as? ProcessExecutionError else { return false } + let output = error.standardOutput + "\n" + error.standardError + return output.contains("SimDiskImageErrorDomain") && output.contains("Duplicate of ") + } - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - struct ProcessExecutionError: Swift.Error {} - continuation.finish(throwing: ProcessExecutionError()) - return - } - continuation.finish() - } - } + private static func selectedXcodeVersionFromXcodebuild() async throws -> Version? { + let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild") + let versionString = try await Process.runAsync(xcodeBuildPath, "-version") + return RuntimeInstallPolicy().selectedXcodeVersion(fromXcodebuildVersionOutput: versionString.out) } + } extension RuntimeInstaller { @@ -372,91 +376,10 @@ extension RuntimeInstaller { case let .missingRuntimeSource(identifier): return "Downloading runtime \(identifier) is not supported at this time. Please use `xcodes runtimes install \"\(identifier)\"` instead." case let .xcode16_1OrGreaterRequired(version): - return "Installing this runtime requires Xcode 16.1 or greater to be selected, but is currently \(version.description)" + return RuntimeInstallPolicyError.xcode16_1OrGreaterRequired(version).localizedDescription case .noXcodeSelectedFound: return "No Xcode is currently selected, please make sure that you have one selected and installed before trying to install this runtime" } } } } - -fileprivate struct PrintableRuntime { - let platform: DownloadableRuntime.Platform - let betaNumber: Int? - let version: String - let build: String - var kind: InstalledRuntime.Kind? = nil - var hasDuplicateVersion = false - let architectures: [String]? - - var completeVersion: String { - makeVersion(for: version, betaNumber: betaNumber) - } - - var visibleIdentifier: String { - return platform.shortName + " " + completeVersion + (architectures != nil ? " \(architectures?.joined(separator: "|") ?? "")" : "") - } -} - -extension Array { - fileprivate mutating func removeAll(where predicate: ((Element) -> Bool)) -> [Element] { - guard !isEmpty else { return [] } - var removed: [Element] = [] - self = filter { current in - let satisfy = predicate(current) - if satisfy { - removed.append(current) - } - return !satisfy - } - return removed - } - - fileprivate func indices(where predicate: ((Element) -> Bool)) -> [Index] { - var result: [Index] = [] - - for index in indices { - if predicate(self[index]) { - result.append(index) - } - } - - return result - } -} - - -private extension Progress { - func updateFromXcodebuild(text: String) { - self.totalUnitCount = 100 - self.completedUnitCount = 0 - self.localizedAdditionalDescription = "" // to not show the addtional - - do { - - let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"# - let downloadRegex = try NSRegularExpression(pattern: downloadPattern) - - // Search for matches in the text - if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) { - // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes. - if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) { - let percent = Int64(percentDouble.rounded()) - self.completedUnitCount = percent - } - } - - // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or - // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..." - if text.range(of: "Installing") != nil { - // sets the progress to indeterminite to show animating progress - self.totalUnitCount = 0 - self.completedUnitCount = 0 - } - - } catch { - print("Invalid regular expression") - } - - } -} diff --git a/Sources/XcodesKit/RuntimeList.swift b/Sources/XcodesKit/RuntimeList.swift index 9b19a941..9ffdc1ef 100644 --- a/Sources/XcodesKit/RuntimeList.swift +++ b/Sources/XcodesKit/RuntimeList.swift @@ -1,22 +1,65 @@ import Foundation +import os +import XcodesKit -public class RuntimeList { +public final class RuntimeList: Sendable { + private let store: OSAllocatedUnfairLock - public init() {} + public init() { + var store = Self.makeStore() + try? store.loadCachedDownloadableRuntimes() + self.store = OSAllocatedUnfairLock(initialState: store) + } + + var runtimeService: RuntimeService { + Self.makeRuntimeService() + } + + private static func makeRuntimeService() -> RuntimeService { + return RuntimeService( + loadData: { request in + let (data, response) = try await Current.network.data(for: request) + return (data, response) + }, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + installedRuntimesOutput: Current.shell.installedRuntimes, + installRuntimeImageOutput: Current.shell.installRuntimeImage, + mountDMGOutput: Current.shell.mountDmg, + unmountDMGOutput: Current.shell.unmountDmg + ) + } + + private static func makeStore() -> RuntimeListStore { + RuntimeListStore( + cache: DownloadableRuntimeCache( + cacheFile: .runtimeCacheFile, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + writeData: { data, url in try Current.files.write(data, to: url) }, + createDirectory: { url, createIntermediates, attributes in + try Current.files.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates, + attributes: attributes + ) + } + ), + service: makeRuntimeService() + ) + } + + func downloadableRuntimes() async throws -> [DownloadableRuntime] { + try await updateDownloadableRuntimeList().runtimes + } - func downloadableRuntimes() async throws -> DownloadableRuntimesResponse { - let (data, _) = try await Current.network.dataTask(with: URLRequest.runtimes).async() - let decodedResponse = try PropertyListDecoder().decode(DownloadableRuntimesResponse.self, from: data) - return decodedResponse + func updateDownloadableRuntimeList() async throws -> RuntimeListStore.UpdateResult { + var updatedStore = store.withLock { $0 } + let result = try await updatedStore.updateDownloadableRuntimeList() + let finishedStore = updatedStore + store.withLock { $0 = finishedStore } + return result } func installedRuntimes() async throws -> [InstalledRuntime] { - let output = try await Current.shell.installedRuntimes().async() - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let outputDictionary = try decoder.decode([String: InstalledRuntime].self, from: output.out.data(using: .utf8)!) - return outputDictionary.values.sorted { first, second in - return first.identifier.uuidString.compare(second.identifier.uuidString, options: .numeric) == .orderedAscending - } + try await runtimeService.installedRuntimes() } } diff --git a/Sources/XcodesKit/URLRequest+Apple.swift b/Sources/XcodesKit/URLRequest+Apple.swift deleted file mode 100644 index 73ad510f..00000000 --- a/Sources/XcodesKit/URLRequest+Apple.swift +++ /dev/null @@ -1,44 +0,0 @@ -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")! - static let downloadADCAuth = URL(string: "https://developerservices2.apple.com/services/download")! - static let downloadableRuntimes = URL(string: "https://devimages-cdn.apple.com/downloads/xcode/simulators/index2.dvtdownloadableindex")! -} - -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 var runtimes: URLRequest { - return URLRequest(url: .downloadableRuntimes) - } - - 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 - } - - // default to a known download path if none passed in - static func downloadADCAuth(path: String? = "/Developer_Tools/Xcode_14/Xcode_14.xip") -> URLRequest { - var components = URLComponents(url: .downloadADCAuth, 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/Sources/XcodesKit/URLSession+Promise.swift b/Sources/XcodesKit/URLSession+Promise.swift deleted file mode 100644 index cd4dfc89..00000000 --- a/Sources/XcodesKit/URLSession+Promise.swift +++ /dev/null @@ -1,47 +0,0 @@ -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) - } -} diff --git a/Sources/XcodesKit/Version+.swift b/Sources/XcodesKit/Version+.swift deleted file mode 100644 index 74170dbe..00000000 --- a/Sources/XcodesKit/Version+.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Version - -public extension Version { - /// Determines if two Xcode versions should be treated equivalently. This is not the same as equality. - /// - /// We need a way to determine if two Xcode versions are the same without always having full information, and supporting different data sources. - /// For example, the Apple data source often doesn't have build metadata identifiers. - func isEquivalent(to other: Version) -> Bool { - // If we don't have build metadata identifiers for both Versions, compare major, minor, patch and prerelease identifiers. - if buildMetadataIdentifiers.isEmpty || other.buildMetadataIdentifiers.isEmpty { - return isEqualWithoutAllIdentifiers(to: other) && - prereleaseIdentifiers.map { $0.lowercased() } == other.prereleaseIdentifiers.map { $0.lowercased() } - // If we have build metadata identifiers for both, we can ignore the prerelease identifiers. - } else { - return isEqualWithoutAllIdentifiers(to: other) && - buildMetadataIdentifiers.map { $0.lowercased() } == other.buildMetadataIdentifiers.map { $0.lowercased() } - } - } - - 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/Sources/XcodesKit/Version+Gem.swift b/Sources/XcodesKit/Version+Gem.swift deleted file mode 100644 index 57869ab6..00000000 --- a/Sources/XcodesKit/Version+Gem.swift +++ /dev/null @@ -1,45 +0,0 @@ -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/Sources/XcodesKit/Version+Xcode.swift b/Sources/XcodesKit/Version+Xcode.swift index b8d1e552..41804b69 100644 --- a/Sources/XcodesKit/Version+Xcode.swift +++ b/Sources/XcodesKit/Version+Xcode.swift @@ -1,110 +1,14 @@ -import Foundation import Path import Version +import XcodesKit 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 - 13.2 Release Candidate - */ - init?(xcodeVersion: String, buildMetadataIdentifier: String? = nil) { - let nsrange = NSRange(xcodeVersion.startIndex.. Version? { - let xcodeVersionFilePath = inDirectory.join(".xcode-version") - guard - Current.files.fileExists(atPath: xcodeVersionFilePath.string), - let contents = Current.files.contents(atPath: xcodeVersionFilePath.string), - let versionString = String(data: contents, encoding: .utf8), - let version = Version(gemVersion: versionString) - else { - return nil - } - - return version - } - - /// The intent here is to match Apple's marketing version - /// - /// Only show the patch number if it's not 0 - /// Format prerelease identifiers - /// Don't include build identifiers - var appleDescription: String { - var base = "\(major).\(minor)" - if patch != 0 { - base += ".\(patch)" - } - if !prereleaseIdentifiers.isEmpty { - base += " " + prereleaseIdentifiers - .map { identifier in - identifier - .replacingOccurrences(of: "-", with: " ") - .capitalized - .replacingOccurrences(of: "Gm", with: "GM") - .replacingOccurrences(of: "Rc", with: "RC") - } - .joined(separator: " ") - } - return base - } - var appleDescriptionWithBuildIdentifier: String { - [appleDescription, buildMetadataIdentifiersDisplay].filter { !$0.isEmpty }.joined(separator: " ") - } -} - -extension NSTextCheckingResult { - func groupNamed(_ name: String, in string: String) -> String? { - let nsrange = range(withName: name) - guard let range = Range(nsrange, in: string) else { return nil } - return String(string[range]) + /// Attempt to instantiate a `Version` using the `.xcode-version` file in the provided directory. + static func fromXcodeVersionFile(inDirectory: Path = Path(.cwd)) -> Version? { + XcodeVersionFileService( + fileExists: { path in Current.files.fileExists(atPath: path) }, + contentsAtPath: { path in Current.files.contents(atPath: path) } + ) + .version(inDirectory: inDirectory) } } diff --git a/Sources/XcodesKit/Version+XcodeReleases.swift b/Sources/XcodesKit/Version+XcodeReleases.swift deleted file mode 100644 index ef49d509..00000000 --- a/Sources/XcodesKit/Version+XcodeReleases.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Version -import struct XCModel.Xcode - -extension Version { - /// Initialize a Version from an XcodeReleases' XCModel.Xcode - /// - /// This is kinda quick-and-dirty, and it would probably be better for us to adopt something closer to XCModel.Xcode under the hood and map the scraped data to it instead. - init?(xcReleasesXcode: XCModel.Xcode) { - var versionString = xcReleasesXcode.version.number ?? "" - - // Append trailing ".0" in order to get a fully-specified version string - let components = versionString.components(separatedBy: ".") - versionString += Array(repeating: ".0", count: 3 - components.count).joined() - - // Append prerelease identifier - switch xcReleasesXcode.version.release { - case let .beta(beta): - versionString += "-Beta" - if beta > 1 { - versionString += ".\(beta)" - } - case let .dp(dp): - versionString += "-DP" - if dp > 1 { - versionString += ".\(dp)" - } - case .gm: - versionString += "-GM" - case let .gmSeed(gmSeed): - versionString += "-GM.Seed" - if gmSeed > 1 { - versionString += ".\(gmSeed)" - } - case let .rc(rc): - versionString += "-Release.Candidate" - if rc > 1 { - versionString += ".\(rc)" - } - case .release: - break - } - - // Append build identifier - if let buildNumber = xcReleasesXcode.version.build { - versionString += "+\(buildNumber)" - } - - self.init(versionString) - } - - var buildMetadataIdentifiersDisplay: String { - return !buildMetadataIdentifiers.isEmpty ? "(\(buildMetadataIdentifiers.joined(separator: " ")))" : "" - } -} diff --git a/Sources/XcodesKit/Version.swift b/Sources/XcodesKit/Version.swift index 2b615932..c83be66f 100644 --- a/Sources/XcodesKit/Version.swift +++ b/Sources/XcodesKit/Version.swift @@ -1,3 +1,5 @@ -import Version +@preconcurrency import Version -public let version = Version("1.6.2")! +public var version: Version { + Version("1.6.2")! +} diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 44747c6e..f829d219 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -1,19 +1,19 @@ import Foundation -import PromiseKit -import Path -import AppleAPI -import Version +@preconcurrency import Path +@preconcurrency import Version import LegibleError import Rainbow import Unxip +import XcodesKit /// 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 final class XcodeInstaller: Sendable { + static let XcodeTeamIdentifier = XcodesKit.XcodeSignatureVerifier.expectedTeamIdentifier + static let XcodeCertificateAuthority = XcodesKit.XcodeSignatureVerifier.expectedCertificateAuthority public enum Error: LocalizedError, Equatable { case damagedXIP(url: URL) + case notEnoughFreeSpaceToExpandArchive(url: URL) case failedToMoveXcodeToDestination(Path) case failedSecurityAssessment(xcode: InstalledXcode, output: String) case codesignVerifyFailed(output: String) @@ -32,6 +32,8 @@ public final class XcodeInstaller { switch self { case .damagedXIP(let url): return "The archive \"\(url.lastPathComponent)\" is damaged and can't be expanded." + case .notEnoughFreeSpaceToExpandArchive(let url): + return "The archive \"\(url.lastPathComponent)\" couldn't be expanded because the selected volume doesn't have enough free space." case .failedToMoveXcodeToDestination(let destination): return "Failed to move Xcode to the \(destination.string) directory." case .failedSecurityAssessment(let xcode, let output): @@ -150,462 +152,447 @@ public final class XcodeInstaller { } } - private var sessionService: AppleSessionService - private var xcodeList: XcodeList + private let sessionService: AppleSessionService + private let xcodeList: XcodeList + private let currentOSVersion: @Sendable () -> OperatingSystemVersion - public init(xcodeList: XcodeList, sessionService: AppleSessionService) { + public init( + xcodeList: XcodeList, + sessionService: AppleSessionService, + currentOSVersion: @escaping @Sendable () -> OperatingSystemVersion = { ProcessInfo.processInfo.operatingSystemVersion } + ) { self.xcodeList = xcodeList self.sessionService = sessionService + self.currentOSVersion = currentOSVersion } - public enum InstallationType { + public enum InstallationType: Sendable { case version(String) case path(String, Path) case latest case latestPrerelease - } - - public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise { - return firstly { () -> Promise in - return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) - } - .map { xcode in - Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)".green) - return xcode - } - } - private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise { - return firstly { () -> Promise<(Xcode, URL)> in - return self.getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true) - } - .then { xcode, url -> Promise in - return self.installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) - } - .recover { error -> Promise in - switch error { - case XcodeInstaller.Error.damagedXIP(let damagedXIPURL): - guard attemptNumber < 1 else { throw error } - - switch installationType { - case .path: - // If the user provided the path, 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.red) - Current.logging.log("Removing damaged XIP and re-attempting installation.\n") - try Current.files.removeItem(at: damagedXIPURL) - return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) - } - } - default: - throw error + var shouldRetryAfterDamagedArchive: Bool { + switch self { + case .path: + return false + case .version, .latest, .latestPrerelease: + return true } } } - public func download(_ installation: InstallationType, dataSource: DataSource, downloader: Downloader, destinationDirectory: Path) -> Promise { - return firstly { () -> Promise<(Xcode, URL)> in - return self.getXcodeArchive(installation, dataSource: dataSource, downloader: downloader, destination: destinationDirectory, willInstall: false) - } - .map { (xcode, url) -> (Xcode, URL) in - let destination = destinationDirectory.url.appendingPathComponent(url.lastPathComponent) - try Current.files.moveItem(at: url, to: destination) - return (xcode, destination) - } - .done { (xcode, url) in - Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been downloaded to \(url.path)".green) - Current.shell.exit(0) - } + public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) async throws -> InstalledXcode { + let xcode = try await xcodeInstallRetryService.install( + shouldRetryAfterDamagedArchive: installationType.shouldRetryAfterDamagedArchive, + attempt: { _ in + let (xcode, url) = try await getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true) + return try await installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) + }, + onRetryDamagedArchive: { error, _ in + Current.logging.log(error.legibleLocalizedDescription.red) + Current.logging.log("Removing damaged XIP and re-attempting installation.\n") + } + ) + Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)".green) + return xcode } - private func getXcodeArchive(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, willInstall: Bool) -> Promise<(Xcode, URL)> { - return firstly { () -> Promise<(Xcode, URL)> in - switch installationType { - case .latest: - Current.logging.log("Updating...") - - return update(dataSource: dataSource) - .then { availableXcodes -> Promise<(Xcode, URL)> in - guard let latestReleaseXcode = availableXcodes.filter(\.version.isNotPrerelease).sorted(\.version).last else { - throw Error.noReleaseVersionAvailable - } - - Current.logging.log("Latest release version available is \(latestReleaseXcode.version.appleDescription)") - - if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEquivalent(to: latestReleaseXcode.version) }) { - throw Error.versionAlreadyInstalled(installedXcode) - } - - return self.downloadXcode(version: latestReleaseXcode.version, dataSource: dataSource, downloader: downloader, willInstall: willInstall) - } - case .latestPrerelease: - Current.logging.log("Updating...") - - return update(dataSource: dataSource) - .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.noReleaseVersionAvailable - } - Current.logging.log("Latest prerelease version available is \(latestPrereleaseXcode.version.appleDescription)") + private var xcodeInstallRetryService: XcodeInstallRetryService { + XcodeInstallRetryService( + damagedArchiveURL: { error in + guard case XcodeInstaller.Error.damagedXIP(let url) = error else { return nil } + return url + }, + removeDamagedArchive: { url in + try Current.files.removeItem(at: url) + } + ) + } - if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEquivalent(to: latestPrereleaseXcode.version) }) { - throw Error.versionAlreadyInstalled(installedXcode) - } + public func download(_ installation: InstallationType, dataSource: DataSource, downloader: Downloader, destinationDirectory: Path) async throws { + let (xcode, url) = try await getXcodeArchive(installation, dataSource: dataSource, downloader: downloader, destination: destinationDirectory, willInstall: false) + let destination = destinationDirectory.url.appendingPathComponent(url.lastPathComponent) + try Current.files.moveItem(at: url, to: destination) + Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been downloaded to \(destination.path)".green) + Current.shell.exit(0) + } - return self.downloadXcode(version: latestPrereleaseXcode.version, dataSource: dataSource, downloader: downloader, willInstall: willInstall) - } - case .path(let versionString, let path): - guard let version = Version(xcodeVersion: versionString) ?? Version.fromXcodeVersionFile() 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) ?? Version.fromXcodeVersionFile() else { - throw Error.invalidVersion(versionString) - } - if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEquivalent(to: version) }) { - throw Error.versionAlreadyInstalled(installedXcode) - } - return self.downloadXcode(version: version, dataSource: dataSource, downloader: downloader, willInstall: willInstall) - } + private func getXcodeArchive(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, willInstall: Bool) async throws -> (Xcode, URL) { + let resolutionService = XcodeInstallResolutionService(versionFile: XcodeVersionFileService( + fileExists: { path in Current.files.fileExists(atPath: path) }, + contentsAtPath: { path in Current.files.contents(atPath: path) } + )) + + switch installationType { + case .latest: + Current.logging.log("Updating...") + let availableXcodes = try await update(dataSource: dataSource) + + let resolution = try mapInstallResolutionError { + try resolutionService.resolve( + .latest, + availableXcodes: availableXcodes, + installedXcodes: Current.files.installedXcodes(destination), + willInstall: willInstall + ) + } + return try await archive(for: resolution, dataSource: dataSource, downloader: downloader, willInstall: willInstall) + case .latestPrerelease: + Current.logging.log("Updating...") + let availableXcodes = try await update(dataSource: dataSource) + + let resolution = try mapInstallResolutionError { + try resolutionService.resolve( + .latestPrerelease, + availableXcodes: availableXcodes, + installedXcodes: Current.files.installedXcodes(destination), + willInstall: willInstall + ) + } + return try await archive(for: resolution, dataSource: dataSource, downloader: downloader, willInstall: willInstall) + case .path(let versionString, let path): + let resolution = try mapInstallResolutionError { + try resolutionService.resolve( + .path(versionString: versionString, path: path), + availableXcodes: [], + installedXcodes: [], + willInstall: willInstall + ) + } + return try await archive(for: resolution, dataSource: dataSource, downloader: downloader, willInstall: willInstall) + case .version(let versionString): + let resolution = try mapInstallResolutionError { + try resolutionService.resolve( + .version(versionString), + availableXcodes: [], + installedXcodes: Current.files.installedXcodes(destination), + willInstall: willInstall + ) + } + return try await archive(for: resolution, dataSource: dataSource, downloader: downloader, willInstall: willInstall) } } - private func downloadXcode(version: Version, dataSource: DataSource, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> { - return firstly { () -> Promise in - switch dataSource { - case .apple: - // When using the Apple data source, an authenticated session is required for both - // downloading the list of Xcodes as well as to actually download Xcode, so we'll - // establish our session right at the start. - return sessionService.loginIfNeeded() - - case .xcodeReleases: - // When using the Xcode Releases data source, we only need to establish an anonymous - // session once we're ready to download Xcode. Doing that requires us to know the - // URL we want to download though (and we may not know that yet), so we don't need - // to do anything session-related quite yet. - return sessionService.loginIfNeeded() + private func archive(for resolution: XcodeInstallResolution, dataSource: DataSource, downloader: Downloader, willInstall: Bool) async throws -> (Xcode, URL) { + switch resolution { + case let .download(version, resolvedXcode): + if let resolvedXcode { + let releaseType = resolvedXcode.version.isPrerelease ? "prerelease" : "release" + Current.logging.log("Latest \(releaseType) version available is \(resolvedXcode.version.appleDescription)") } + return try await downloadXcode(version: version, dataSource: dataSource, downloader: downloader, willInstall: willInstall) + case let .localArchive(xcode, url): + return (xcode, url) } - .then { () -> Promise in - if self.xcodeList.shouldUpdateBeforeDownloading(version: version) { - return self.xcodeList.update(dataSource: dataSource).asVoid() - } else { - return Promise() - } - } - .then { () -> Promise in - guard let xcode = self.xcodeList.availableXcodes.first(withVersion: version) else { - throw Error.unavailableVersion(version) - } + } - return Promise.value(xcode) - } - .then { xcode -> Promise in - switch dataSource { - case .apple: - /// We already established a session for the Apple data source at the beginning of - /// this download, so we don't need to do anything session-related at this point. - return Promise.value(xcode) - - case .xcodeReleases: - /// Now that we've used Xcode Releases to determine what URL we should use to - /// download Xcode, we can use that to establish an anonymous session with Apple. - // As of Nov 2022, the `validateADCSession` return 403 forbidden for Xcode versions (works with runtimes) - // return self.sessionService.validateADCSession(path: xcode.downloadPath).map { xcode } - // ------- - // We need the cookies from its response in order to download Xcodes though, - // so perform it here first just to be sure. - return Current.network.dataTask(with: URLRequest.downloads).map { _ in xcode } + private func mapInstallResolutionError(_ body: () throws -> T) throws -> T { + do { + return try body() + } catch let error as XcodeInstallResolutionError { + switch error { + case let .invalidVersion(version): + throw Error.invalidVersion(version) + case .noReleaseVersionAvailable: + throw Error.noReleaseVersionAvailable + case .noPrereleaseVersionAvailable: + throw Error.noPrereleaseVersionAvailable + case let .versionAlreadyInstalled(installedXcode): + throw Error.versionAlreadyInstalled(installedXcode) } } - .then { xcode -> Promise<(Xcode, URL)> in - if Current.shell.isatty() { - // Move to the next line so that the escape codes below can move up a line and overwrite it with download progress - Current.logging.log("") - } else { - Current.logging.log("\(InstallationStep.downloading(version: xcode.version.description, progress: nil, willInstall: willInstall))") - } - let formatter = NumberFormatter(numberStyle: .percent) - var observation: NSKeyValueObservation? + } - let promise = self.downloadOrUseExistingArchive(for: xcode, downloader: downloader, willInstall: willInstall, progressChanged: { progress in - observation?.invalidate() - observation = progress.observe(\.fractionCompleted) { progress, _ in - guard Current.shell.isatty() else { return } + private func downloadXcode(version: Version, dataSource: DataSource, downloader: Downloader, willInstall: Bool) async throws -> (Xcode, URL) { + switch dataSource { + case .apple: + // When using the Apple data source, an authenticated session is required for both + // downloading the list of Xcodes as well as to actually download Xcode, so we'll + // establish our session right at the start. + try await sessionService.loginIfNeeded() - // 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)!, willInstall: willInstall))") - } - }) + case .xcodeReleases: + // When using the Xcode Releases data source, we only need to establish an anonymous + // session once we're ready to download Xcode. Doing that requires us to know the + // URL we want to download though (and we may not know that yet), so we don't need + // to do anything session-related quite yet. + try await sessionService.loginIfNeeded() + } - return promise - .get { _ in observation?.invalidate() } - .map { return (xcode, $0) } + if xcodeList.shouldUpdateBeforeDownloading(version: version) { + _ = try await xcodeList.updateAvailableXcodes(dataSource: dataSource) } - } - public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, willInstall: Bool, progressChanged: @escaping (Progress) -> Void) -> Promise { - // Check to see if the archive is in the expected path in case it was downloaded but failed to install - let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(xcode.version).\(xcode.filename.suffix(fromLast: "."))" - // aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete - let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2") - var aria2DownloadIsIncomplete = false - if case .aria2 = downloader, aria2DownloadMetadataPath.exists { - aria2DownloadIsIncomplete = true + guard let xcode = xcodeList.availableXcodes.first(withVersion: version) else { + throw Error.unavailableVersion(version) } - if Current.files.fileExistsAtPath(expectedArchivePath.string), aria2DownloadIsIncomplete == false { - if willInstall { - Current.logging.log("(1/\(InstallationStep.installStepCount)) Found existing archive that will be used for installation at \(expectedArchivePath).") - } else { - Current.logging.log("(1/\(InstallationStep.downloadStepCount)) Found existing archive at \(expectedArchivePath).") + + if willInstall { + let currentOSVersion = currentOSVersion() + switch XcodeCompatibilityService().status(for: xcode, currentOSVersion: currentOSVersion) { + case .supported: + break + case let .unsupported(requiredMacOSVersion, currentMacOSVersion): + Current.logging.log("Warning: Xcode \(xcode.version.appleDescription) requires macOS \(requiredMacOSVersion) or later. This Mac is running macOS \(currentMacOSVersion).".yellow) } - return Promise.value(expectedArchivePath.url) - } - else { - return downloader.download(url: xcode.url, to: expectedArchivePath, progressChanged: progressChanged) } - } - public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) -> Promise { - 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, experimentalUnxip: experimentalUnxip).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) - } + switch dataSource { + case .apple: + /// We already established a session for the Apple data source at the beginning of + /// this download, so we don't need to do anything session-related at this point. + break + + case .xcodeReleases: + /// Now that we've used Xcode Releases to determine what URL we should use to + /// download Xcode, we can use that to establish an anonymous session with Apple. + // As of Nov 2022, the `validateADCSession` return 403 forbidden for Xcode versions (works with runtimes) + // try await sessionService.validateADCSession(path: xcode.downloadPath) + // ------- + // We need the cookies from its response in order to download Xcodes though, + // so perform it here first just to be sure. + _ = try await Current.network.data(for: URLRequest.developerDownloads) } - .then { xcode -> Promise in - Current.logging.log(InstallationStep.cleaningArchive(archiveName: archiveURL.lastPathComponent, shouldDelete: emptyTrash).description) - if emptyTrash { - try Current.files.removeItem(at: archiveURL) - } - else { - 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 } + if Current.shell.isatty() { + // Move to the next line so that the escape codes below can move up a line and overwrite it with download progress + Current.logging.log("") + } else { + Current.logging.log("\(InstallationStep.downloading(version: xcode.version.description, progress: nil, willInstall: willInstall))") } - .then { xcode -> Promise in - if noSuperuser { - Current.logging.log(InstallationStep.finishing.description) - Current.logging.log("Skipping asking for superuser privileges.") - return Promise.value(xcode) + let versionDescription = xcode.version.description + let observation = ProgressObservation() + defer { observation.invalidate() } + + let url = try await downloadOrUseExistingArchive(for: xcode, downloader: downloader, willInstall: willInstall) { progress in + observation.observe(progress) { progress in + guard Current.shell.isatty() else { return } + + // These escape codes move up a line and then clear to the end + let progressString = NumberFormatter.localizedString(from: NSNumber(value: progress.fractionCompleted), number: .percent) + Current.logging.log("\u{1B}[1A\u{1B}[K\(InstallationStep.downloading(version: versionDescription, progress: progressString, willInstall: willInstall))") } - return self.postInstallXcode(xcode) } + return (xcode, url) } - public func postInstallXcode(_ xcode: InstalledXcode) -> 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") + public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, willInstall: Bool, progressChanged: @escaping @Sendable (Progress) -> Void) async throws -> URL { + let archive = XcodeArchive(xcode) + let archiveDownloader = XcodeArchiveDownloader(downloader) + let service = archiveService(downloader: downloader) + + if let existingArchiveURL = service.existingArchiveURL(for: archive, downloader: archiveDownloader) { + if willInstall { + Current.logging.log("(1/\(InstallationStep.installStepCount)) Found existing archive that will be used for installation at \(Path(url: existingArchiveURL)!).") + } else { + Current.logging.log("(1/\(InstallationStep.downloadStepCount)) Found existing archive at \(Path(url: existingArchiveURL)!).") } + return existingArchiveURL } - return firstly { () -> 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 } - } + return try await service.archiveURL(for: archive, downloader: archiveDownloader, progressChanged: progressChanged) } - public func uninstallXcode(_ versionString: String, directory: Path, emptyTrash: Bool) -> Promise { - return firstly { () -> Promise in - guard let version = Version(xcodeVersion: versionString) else { - Current.logging.log(Error.invalidVersion(versionString).legibleLocalizedDescription) - return chooseFromInstalledXcodesInteractively(currentPath: "", directory: directory) - } - - guard let installedXcode = Current.files.installedXcodes(directory).first(withVersion: version) else { - Current.logging.log(Error.versionNotInstalled(version).legibleLocalizedDescription) - return chooseFromInstalledXcodesInteractively(currentPath: "", directory: directory) + private func archiveService(downloader: Downloader) -> XcodeArchiveService { + XcodeArchiveService( + applicationSupportPath: .xcodesApplicationSupport, + fileExists: { Current.files.fileExistsAtPath($0.string) }, + download: { archive, destination, _, progressChanged in + try await downloader.download(url: archive.downloadURL, to: destination, progressChanged: progressChanged) } + ) + } - return Promise.value(installedXcode) - } - .map { installedXcode -> (InstalledXcode, URL?) in - if emptyTrash { - try Current.files.removeItem(at: installedXcode.path.url) - return (installedXcode, nil) - } - return (installedXcode, try Current.files.trashItem(at: installedXcode.path.url)) - } - .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(directory).sorted(by: { $0.version < $1.version }).last { - return selectXcodeAtPath(latestInstalledXcode.path.string) - .map { output in - Current.logging.log("Selected \(output.out)") - return (installedXcode, trashURL) - } + public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, emptyTrash: Bool, noSuperuser: Bool) async throws -> InstalledXcode { + let installedXcode: InstalledXcode + do { + installedXcode = try await xcodeArchiveInstallService(experimentalUnxip: experimentalUnxip, destination: destination) + .installArchivedXcode( + xcode, + at: archiveURL, + cleanArchive: { archiveURL in + if emptyTrash { + try Current.files.removeItem(at: archiveURL) + } else { + try Current.files.trashItem(at: archiveURL) + } } - else { - return Promise.value((installedXcode, trashURL)) + ) { step in + switch step { + case .unarchive(.unarchiving): + Current.logging.log(InstallationStep.unarchiving(experimentalUnxip: experimentalUnxip).description) + case let .unarchive(.moving(destination)): + Current.logging.log(InstallationStep.moving(destination: destination).description) + case let .cleaningArchive(archiveName): + Current.logging.log(InstallationStep.cleaningArchive(archiveName: archiveName, shouldDelete: emptyTrash).description) + case .checkingSecurity: + Current.logging.log(InstallationStep.checkingSecurity.description) } } + } catch { + throw mapXcodeArchiveInstallError(error, destination: destination) } - .done { (installedXcode, trashURL) in - if let trashURL = trashURL { - Current.logging.log("Xcode \(installedXcode.version.appleDescription) moved to Trash: \(trashURL.path)".green) - } - else { - Current.logging.log("Xcode \(installedXcode.version.appleDescription) deleted".green) - } - Current.shell.exit(0) - } - } - func update(dataSource: DataSource) -> Promise<[Xcode]> { - if dataSource == .apple { - return firstly { () -> Promise in - sessionService.loginIfNeeded() - } - .then { () -> Promise<[Xcode]> in - self.xcodeList.update(dataSource: dataSource) - } - } else { - return self.xcodeList.update(dataSource: dataSource) + if noSuperuser { + Current.logging.log(InstallationStep.finishing.description) + Current.logging.log("Skipping asking for superuser privileges.") + return installedXcode } + return try await postInstallXcode(installedXcode) } - public func updateAndPrint(dataSource: DataSource, directory: Path) -> Promise { - update(dataSource: dataSource) - .then { xcodes -> Promise in - self.printAvailableXcodes(xcodes, installed: Current.files.installedXcodes(directory)) - } - .done { - Current.shell.exit(0) - } + public func postInstallXcode(_ xcode: InstalledXcode) async throws -> InstalledXcode { + try await postInstallXcode(xcode, passwordInput: { + Current.logging.log("xcodes requires superuser privileges in order to finish installation.") + guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { throw Error.missingSudoerPassword } + return password + "\n" + }) } - public func printAvailableXcodes(_ xcodes: [Xcode], installed installedXcodes: [InstalledXcode]) -> Promise { - struct ReleasedVersion { - let version: Version - let releaseDate: Date? - } + public func postInstallXcode(_ xcode: InstalledXcode, passwordInput: @escaping @Sendable () async throws -> String) async throws -> InstalledXcode { + Current.logging.log(InstallationStep.finishing.description) + try await xcodePostInstallWorkflowService(passwordInput: passwordInput) + .performPostInstallSteps(for: xcode) + return xcode + } - 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.isEquivalent(to: 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.isEquivalent(to: installedXcode.version) && - releasedVersion.version.buildMetadataIdentifiers.isEmpty - }) { - allXcodeVersions[index] = ReleasedVersion(version: installedXcode.version, releaseDate: nil) + public func uninstallXcode(_ versionString: String, directory: Path, emptyTrash: Bool) async throws { + let installedXcode: InstalledXcode + if let version = Version(xcodeVersion: versionString), + let matchingXcode = Current.files.installedXcodes(directory).first(withVersion: version) { + installedXcode = matchingXcode + } else { + if let version = Version(xcodeVersion: versionString) { + Current.logging.log(Error.versionNotInstalled(version).legibleLocalizedDescription) + } else { + Current.logging.log(Error.invalidVersion(versionString).legibleLocalizedDescription) } + installedXcode = try await chooseFromInstalledXcodesInteractivelyAsync(currentPath: "", directory: directory) } - return Current.shell.xcodeSelectPrintPath() - .done { output in - let selectedInstalledXcodeVersion = installedXcodes.first { output.out.hasPrefix($0.path.string) }.map { $0.version } + let result = try XcodesKit.XcodeUninstallService( + removeItem: { url in try Current.files.removeItem(at: url) }, + trashItem: { url in try Current.files.trashItem(at: url) } + ).uninstall(installedXcode, emptyTrash: emptyTrash) - 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.appleDescriptionWithBuildIdentifier - if installedXcodes.contains(where: { releasedVersion.version.isEquivalent(to: $0.version) }) { - if releasedVersion.version == selectedInstalledXcodeVersion { - output += " (\("Installed".blue), \("Selected".green))" - } - else { - output += " (\("Installed".blue))" - } - } - Current.logging.log(output) - } - } + if let trashURL = result.trashURL { + Current.logging.log("Xcode \(installedXcode.version.appleDescription) moved to Trash: \(trashURL.path)".green) + } else { + Current.logging.log("Xcode \(installedXcode.version.appleDescription) deleted".green) + } + Current.shell.exit(0) } - public func printInstalledXcodes(directory: Path) -> Promise { - Current.shell.xcodeSelectPrintPath() - .done { pathOutput in - let installedXcodes = Current.files.installedXcodes(directory) - .sorted { $0.version < $1.version } - let selectedString = "(Selected)" - - let lines = installedXcodes.map { installedXcode -> String in - var line = installedXcode.version.appleDescriptionWithBuildIdentifier + func update(dataSource: DataSource) async throws -> [Xcode] { + if dataSource == .apple { + try await sessionService.loginIfNeeded() + } + return try await xcodeList.updateAvailableXcodes(dataSource: dataSource) + } - if pathOutput.out.hasPrefix(installedXcode.path.string) { - line += " " + selectedString - } + public func updateAndPrint(dataSource: DataSource, directory: Path, architectures: [ArchitectureFilter] = []) async throws { + let xcodes = try await update(dataSource: dataSource) + try await printAvailableXcodes(xcodes, installed: Current.files.installedXcodes(directory), dataSource: dataSource, architectures: architectures) + Current.shell.exit(0) + } - return line + public func printAvailableXcodes(_ xcodes: [Xcode], installed installedXcodes: [InstalledXcode], dataSource: DataSource = .xcodeReleases, architectures: [ArchitectureFilter] = []) async throws { + let output = try await Current.shell.xcodeSelectPrintPath() + let machineArchitecture = Current.shell.machineArchitecture() + let effectiveArchitectures = architectures.isEmpty + ? [ArchitectureFilter].defaultForMachine(machineHardwareName: machineArchitecture) + : architectures + + XcodeListPresentationService() + .availableRows( + availableXcodes: xcodes, + installedXcodes: installedXcodes, + selectedXcodePath: output.out, + dataSource: dataSource, + architectures: effectiveArchitectures + ) + .forEach { row in + var output = row.versionDescription + if row.isInstalled { + output += row.isSelected + ? " (\("Installed".blue), \("Selected".green))" + : " (\("Installed".blue))" } + Current.logging.log(output) + } - // Add one so there's always at least one space between columns - let maxWidthOfFirstColumn = (lines.map(\.count).max() ?? 0) + 1 + if architectures.isEmpty { + let defaultVariant = ArchitectureVariant.defaultForMachine(machineHardwareName: machineArchitecture) + let machineDescription = machineArchitecture ?? "unknown" + Current.logging.log("\nShowing Xcodes for this Mac by default: \(defaultVariant.displayString) (\(machineDescription)). Switch with `--architecture arm64`, `--architecture x86_64`, `--architecture appleSilicon`, or `--architecture universal`.") + } + } - for (index, installedXcode) in installedXcodes.enumerated() { - var line = lines[index] - let widthOfFirstColumnInThisRow = line.count - let spaceBetweenFirstAndSecondColumns = maxWidthOfFirstColumn - widthOfFirstColumnInThisRow + public func printInstalledXcodes(directory: Path) async throws { + let pathOutput = try await Current.shell.xcodeSelectPrintPath() + let selectedString = "(Selected)" + let presentationService = XcodeListPresentationService() + let rows = presentationService.installedRows( + installedXcodes: Current.files.installedXcodes(directory), + selectedXcodePath: pathOutput.out + ) - line = line.replacingOccurrences(of: selectedString, with: selectedString.green) + for line in presentationService.installedLines(rows: rows, interactive: Current.shell.isatty(), selectedMarker: selectedString) { + Current.logging.log(line.replacingOccurrences(of: selectedString, with: selectedString.green)) + } + } - // If outputting to an interactive terminal, align the columns so they're easier for a human to read - // Otherwise, separate columns by a tab character so it's easier for a computer to split up - if Current.shell.isatty() { - line += Array(repeating: " ", count: max(spaceBetweenFirstAndSecondColumns, 0)) - line += "\(installedXcode.path.string)" - } else { - line += "\t\(installedXcode.path.string)" - } + private func xcodeArchiveInstallService(experimentalUnxip: Bool, destination: Path) -> XcodesKit.XcodeArchiveInstallService { + XcodesKit.XcodeArchiveInstallService( + destinationDirectory: destination, + unarchiveService: xcodeUnarchiveService(experimentalUnxip: experimentalUnxip), + validationService: xcodeValidationService, + fileExists: { path in Current.files.fileExists(atPath: path) }, + makeInstalledXcode: { path in + InstalledXcode( + path: path, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + ) + } + ) + } - Current.logging.log(line) - } + private func mapXcodeArchiveInstallError(_ error: Swift.Error, destination: Path) -> Swift.Error { + switch error { + case let error as XcodesKit.XcodeArchiveInstallError: + switch error { + case let .failedToMoveXcodeToDestination(destination): + return Error.failedToMoveXcodeToDestination(destination) + case let .unsupportedFileFormat(fileExtension): + return Error.unsupportedFileFormat(extension: fileExtension) + } + case let error as XcodesKit.XcodeUnarchiveError: + switch error { + case let .damagedXIP(url): + return Error.damagedXIP(url: url) + case let .notEnoughFreeSpaceToExpandArchive(url): + return Error.notEnoughFreeSpaceToExpandArchive(url: url) } + case let error as XcodesKit.XcodeValidationError: + switch error { + case let .failedSecurityAssessment(xcode, output): + return Error.failedSecurityAssessment(xcode: xcode, output: output) + case let .codesignVerifyFailed(output): + return Error.codesignVerifyFailed(output: output) + case let .unexpectedCodeSigningIdentity(identifier, certificateAuthority): + return Error.unexpectedCodeSigningIdentity( + identifier: identifier, + certificateAuthority: certificateAuthority + ) + } + default: + return error + } } - public func printXcodePath(ofVersion versionString: String, searchingIn directory: Path) -> Promise { - return firstly { () -> Promise in + public func printXcodePath(ofVersion versionString: String, searchingIn directory: Path) async throws { guard let version = Version(xcodeVersion: versionString) else { throw Error.invalidVersion(versionString) } @@ -615,162 +602,112 @@ public final class XcodeInstaller { throw Error.versionNotInstalled(version) } Current.logging.log(installedXcode.path.string) - return Promise.value(()) - } } - func unarchiveAndMoveXIP(at source: URL, to destination: URL, experimentalUnxip: Bool) -> Promise { - return firstly { () -> Promise in - Current.logging.log(InstallationStep.unarchiving(experimentalUnxip: experimentalUnxip).description) - - if experimentalUnxip, #available(macOS 11, *) { - return Promise { seal in - Task.detached { - let output = source.deletingLastPathComponent() - let options = UnxipOptions(input: source, output: output) - - do { - try await Unxip(options: options).run() - seal.resolve(.fulfilled(())) - } catch { - seal.reject(error) - } - } - } - } - - 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 + private func xcodeUnarchiveService(experimentalUnxip: Bool) -> XcodesKit.XcodeUnarchiveService { + XcodesKit.XcodeUnarchiveService( + unarchive: { source in + if experimentalUnxip, #available(macOS 11, *) { + let output = source.deletingLastPathComponent() + let options = UnxipOptions(input: source, output: output) + try await Unxip(options: options).run() + } else { + _ = try await Current.shell.unxip(source) } - .map { _ in () } - } - .map { _ -> 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 - } + }, + fileExists: { path in Current.files.fileExists(atPath: path) }, + moveItem: { source, destination in try Current.files.moveItem(at: source, to: destination) }, + removeItem: { url in try Current.files.removeItem(at: url) } + ) } - 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") - } + public func verifySecurityAssessment(of xcode: InstalledXcode) async throws { + do { + try await xcodeValidationService.verifySecurityAssessment(of: xcode) + } catch let error as XcodesKit.XcodeValidationError { + switch error { + case let .failedSecurityAssessment(xcode, output): throw Error.failedSecurityAssessment(xcode: xcode, output: output) + case let .codesignVerifyFailed(output): + throw Error.codesignVerifyFailed(output: output) + case let .unexpectedCodeSigningIdentity(identifier, certificateAuthority): + throw Error.unexpectedCodeSigningIdentity( + identifier: identifier, + certificateAuthority: certificateAuthority + ) } - .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") - } + func verifySigningCertificate(of url: URL) async throws { + do { + try await xcodeValidationService.verifySigningCertificate(of: url) + } catch let error as XcodesKit.XcodeValidationError { + switch error { + case let .failedSecurityAssessment(xcode, output): + throw Error.failedSecurityAssessment(xcode: xcode, output: output) + case let .codesignVerifyFailed(output): throw Error.codesignVerifyFailed(output: output) + case let .unexpectedCodeSigningIdentity(identifier, certificateAuthority): + throw Error.unexpectedCodeSigningIdentity( + identifier: identifier, + certificateAuthority: certificateAuthority + ) } - .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) -> XcodesKit.XcodeSignature { + XcodesKit.XcodeSignatureVerifier().parse(rawInfo) } - public func parseCertificateInfo(_ rawInfo: String) -> CertificateInfo { - var info = CertificateInfo(authority: [], teamIdentifier: "", bundleIdentifier: "") + private var xcodeValidationService: XcodesKit.XcodeValidationService { + XcodesKit.XcodeValidationService( + assessSecurity: { url in try await Current.shell.spctlAssess(url) }, + verifyCodesign: { url in try await Current.shell.codesignVerify(url) } + ) + } - 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] - } - } + private func xcodePostInstallWorkflowService(passwordInput: @escaping @Sendable () async throws -> String) -> XcodesKit.XcodePostInstallWorkflowService { + return XcodesKit.XcodePostInstallWorkflowService( + enableDeveloperMode: { try await Self.enableDeveloperMode(passwordInput: passwordInput) }, + approveLicense: { try await Self.approveLicense(for: $0, passwordInput: passwordInput) }, + installComponents: { try await Self.installComponents(for: $0, passwordInput: passwordInput) } + ) + } - return info + private static func enableDeveloperMode(passwordInput: @escaping @Sendable () async throws -> String) async throws { + let possiblePassword = try await Current.shell.authenticateSudoerIfNecessaryAsync(passwordInput: passwordInput) + try await xcodePostInstallPreparationService(password: possiblePassword).enableDeveloperMode() } - 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() - } + private static func approveLicense(for xcode: InstalledXcode, passwordInput: @escaping @Sendable () async throws -> String) async throws { + let possiblePassword = try await Current.shell.authenticateSudoerIfNecessaryAsync(passwordInput: passwordInput) + try await xcodePostInstallPreparationService(password: possiblePassword).approveLicense(for: xcode) } - 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() - } + private static func installComponents(for xcode: InstalledXcode, passwordInput: @escaping @Sendable () async throws -> String) async throws { + let possiblePassword = try await Current.shell.authenticateSudoerIfNecessaryAsync(passwordInput: passwordInput) + try await xcodePostInstallService(password: possiblePassword).installComponents(for: xcode) } - 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 static func xcodePostInstallService(password: String?) -> XcodesKit.XcodePostInstallService { + XcodesKit.XcodePostInstallService( + runFirstLaunch: { xcode in _ = try await Current.shell.runFirstLaunch(xcode, password) }, + getUserCacheDirectory: { try await Current.shell.getUserCacheDir() }, + getMacOSBuildVersion: { try await Current.shell.buildVersion() }, + getXcodeBuildVersion: { xcode in try await Current.shell.xcodeBuildVersion(xcode) }, + touchInstallCheck: { cacheDirectory, macOSBuildVersion, toolsVersion in + try await Current.shell.touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion) + } + ) } -} -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) - } + private static func xcodePostInstallPreparationService(password: String?) -> XcodesKit.XcodePostInstallPreparationService { + XcodesKit.XcodePostInstallPreparationService( + enableDeveloperTools: { _ = try await Current.shell.devToolsSecurityEnable(password) }, + addStaffToDevelopersGroup: { _ = try await Current.shell.addStaffToDevelopersGroup(password) }, + acceptLicense: { xcode in _ = try await Current.shell.acceptXcodeLicense(xcode, password) } + ) } } diff --git a/Sources/XcodesKit/XcodeList.swift b/Sources/XcodesKit/XcodeList.swift index b46c970d..22a738ff 100644 --- a/Sources/XcodesKit/XcodeList.swift +++ b/Sources/XcodesKit/XcodeList.swift @@ -1,194 +1,65 @@ import Foundation +import os import Path -import Version -import PromiseKit -import SwiftSoup -import struct XCModel.Xcode +@preconcurrency import Version +import XcodesKit /// Provides lists of available and installed Xcodes -public final class XcodeList { +public final class XcodeList: Sendable { public init() { - try? loadCachedAvailableXcodes() + var store = Self.makeStore() + try? store.loadCachedAvailableXcodes() + self.store = OSAllocatedUnfairLock(initialState: store) } - public private(set) var availableXcodes: [Xcode] = [] - public private(set) var lastUpdated: Date? + private let store: OSAllocatedUnfairLock - public var shouldUpdateBeforeListingVersions: Bool { - return availableXcodes.isEmpty || (cacheAge ?? 0) > Self.maxCacheAge + public var availableXcodes: [Xcode] { + store.withLock { $0.availableXcodes } } - public func shouldUpdateBeforeDownloading(version: Version) -> Bool { - return availableXcodes.first(withVersion: version) == nil + public var lastUpdated: Date? { + store.withLock { $0.lastUpdated } } - public func update(dataSource: DataSource) -> Promise<[Xcode]> { - switch dataSource { - case .apple: - 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.isEquivalent(to: releasedXcode.version) } == false - } + prereleaseXcodes - self.availableXcodes = xcodes - self.lastUpdated = Date() - try? self.cacheAvailableXcodes(xcodes) - return xcodes - } - case .xcodeReleases: - return xcodeReleases() - .map { xcodes in - self.availableXcodes = xcodes - self.lastUpdated = Date() - try? self.cacheAvailableXcodes(xcodes) - return xcodes - } - } + public var shouldUpdateBeforeListingVersions: Bool { + store.withLock { $0.shouldUpdateBeforeListingVersions } } -} - -extension XcodeList { - private static let maxCacheAge = TimeInterval(86400) // 24 hours - private var cacheAge: TimeInterval? { - guard let lastUpdated = lastUpdated else { return nil } - return -lastUpdated.timeIntervalSinceNow + public func shouldUpdateBeforeDownloading(version: Version) -> Bool { + store.withLock { $0.shouldUpdateBeforeDownloading(version: version) } } - 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) - - let attributes = try? Current.files.attributesOfItem(atPath: Path.cacheFile.string) - let lastUpdated = attributes?[.modificationDate] as? Date - - self.availableXcodes = xcodes - self.lastUpdated = lastUpdated + public func updateAvailableXcodes(dataSource: DataSource) async throws -> [Xcode] { + var updatedStore = store.withLock { $0 } + let xcodes = try await updatedStore.updateAvailableXcodes(from: dataSource) + let finishedStore = updatedStore + store.withLock { $0 = finishedStore } + return 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 Current.files.write(data, to: Path.cacheFile.url) - } } - extension XcodeList { - // MARK: - Apple - - 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 static func makeStore() -> XcodesKit.XcodeListStore { + let service = XcodesKit.XcodeListService { request in + let result = try await Current.network.data(for: request) + return (result.data, result.response) } - } - - 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))] + let cache = XcodesKit.AvailableXcodeCache( + cacheFile: .cacheFile, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + writeData: { data, url in try Current.files.write(data, to: url) }, + attributesOfItem: { path in try Current.files.attributesOfItem(atPath: path) } + ) + return XcodesKit.XcodeListStore(cache: cache, service: service) } } extension XcodeList { - // MARK: - XcodeReleases - - private func xcodeReleases() -> Promise<[Xcode]> { - return firstly { () -> Promise<(data: Data, response: URLResponse)> in - Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!)) - } - .map { (data, response) in - let decoder = JSONDecoder() - let xcReleasesXcodes = try decoder.decode([XCModel.Xcode].self, from: data) - let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> Xcode? in - guard - let downloadURL = xcReleasesXcode.links?.download?.url, - let version = Version(xcReleasesXcode: xcReleasesXcode) - else { return nil } - - let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents( - year: xcReleasesXcode.date.year, - month: xcReleasesXcode.date.month, - day: xcReleasesXcode.date.day - )) - - return Xcode( - version: version, - url: downloadURL, - filename: String(downloadURL.path.suffix(fromLast: "/")), - releaseDate: releaseDate - ) - } - return xcodes - } - .map(filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers) + // MARK: - Apple + + func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] { + try XcodesKit.XcodeListService.parsePrereleaseXcodes(from: data) + .map(AvailableXcode.init) } - - /// Xcode Releases may have multiple releases with the same build metadata when a build doesn't change between candidate and final releases. - /// For example, 12.3 RC and 12.3 are both build 12C33 - /// We don't care about that difference, so only keep the final release (GM or Release, in XCModel terms). - /// The downside of this is that a user could technically have both releases installed, and so they won't both be shown in the list, but I think most users wouldn't do this. - func filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers(_ xcodes: [Xcode]) -> [Xcode] { - var filteredXcodes: [Xcode] = [] - for xcode in xcodes { - if xcode.version.buildMetadataIdentifiers.isEmpty { - filteredXcodes.append(xcode) - continue - } - - let xcodesWithSameBuildMetadataIdentifiers = xcodes - .filter({ $0.version.buildMetadataIdentifiers == xcode.version.buildMetadataIdentifiers }) - if xcodesWithSameBuildMetadataIdentifiers.count > 1, - xcode.version.prereleaseIdentifiers.isEmpty || xcode.version.prereleaseIdentifiers == ["GM"] { - filteredXcodes.append(xcode) - } else if xcodesWithSameBuildMetadataIdentifiers.count == 1 { - filteredXcodes.append(xcode) - } - } - return filteredXcodes - } } diff --git a/Sources/XcodesKit/XcodeSelect.swift b/Sources/XcodesKit/XcodeSelect.swift index 7a4c1931..42ae96c9 100644 --- a/Sources/XcodesKit/XcodeSelect.swift +++ b/Sources/XcodesKit/XcodeSelect.swift @@ -1,98 +1,80 @@ import Foundation -import PromiseKit import Path import Version import Rainbow +import XcodesKit -public func selectXcode(shouldPrint: Bool, pathOrVersion: String, directory: Path, fallbackToInteractive: Bool = true) -> 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(()) - } - } - - let versionToSelect = pathOrVersion.isEmpty ? Version.fromXcodeVersionFile() : Version(xcodeVersion: pathOrVersion) - let installedXcodes = Current.files.installedXcodes(directory) - if let version = versionToSelect, - let installedXcode = installedXcodes.first(withVersion: version) { - let selectedInstalledXcodeVersion = installedXcodes.first { output.out.hasPrefix($0.path.string) }.map { $0.version } - if installedXcode.version == selectedInstalledXcodeVersion { - Current.logging.log("Xcode \(version) is already selected".green) - Current.shell.exit(0) - return Promise.value(()) - } +public func selectXcodeAsync(shouldPrint: Bool, pathOrVersion: String, directory: Path, fallbackToInteractive: Bool = true) async throws { + let output = try await Current.shell.xcodeSelectPrintPath() - return selectXcodeAtPath(installedXcode.path.string) - .done { output in - Current.logging.log("Selected \(output.out)".green) - Current.shell.exit(0) - } + if shouldPrint { + if output.out.isEmpty == false { + Current.logging.log(output.out) + } else { + Current.logging.log("No selected Xcode") } - else { - let pathToSelect = pathOrVersion.trimmingCharacters(in: .whitespacesAndNewlines) - let currentPath = output.out.trimmingCharacters(in: .whitespacesAndNewlines) - - if pathToSelect == currentPath { - Current.logging.log("Xcode at path \(pathOrVersion) is already selected".green) - Current.shell.exit(0) - return Promise.value(()) - } + Current.shell.exit(0) + return + } - let selectPromise = selectXcodeAtPath(pathToSelect) - .done { output in - Current.logging.log("Selected \(output.out)".green) - Current.shell.exit(0) - } - if fallbackToInteractive { - return selectPromise - .recover { _ in - selectXcodeInteractively(currentPath: output.out, directory: directory) - .done { output in - Current.logging.log("Selected \(output.out)".green) - Current.shell.exit(0) - } - } - } else { - return selectPromise - } + let installedXcodes = Current.files.installedXcodes(directory) + let selectionService = XcodeSelectionService(versionFile: XcodeVersionFileService( + fileExists: { path in Current.files.fileExists(atPath: path) }, + contentsAtPath: { path in Current.files.contents(atPath: path) } + )) + + switch selectionService.request( + pathOrVersion: pathOrVersion, + installedXcodes: installedXcodes, + selectedXcodePath: output.out + ) { + case let .alreadySelectedVersion(version): + Current.logging.log("Xcode \(version) is already selected".green) + Current.shell.exit(0) + return + case let .selectInstalledXcode(installedXcode): + let selectedOutput = try await selectXcodeAtPathAsync(installedXcode.path.string) + Current.logging.log("Selected \(selectedOutput.out)".green) + Current.shell.exit(0) + return + case .alreadySelectedPath: + Current.logging.log("Xcode at path \(pathOrVersion) is already selected".green) + Current.shell.exit(0) + return + case let .selectPath(pathToSelect): + do { + let selectedOutput = try await selectXcodeAtPathAsync(pathToSelect) + Current.logging.log("Selected \(selectedOutput.out)".green) + Current.shell.exit(0) + } catch { + guard fallbackToInteractive else { throw error } + let selectedOutput = try await selectXcodeInteractivelyAsync(currentPath: output.out, directory: directory) + Current.logging.log("Selected \(selectedOutput.out)".green) + Current.shell.exit(0) } } } -public func selectXcodeInteractively(currentPath: String, directory: Path, shouldRetry: Bool) -> Promise { +public func selectXcodeInteractivelyAsync(currentPath: String, directory: Path, shouldRetry: Bool) async throws -> ProcessOutput { if shouldRetry { - func selectWithRetry(currentPath: String) -> Promise { - return firstly { - selectXcodeInteractively(currentPath: currentPath, directory: directory) - } - .recover { error throws -> Promise in - guard case XcodeSelectError.invalidIndex = error else { throw error } + while true { + do { + return try await selectXcodeInteractivelyAsync(currentPath: currentPath, directory: directory) + } catch let error as XcodeSelectError { + guard case .invalidIndex = error else { throw error } Current.logging.log("\(error.legibleLocalizedDescription)\n".red) - return selectWithRetry(currentPath: currentPath) } } - - return selectWithRetry(currentPath: currentPath) - } - else { - return firstly { - selectXcodeInteractively(currentPath: currentPath, directory: directory) - } + } else { + return try await selectXcodeInteractivelyAsync(currentPath: currentPath, directory: directory) } } -public func chooseFromInstalledXcodesInteractively(currentPath: String, directory: Path) -> Promise { +public func chooseFromInstalledXcodesInteractivelyAsync(currentPath: String, directory: Path) async throws -> InstalledXcode { + try chooseFromInstalledXcodesInteractivelySync(currentPath: currentPath, directory: directory) +} + +private func chooseFromInstalledXcodesInteractivelySync(currentPath: String, directory: Path) throws -> InstalledXcode { let sortedInstalledXcodes = Current.files.installedXcodes(directory).sorted { $0.version < $1.version } Current.logging.log("Available Xcode versions:") @@ -108,46 +90,40 @@ public func chooseFromInstalledXcodesInteractively(currentPath: String, director } 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 { - let error = XcodeSelectError.invalidIndex(min: 1, max: sortedInstalledXcodes.count, given: possibleSelectionNumberString) - return Promise(error: error) + do { + return try XcodeSelectionService().installedXcode( + fromSelection: possibleSelectionNumberString, + installedXcodes: sortedInstalledXcodes + ) + } catch let error as XcodeSelectionError { + switch error { + case let .invalidIndex(min, max, given): + throw XcodeSelectError.invalidIndex(min: min, max: max, given: given) + } } - - return Promise.value(sortedInstalledXcodes[selectionNumber - 1]) } -public func selectXcodeInteractively(currentPath: String, directory: Path) -> Promise { - return chooseFromInstalledXcodesInteractively(currentPath: currentPath, directory: directory) - .map(\.path.string) - .then(selectXcodeAtPath) +public func selectXcodeInteractivelyAsync(currentPath: String, directory: Path) async throws -> ProcessOutput { + let selectedXcode = try await chooseFromInstalledXcodesInteractivelyAsync(currentPath: currentPath, directory: directory) + return try await selectXcodeAtPathAsync(selectedXcode.path.string) } -public func selectXcodeAtPath(_ pathString: String) -> Promise { - firstly { () -> Promise in - guard Current.files.fileExists(atPath: pathString) else { - throw XcodeSelectError.invalidPath(pathString) - } +public func selectXcodeAtPathAsync(_ pathString: String) async throws -> ProcessOutput { + 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") - } + let passwordInput: @Sendable () async throws -> String = { + Current.logging.log("xcodes requires superuser privileges to select an Xcode") + guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { + throw XcodeInstaller.Error.missingSudoerPassword } - - return Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput) - } - .then { possiblePassword in - Current.shell.xcodeSelectSwitch(password: possiblePassword, path: pathString) - } - .then { _ in - Current.shell.xcodeSelectPrintPath() + return password + "\n" } + + let possiblePassword = try await Current.shell.authenticateSudoerIfNecessaryAsync(passwordInput: passwordInput) + _ = try await Current.shell.xcodeSelectSwitch(possiblePassword, pathString) + return try await Current.shell.xcodeSelectPrintPath() } public enum XcodeSelectError: LocalizedError { diff --git a/Sources/xcodes/App.swift b/Sources/xcodes/App.swift index 95e9fff6..36ee7e5e 100644 --- a/Sources/xcodes/App.swift +++ b/Sources/xcodes/App.swift @@ -1,11 +1,15 @@ import Foundation -import ArgumentParser +@preconcurrency import ArgumentParser import Version -import PromiseKit +import XcodesCLIKit import XcodesKit import LegibleError import Path -import Rainbow +@preconcurrency import Rainbow + +func configureRainbow(enabled: Bool) { + Rainbow.enabled = Rainbow.enabled && enabled +} func getDirectory(possibleDirectory: String?, default: Path = Path.root.join("Applications")) -> Path { let directory = possibleDirectory.flatMap(Path.init) ?? @@ -51,28 +55,50 @@ struct GlobalColorOption: ParsableArguments { var color: Bool = true } +extension ArchitectureFilter: @retroactive ExpressibleByArgument { + public init?(argument: String) { + self.init(argument) + } +} + @main struct Xcodes: AsyncParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( abstract: "Manage the Xcodes installed on your Mac", shouldDisplay: true, subcommands: [Download.self, Install.self, Installed.self, List.self, Runtimes.self, Select.self, Uninstall.self, Update.self, Version.self, Signout.self] ) - static var xcodesConfiguration = Configuration() - static var sessionService: AppleSessionService! - static let xcodeList = XcodeList() - static let runtimeList = RuntimeList() - static var runtimeInstaller: RuntimeInstaller! - static var xcodeInstaller: XcodeInstaller! - static var fastlaneSessionManager: FastlaneSessionManager! + private struct Services { + let sessionService: AppleSessionService + let xcodeList: XcodeList + let runtimeInstaller: RuntimeInstaller + let xcodeInstaller: XcodeInstaller + let fastlaneSessionManager: FastlaneSessionManager + } - static func main() async { + private static func makeServices() -> Services { + var xcodesConfiguration = Configuration() try? xcodesConfiguration.load() - sessionService = AppleSessionService(configuration: xcodesConfiguration) - xcodeInstaller = XcodeInstaller(xcodeList: xcodeList, sessionService: sessionService) - runtimeInstaller = RuntimeInstaller(runtimeList: runtimeList, sessionService: sessionService) - fastlaneSessionManager = FastlaneSessionManager() + let sessionService = AppleSessionService(configuration: xcodesConfiguration) + let xcodeList = XcodeList() + let runtimeList = RuntimeList() + return Services( + sessionService: sessionService, + xcodeList: xcodeList, + runtimeInstaller: RuntimeInstaller(runtimeList: runtimeList, sessionService: sessionService), + xcodeInstaller: XcodeInstaller(xcodeList: xcodeList, sessionService: sessionService), + fastlaneSessionManager: FastlaneSessionManager() + ) + } + + private static func availableXcodeCompletions() -> [String] { + XcodeList().availableXcodes + .sorted { $0.version < $1.version } + .map { $0.version.appleDescription } + } + + static func main() async { migrateApplicationSupportFiles() do { var command = try parseAsRoot() @@ -86,8 +112,8 @@ struct Xcodes: AsyncParsableCommand { } } - struct Download: ParsableCommand { - static var configuration = CommandConfiguration( + struct Download: AsyncParsableCommand { + static let configuration = CommandConfiguration( abstract: "Download a specific version of Xcode", discussion: """ By default, xcodes will use a URLSession to download the specified version. If aria2 (https://aria2.github.io, available in Homebrew) is installed, either somewhere in PATH or at the path specified by the --aria2 flag, then it will be used instead. aria2 will use up to 16 connections to download Xcode 3-5x faster. If you have aria2 installed and would prefer to not use it, you can use the --no-aria2 flag. @@ -102,7 +128,7 @@ struct Xcodes: AsyncParsableCommand { ) @Argument(help: "The version to download", - completion: .custom { args in xcodeList.availableXcodes.sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) + completion: .custom { _ in Xcodes.availableXcodeCompletions() }) var version: [String] = [] @Flag(help: "Update and then download the latest release version available.") @@ -135,8 +161,9 @@ struct Xcodes: AsyncParsableCommand { @OptionGroup var globalColor: GlobalColorOption - func run() { - Rainbow.enabled = Rainbow.enabled && globalColor.color + func run() async throws { + configureRainbow(enabled: globalColor.color) + let services = Xcodes.makeServices() let versionString = version.joined(separator: " ") @@ -155,20 +182,19 @@ struct Xcodes: AsyncParsableCommand { let destination = getDirectory(possibleDirectory: directory, default: .environmentDownloads) if useFastlaneAuth { - fastlaneSessionManager.setupFastlaneAuth(fastlaneUser: fastlaneUser) + services.fastlaneSessionManager.setupFastlaneAuth(fastlaneUser: fastlaneUser) } - xcodeInstaller.download(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destinationDirectory: destination) - .catch { error in - Install.processDownloadOrInstall(error: error) - } - - RunLoop.current.run() + do { + try await services.xcodeInstaller.download(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destinationDirectory: destination) + } catch { + Install.processDownloadOrInstall(error: error) + } } } - struct Install: ParsableCommand { - static var configuration = CommandConfiguration( + struct Install: AsyncParsableCommand { + static let configuration = CommandConfiguration( abstract: "Download and install a specific version of Xcode", discussion: """ By default, xcodes will use a URLSession to download the specified version. If aria2 (https://aria2.github.io, available in Homebrew) is installed, either somewhere in PATH or at the path specified by the --aria2 flag, then it will be used instead. aria2 will use up to 16 connections to download Xcode 3-5x faster. If you have aria2 installed and would prefer to not use it, you can use the --no-aria2 flag. @@ -184,7 +210,7 @@ struct Xcodes: AsyncParsableCommand { ) @Argument(help: "The version to install", - completion: .custom { args in xcodeList.availableXcodes.sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) + completion: .custom { _ in Xcodes.availableXcodeCompletions() }) var version: [String] = [] @Option(name: .customLong("path"), @@ -240,8 +266,9 @@ struct Xcodes: AsyncParsableCommand { @OptionGroup var globalColor: GlobalColorOption - func run() { - Rainbow.enabled = Rainbow.enabled && globalColor.color + func run() async throws { + configureRainbow(enabled: globalColor.color) + let services = Xcodes.makeServices() let versionString = version.joined(separator: " ") @@ -261,63 +288,49 @@ struct Xcodes: AsyncParsableCommand { let destination = getDirectory(possibleDirectory: directory) if select, case .version(let version) = installation { - firstly { - selectXcode(shouldPrint: print, pathOrVersion: version, directory: destination, fallbackToInteractive: false) - } - .catch { _ in - install(installation, using: downloader, to: destination) + do { + try await selectXcodeAsync(shouldPrint: print, pathOrVersion: version, directory: destination, fallbackToInteractive: false) + } catch { + try await install(installation, using: downloader, to: destination, services: services) } } else { - install(installation, using: downloader, to: destination) + try await install(installation, using: downloader, to: destination, services: services) } - - RunLoop.current.run() } private func install(_ installation: XcodeInstaller.InstallationType, using downloader: Downloader, - to destination: Path) { - firstly { () -> Promise in - if useFastlaneAuth { fastlaneSessionManager.setupFastlaneAuth(fastlaneUser: fastlaneUser) } + to destination: Path, + services: Xcodes.Services) async throws { + do { + if useFastlaneAuth { services.fastlaneSessionManager.setupFastlaneAuth(fastlaneUser: fastlaneUser) } // update the list before installing only for version type because the other types already update internally if update, case .version = installation { Current.logging.log("Updating...") - return xcodeList.update(dataSource: globalDataSource.dataSource) - .then { _ -> Promise in - xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) - } - } else { - return xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) + _ = try await services.xcodeList.updateAvailableXcodes(dataSource: globalDataSource.dataSource) } - } - .recover { error -> Promise in + + let xcode = try await services.xcodeInstaller.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) + if select { + try await selectXcodeAsync(shouldPrint: print, pathOrVersion: xcode.path.string, directory: destination, fallbackToInteractive: false) + } + Install.exit() + } catch { if select, case let XcodeInstaller.Error.versionAlreadyInstalled(installedXcode) = error { Current.logging.log(error.legibleLocalizedDescription.green) - return Promise { seal in - seal.fulfill(installedXcode) + if select { + try await selectXcodeAsync(shouldPrint: print, pathOrVersion: installedXcode.path.string, directory: destination, fallbackToInteractive: false) } + Install.exit() } else { - throw error - } - } - .then { xcode -> Promise in - if select { - return selectXcode(shouldPrint: print, pathOrVersion: xcode.path.string, directory: destination, fallbackToInteractive: false) - } else { - return .init() + Install.processDownloadOrInstall(error: error) } } - .done { - Install.exit() - } - .catch { error in - Install.processDownloadOrInstall(error: error) - } } } - struct Installed: ParsableCommand { - static var configuration = CommandConfiguration( + struct Installed: AsyncParsableCommand { + static let configuration = CommandConfiguration( abstract: "List the versions of Xcode that are installed" ) @@ -331,32 +344,32 @@ struct Xcodes: AsyncParsableCommand { @OptionGroup var globalColor: GlobalColorOption - func run() { - Rainbow.enabled = Rainbow.enabled && globalColor.color + func run() async throws { + configureRainbow(enabled: globalColor.color) let directory = getDirectory(possibleDirectory: globalDirectory.directory) - - xcodeInstaller.printXcodePath(ofVersion: version.joined(separator: " "), searchingIn: directory) - .recover { error -> Promise in - switch error { - case XcodeInstaller.Error.invalidVersion: - return xcodeInstaller.printInstalledXcodes(directory: directory) - default: - throw error - } - } - .done { Installed.exit() } - .catch { error in Installed.exit(withLegibleError: error) } - - RunLoop.current.run() + let services = Xcodes.makeServices() + + do { + try await services.xcodeInstaller.printXcodePath(ofVersion: version.joined(separator: " "), searchingIn: directory) + Installed.exit() + } catch XcodeInstaller.Error.invalidVersion { + try await services.xcodeInstaller.printInstalledXcodes(directory: directory) + Installed.exit() + } catch { + Installed.exit(withLegibleError: error) + } } } - struct List: ParsableCommand { - static var configuration = CommandConfiguration( + struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration( abstract: "List all versions of Xcode that are available to install" ) + @Option(help: "Only list Xcodes matching the specified architecture: arm64, x86_64, appleSilicon, or universal. Can be used multiple times.") + var architecture: [ArchitectureFilter] = [] + @OptionGroup var globalDirectory: GlobalDirectoryOption @@ -366,28 +379,28 @@ struct Xcodes: AsyncParsableCommand { @OptionGroup var globalColor: GlobalColorOption - func run() { - Rainbow.enabled = Rainbow.enabled && globalColor.color + func run() async throws { + configureRainbow(enabled: globalColor.color) let directory = getDirectory(possibleDirectory: globalDirectory.directory) + let services = Xcodes.makeServices() - firstly { () -> Promise in - if xcodeList.shouldUpdateBeforeListingVersions { - return xcodeInstaller.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) + do { + if services.xcodeList.shouldUpdateBeforeListingVersions { + try await services.xcodeInstaller.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory, architectures: architecture) } else { - return xcodeInstaller.printAvailableXcodes(xcodeList.availableXcodes, installed: Current.files.installedXcodes(directory)) + try await services.xcodeInstaller.printAvailableXcodes(services.xcodeList.availableXcodes, installed: Current.files.installedXcodes(directory), architectures: architecture) } + List.exit() + } catch { + List.exit(withLegibleError: error) } - .done { List.exit() } - .catch { error in List.exit(withLegibleError: error) } - - RunLoop.current.run() } } struct Runtimes: AsyncParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( abstract: "List all simulator runtimes that are available to install", subcommands: [Download.self, Install.self] ) @@ -395,12 +408,21 @@ struct Xcodes: AsyncParsableCommand { @Flag(help: "Include beta runtimes available to install") var includeBetas: Bool = false + @Option(help: "Only list runtimes matching the specified architecture: arm64, x86_64, appleSilicon, or universal. Can be used multiple times.") + var architecture: [ArchitectureFilter] = [] + + @OptionGroup + var globalColor: GlobalColorOption + func run() async throws { - try await runtimeInstaller.printAvailableRuntimes(includeBetas: includeBetas) + configureRainbow(enabled: globalColor.color) + + let services = Xcodes.makeServices() + try await services.runtimeInstaller.printAvailableRuntimes(includeBetas: includeBetas, architectures: architecture) } struct Install: AsyncParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( abstract: "Download and install a specific simulator runtime" ) @@ -418,6 +440,9 @@ struct Xcodes: AsyncParsableCommand { completion: .directory) var directory: String? + @Option(help: "Install the runtime matching the specified architecture: arm64, x86_64, appleSilicon, or universal. Can be used multiple times.") + var architecture: [ArchitectureFilter] = [] + @Flag(help: "Do not delete the runtime archive after the installation is finished.") var keepArchive = false @@ -425,19 +450,20 @@ struct Xcodes: AsyncParsableCommand { var globalColor: GlobalColorOption func run() async throws { - Rainbow.enabled = Rainbow.enabled && globalColor.color + configureRainbow(enabled: globalColor.color) let downloader = noAria2 ? Downloader.urlSession : Downloader(aria2Path: aria2) let destination = getDirectory(possibleDirectory: directory, default: .environmentDownloads) + let services = Xcodes.makeServices() - try await runtimeInstaller.downloadAndInstallRuntime(identifier: version, to: destination, with: downloader, shouldDelete: !keepArchive) + try await services.runtimeInstaller.downloadAndInstallRuntime(identifier: version, to: destination, with: downloader, shouldDelete: !keepArchive, architectures: architecture) Current.logging.log("Finished") } } struct Download: AsyncParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( abstract: "Download a specific simulator runtime" ) @@ -455,25 +481,29 @@ struct Xcodes: AsyncParsableCommand { completion: .directory) var directory: String? + @Option(help: "Download the runtime matching the specified architecture: arm64, x86_64, appleSilicon, or universal. Can be used multiple times.") + var architecture: [ArchitectureFilter] = [] + @OptionGroup var globalColor: GlobalColorOption func run() async throws { - Rainbow.enabled = Rainbow.enabled && globalColor.color + configureRainbow(enabled: globalColor.color) let downloader = noAria2 ? Downloader.urlSession : Downloader(aria2Path: aria2) let destination = getDirectory(possibleDirectory: directory, default: .environmentDownloads) + let services = Xcodes.makeServices() - try await runtimeInstaller.downloadRuntime(identifier: version, to: destination, with: downloader) + try await services.runtimeInstaller.downloadRuntime(identifier: version, to: destination, with: downloader, architectures: architecture) Current.logging.log("Finished") } } } - struct Select: ParsableCommand { - static var configuration = CommandConfiguration( + struct Select: AsyncParsableCommand { + static let configuration = CommandConfiguration( abstract: "Change the selected Xcode", discussion: """ Select a version of Xcode by specifying a version number or an absolute path. Run without arguments to select the version specified in your .xcode-version file. If no version file is found, you will be prompted to interactively select from a list. @@ -499,21 +529,22 @@ struct Xcodes: AsyncParsableCommand { @OptionGroup var globalColor: GlobalColorOption - func run() { - Rainbow.enabled = Rainbow.enabled && globalColor.color + func run() async throws { + configureRainbow(enabled: globalColor.color) let directory = getDirectory(possibleDirectory: globalDirectory.directory) - selectXcode(shouldPrint: print, pathOrVersion: versionOrPath.joined(separator: " "), directory: directory) - .done { Select.exit() } - .catch { error in Select.exit(withLegibleError: error) } - - RunLoop.current.run() + do { + try await selectXcodeAsync(shouldPrint: print, pathOrVersion: versionOrPath.joined(separator: " "), directory: directory) + Select.exit() + } catch { + Select.exit(withLegibleError: error) + } } } - struct Uninstall: ParsableCommand { - static var configuration = CommandConfiguration( + struct Uninstall: AsyncParsableCommand { + static let configuration = CommandConfiguration( abstract: "Uninstall a version of Xcode", discussion: """ Run without any arguments to interactively select from a list. @@ -537,21 +568,23 @@ struct Xcodes: AsyncParsableCommand { @OptionGroup var globalColor: GlobalColorOption - func run() { - Rainbow.enabled = Rainbow.enabled && globalColor.color + func run() async throws { + configureRainbow(enabled: globalColor.color) let directory = getDirectory(possibleDirectory: globalDirectory.directory) + let services = Xcodes.makeServices() - xcodeInstaller.uninstallXcode(version.joined(separator: " "), directory: directory, emptyTrash: emptyTrash) - .done { Uninstall.exit() } - .catch { error in Uninstall.exit(withLegibleError: error) } - - RunLoop.current.run() + do { + try await services.xcodeInstaller.uninstallXcode(version.joined(separator: " "), directory: directory, emptyTrash: emptyTrash) + Uninstall.exit() + } catch { + Uninstall.exit(withLegibleError: error) + } } } - struct Update: ParsableCommand { - static var configuration = CommandConfiguration( + struct Update: AsyncParsableCommand { + static let configuration = CommandConfiguration( abstract: "Update the list of available versions of Xcode" ) @@ -564,21 +597,23 @@ struct Xcodes: AsyncParsableCommand { @OptionGroup var globalColor: GlobalColorOption - func run() { - Rainbow.enabled = Rainbow.enabled && globalColor.color + func run() async throws { + configureRainbow(enabled: globalColor.color) let directory = getDirectory(possibleDirectory: globalDirectory.directory) + let services = Xcodes.makeServices() - xcodeInstaller.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) - .done { Update.exit() } - .catch { error in Update.exit(withLegibleError: error) } - - RunLoop.current.run() + do { + try await services.xcodeInstaller.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) + Update.exit() + } catch { + Update.exit(withLegibleError: error) + } } } struct Version: ParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( abstract: "Print the version number of xcodes itself" ) @@ -586,34 +621,32 @@ struct Xcodes: AsyncParsableCommand { var globalColor: GlobalColorOption func run() { - Rainbow.enabled = Rainbow.enabled && globalColor.color + configureRainbow(enabled: globalColor.color) - Current.logging.log(XcodesKit.version.description) + Current.logging.log(XcodesCLIKit.version.description) } } - struct Signout: ParsableCommand { - static var configuration = CommandConfiguration( + struct Signout: AsyncParsableCommand { + static let configuration = CommandConfiguration( abstract: "Clears the stored username and password" ) @OptionGroup var globalColor: GlobalColorOption - func run() { - Rainbow.enabled = Rainbow.enabled && globalColor.color + func run() async throws { + configureRainbow(enabled: globalColor.color) + let services = Xcodes.makeServices() - sessionService.logout() - .done { - Current.logging.log("Successfully signed out".green) - Signout.exit() - } - .recover { error in - Current.logging.log(error.legibleLocalizedDescription) - Signout.exit() - } - - RunLoop.current.run() + do { + try await services.sessionService.logout() + Current.logging.log("Successfully signed out".green) + Signout.exit() + } catch { + Current.logging.log(error.legibleLocalizedDescription) + Signout.exit() + } } } } diff --git a/Sources/xcodes/DataSource+ExpressibleByArgument.swift b/Sources/xcodes/DataSource+ExpressibleByArgument.swift index 0eb58e57..f9097f1b 100644 --- a/Sources/xcodes/DataSource+ExpressibleByArgument.swift +++ b/Sources/xcodes/DataSource+ExpressibleByArgument.swift @@ -1,4 +1,4 @@ import ArgumentParser -import XcodesKit +import XcodesCLIKit -extension DataSource: ExpressibleByArgument {} +extension DataSource: @retroactive ExpressibleByArgument {} diff --git a/Sources/xcodes/ParsableArguments+InstallError.swift b/Sources/xcodes/ParsableArguments+InstallError.swift index 6186a70a..7e4bda3f 100644 --- a/Sources/xcodes/ParsableArguments+InstallError.swift +++ b/Sources/xcodes/ParsableArguments+InstallError.swift @@ -1,18 +1,17 @@ import ArgumentParser import Foundation import LegibleError -import PromiseKit -import XcodesKit +import XcodesCLIKit import Rainbow extension ParsableArguments { static func processDownloadOrInstall(error: Error) -> Never { var exitCode: ExitCode = .failure switch error { - case Process.PMKError.execution(let process, let standardOutput, let standardError): + case let error as ProcessExecutionError: Current.logging.log(""" - Failed executing: `\(process)` (\(process.terminationStatus)) - \([standardOutput, standardError].compactMap { $0 }.joined(separator: "\n")) + Failed executing: `\(error.processDescription)` (\(error.terminationStatus)) + \([error.standardOutput, error.standardError].filter { !$0.isEmpty }.joined(separator: "\n")) """.red) case let error as XcodeInstaller.Error: if case .versionAlreadyInstalled = error { diff --git a/Sources/xcodes/ParsableArguments+LegibleError.swift b/Sources/xcodes/ParsableArguments+LegibleError.swift index c168b401..412d3a0a 100644 --- a/Sources/xcodes/ParsableArguments+LegibleError.swift +++ b/Sources/xcodes/ParsableArguments+LegibleError.swift @@ -1,6 +1,6 @@ import ArgumentParser import LegibleError -import XcodesKit +import XcodesCLIKit import Rainbow extension ParsableArguments { diff --git a/Tests/AppleAPITests/AppleAPITests.swift b/Tests/AppleAPITests/AppleAPITests.swift deleted file mode 100644 index 06c7f36f..00000000 --- a/Tests/AppleAPITests/AppleAPITests.swift +++ /dev/null @@ -1,770 +0,0 @@ -import XCTest -import PromiseKit -import PMKFoundation -@testable import AppleAPI - -func fixture(for url: URL, fileURL: URL? = nil, statusCode: Int, headers: [String: String]) -> Promise<(data: Data, response: URLResponse)> { - .value((data: fileURL != nil ? try! Data(contentsOf: fileURL!) : Data(), - response: HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: headers)!)) -} - -final class AppleAPITests: XCTestCase { - override class func setUp() { - super.setUp() - PromiseKit.conf.Q.map = nil - PromiseKit.conf.Q.return = nil - } - - override func setUp() { - Current = .mock - } - - func test_Login_2FA_Succeeds() { - var log = "" - Current.logging.log = { log.append($0 + "\n") } - - var readLineCount = 0 - Current.shell.readLine = { prompt in - defer { readLineCount += 1 } - - Current.logging.log(prompt) - - // security code - return "000000" - } - - Current.network.dataTask = { convertible in - - switch convertible.pmkRequest.url! { - case .itcServiceKey: - return fixture(for: .itcServiceKey, - fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json"]) - case .signIn: - if convertible.pmkRequest.httpMethod == "GET" { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "Federate", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-HC-Bits": "10", - "X-Apple-HC-Challenge": "somestring", - "scnt": ""]) - } else { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "SignIn", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 409, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - } - case .authOptions: - return fixture(for: .authOptions, - fileURL: Bundle.module.url(forResource: "AuthOptions", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .submitSecurityCode(.device(code: "000000")): - return fixture(for: .submitSecurityCode(.device(code: "000000")), - statusCode: 204, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .trust: - return fixture(for: .trust, - statusCode: 204, - headers: [:]) - case .olympusSession: - return fixture(for: .olympusSession, - fileURL: Bundle.module.url(forResource: "OlympusSession", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - default: - print(convertible.pmkRequest.url!) - XCTFail() - return .init(error: PMKError.invalidCallingConvention) - } - } - - let expectation = self.expectation(description: "promise fulfills") - - let client = Client() - client.login(accountName: "test@example.com", password: "ABC123") - .tap { result in - guard case .fulfilled = result else { - XCTFail("login rejected") - return - } - expectation.fulfill() - } - .cauterize() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(log, """ - Two-factor authentication is enabled for this account. - - Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to. - Enter the 6 digit code from one of your trusted devices: - - """) - } - - func test_Login_2FA_IncorrectPassword() { - var log = "" - Current.logging.log = { log.append($0 + "\n") } - - var readLineCount = 0 - Current.shell.readLine = { prompt in - defer { readLineCount += 1 } - - Current.logging.log(prompt) - - // security code - return "000000" - } - - Current.network.dataTask = { convertible in - switch convertible.pmkRequest.url! { - case .itcServiceKey: - return fixture(for: .itcServiceKey, - fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_2FA_IncorrectPassword")!, - statusCode: 200, - headers: ["Content-Type": "application/json"]) - case .signIn: - if convertible.pmkRequest.httpMethod == "GET" { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "Federate", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-HC-Bits": "10", - "X-Apple-HC-Challenge": "somestring", - "scnt": ""]) - } else { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "SignIn", withExtension: "json", subdirectory: "Fixtures/Login_2FA_IncorrectPassword")!, - statusCode: 401, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - } - default: - XCTFail() - return .init(error: PMKError.invalidCallingConvention) - } - } - - let expectation = self.expectation(description: "promise rejects") - - let client = Client() - client.login(accountName: "test@example.com", password: "ABC123") - .tap { result in - guard case .rejected(let error as AppleAPI.Client.Error) = result else { - XCTFail("login fulfilled, but should have rejected with .invalidUsernameOrPassword error") - return - } - XCTAssertEqual(error, AppleAPI.Client.Error.invalidUsernameOrPassword(username: "test@example.com")) - expectation.fulfill() - } - .cauterize() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(log, "") - } - - func test_Login_SMS_SentAutomatically_Succeeds() { - var log = "" - Current.logging.log = { log.append($0 + "\n") } - - var readLineCount = 0 - Current.shell.readLine = { prompt in - defer { readLineCount += 1 } - - Current.logging.log(prompt) - - // security code - return "000000" - } - - Current.network.dataTask = { convertible in - switch convertible.pmkRequest.url! { - case .itcServiceKey: - return fixture(for: .itcServiceKey, - fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_SMS_SentAutomatically_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json"]) - case .signIn: - if convertible.pmkRequest.httpMethod == "GET" { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "Federate", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-HC-Bits": "10", - "X-Apple-HC-Challenge": "somestring", - "scnt": ""]) - } else { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "SignIn", withExtension: "json", subdirectory: "Fixtures/Login_SMS_SentAutomatically_Succeeds")!, - statusCode: 409, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - } - case .authOptions: - return fixture(for: .authOptions, - fileURL: Bundle.module.url(forResource: "AuthOptions", withExtension: "json", subdirectory: "Fixtures/Login_SMS_SentAutomatically_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .requestSecurityCode: - return fixture(for: .requestSecurityCode, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .submitSecurityCode(.sms(code: "000000", phoneNumberId: 1)): - return fixture(for: .submitSecurityCode(.sms(code: "000000", phoneNumberId: 1)), - statusCode: 204, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .trust: - return fixture(for: .trust, - statusCode: 204, - headers: [:]) - case .olympusSession: - return fixture(for: .olympusSession, - fileURL: Bundle.module.url(forResource: "OlympusSession", withExtension: "json", subdirectory: "Fixtures/Login_SMS_SentAutomatically_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - default: - XCTFail("Unexpected request to \(convertible.pmkRequest.url!)") - return .init(error: PMKError.invalidCallingConvention) - } - } - - let expectation = self.expectation(description: "promise fulfills") - - let client = Client() - client.login(accountName: "test@example.com", password: "ABC123") - .tap { result in - guard case .fulfilled = result else { - XCTFail("login rejected") - return - } - expectation.fulfill() - } - .cauterize() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(log, """ - Two-factor authentication is enabled for this account. - - Enter the 6 digit code sent to +1 (•••) •••-••00: - - """) - } - - func test_Login_SMS_SentAutomatically_IncorrectCode() { - var log = "" - Current.logging.log = { log.append($0 + "\n") } - - var readLineCount = 0 - Current.shell.readLine = { prompt in - defer { readLineCount += 1 } - - Current.logging.log(prompt) - - // security code - return "000000" - } - - Current.network.dataTask = { convertible in - switch convertible.pmkRequest.url! { - case .itcServiceKey: - return fixture(for: .itcServiceKey, - fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_SMS_SentAutomatically_IncorrectCode")!, - statusCode: 200, - headers: ["Content-Type": "application/json"]) - case .signIn: - if convertible.pmkRequest.httpMethod == "GET" { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "Federate", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-HC-Bits": "10", - "X-Apple-HC-Challenge": "somestring", - "scnt": ""]) - } else { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "SignIn", withExtension: "json", subdirectory: "Fixtures/Login_SMS_SentAutomatically_IncorrectCode")!, - statusCode: 409, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - } - case .authOptions: - return fixture(for: .authOptions, - fileURL: Bundle.module.url(forResource: "AuthOptions", withExtension: "json", subdirectory: "Fixtures/Login_SMS_SentAutomatically_IncorrectCode")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .requestSecurityCode: - return fixture(for: .requestSecurityCode, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .submitSecurityCode(.sms(code: "000000", phoneNumberId: 1)): - return fixture(for: .submitSecurityCode(.sms(code: "000000", phoneNumberId: 1)), - statusCode: 401, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - default: - XCTFail("Unexpected request to \(convertible.pmkRequest.url!)") - return .init(error: PMKError.invalidCallingConvention) - } - } - - let expectation = self.expectation(description: "promise rejects") - - let client = Client() - client.login(accountName: "test@example.com", password: "ABC123") - .tap { result in - guard case .rejected(let error as AppleAPI.Client.Error) = result else { - XCTFail("login fulfilled, but should have rejected with .incorrectSecurityCode error") - return - } - XCTAssertEqual(error, AppleAPI.Client.Error.incorrectSecurityCode) - expectation.fulfill() - } - .cauterize() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(log, """ - Two-factor authentication is enabled for this account. - - Enter the 6 digit code sent to +1 (•••) •••-••00: - - """) - } - - func test_Login_SMS_MultipleNumbers_Succeeds() { - var log = "" - Current.logging.log = { log.append($0 + "\n") } - - var readLineCount = 0 - Current.shell.readLine = { prompt in - defer { readLineCount += 1 } - - Current.logging.log(prompt) - - switch readLineCount { - case 0: - // invalid phone number index - return "3" - case 1: - // phone number index - return "1" - case 2: - // security code - return "000000" - default: - XCTFail() - return "" - } - } - - Current.network.dataTask = { convertible in - switch convertible.pmkRequest.url! { - case .itcServiceKey: - return fixture(for: .itcServiceKey, - fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_SMS_MultipleNumbers_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json"]) - case .signIn: - if convertible.pmkRequest.httpMethod == "GET" { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "Federate", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-HC-Bits": "10", - "X-Apple-HC-Challenge": "somestring", - "scnt": ""]) - } else { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "SignIn", withExtension: "json", subdirectory: "Fixtures/Login_SMS_MultipleNumbers_Succeeds")!, - statusCode: 409, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - } - case .authOptions: - return fixture(for: .authOptions, - fileURL: Bundle.module.url(forResource: "AuthOptions", withExtension: "json", subdirectory: "Fixtures/Login_SMS_MultipleNumbers_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .requestSecurityCode: - return fixture(for: .requestSecurityCode, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .submitSecurityCode(.sms(code: "000000", phoneNumberId: 1)): - return fixture(for: .submitSecurityCode(.sms(code: "000000", phoneNumberId: 1)), - statusCode: 204, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .trust: - return fixture(for: .trust, - statusCode: 204, - headers: [:]) - case .olympusSession: - return fixture(for: .olympusSession, - fileURL: Bundle.module.url(forResource: "OlympusSession", withExtension: "json", subdirectory: "Fixtures/Login_SMS_MultipleNumbers_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - default: - XCTFail("Unexpected request to \(convertible.pmkRequest.url!)") - return .init(error: PMKError.invalidCallingConvention) - } - } - - let expectation = self.expectation(description: "promise fulfills") - - let client = Client() - client.login(accountName: "test@example.com", password: "ABC123") - .tap { result in - guard case .fulfilled = result else { - XCTFail("login rejected") - return - } - expectation.fulfill() - } - .cauterize() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(log, """ - Two-factor authentication is enabled for this account. - - Trusted phone numbers: - 1: +1 (•••) •••-••00 - 2: +1 (•••) •••-••01 - Select a trusted phone number to receive a code via SMS: - Not a valid phone number index. Expecting a whole number between 1-2, but was given 3. - - Trusted phone numbers: - 1: +1 (•••) •••-••00 - 2: +1 (•••) •••-••01 - Select a trusted phone number to receive a code via SMS: - Enter the 6 digit code sent to +1 (•••) •••-••00: - - """) - } - - func test_Login_SMS_MultipleNumbers_IncorrectCode() { - var log = "" - Current.logging.log = { log.append($0 + "\n") } - - var readLineCount = 0 - Current.shell.readLine = { prompt in - defer { readLineCount += 1 } - - Current.logging.log(prompt) - - if readLineCount == 0 { - // phone number index - return "1" - } else { - // security code - return "000000" - } - } - - Current.network.dataTask = { convertible in - switch convertible.pmkRequest.url! { - case .itcServiceKey: - return fixture(for: .itcServiceKey, - fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_SMS_MultipleNumbers_IncorrectCode")!, - statusCode: 200, - headers: ["Content-Type": "application/json"]) - case .signIn: - if convertible.pmkRequest.httpMethod == "GET" { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "Federate", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-HC-Bits": "10", - "X-Apple-HC-Challenge": "somestring", - "scnt": ""]) - } else { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "SignIn", withExtension: "json", subdirectory: "Fixtures/Login_SMS_MultipleNumbers_IncorrectCode")!, - statusCode: 409, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - } - case .authOptions: - return fixture(for: .authOptions, - fileURL: Bundle.module.url(forResource: "AuthOptions", withExtension: "json", subdirectory: "Fixtures/Login_SMS_MultipleNumbers_IncorrectCode")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .requestSecurityCode: - return fixture(for: .requestSecurityCode, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .submitSecurityCode(.sms(code: "000000", phoneNumberId: 1)): - return fixture(for: .submitSecurityCode(.sms(code: "000000", phoneNumberId: 1)), - statusCode: 401, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - default: - XCTFail("Unexpected request to \(convertible.pmkRequest.url!)") - return .init(error: PMKError.invalidCallingConvention) - } - } - - let expectation = self.expectation(description: "promise rejects") - - let client = Client() - client.login(accountName: "test@example.com", password: "ABC123") - .tap { result in - guard case .rejected(let error as AppleAPI.Client.Error) = result else { - XCTFail("login fulfilled, but should have rejected with .incorrectSecurityCode error") - return - } - XCTAssertEqual(error, AppleAPI.Client.Error.incorrectSecurityCode) - expectation.fulfill() - } - .cauterize() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(log, """ - Two-factor authentication is enabled for this account. - - Trusted phone numbers: - 1: +1 (•••) •••-••00 - 2: +1 (•••) •••-••01 - Select a trusted phone number to receive a code via SMS: - Enter the 6 digit code sent to +1 (•••) •••-••00: - - """) - } - - func test_Login_SMS_NoNumbers() { - var log = "" - Current.logging.log = { log.append($0 + "\n") } - - var readLineCount = 0 - Current.shell.readLine = { prompt in - defer { readLineCount += 1 } - - Current.logging.log(prompt) - - switch readLineCount { - case 0: - // invalid phone number index - return "3" - case 1: - // phone number index - return "1" - case 2: - // security code - return "000000" - default: - XCTFail() - return "" - } - } - - Current.network.dataTask = { convertible in - switch convertible.pmkRequest.url! { - case .itcServiceKey: - return fixture(for: .itcServiceKey, - fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_SMS_NoNumbers")!, - statusCode: 200, - headers: ["Content-Type": "application/json"]) - case .signIn: - if convertible.pmkRequest.httpMethod == "GET" { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "Federate", withExtension: "json", subdirectory: "Fixtures/Login_2FA_Succeeds")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-HC-Bits": "10", - "X-Apple-HC-Challenge": "somestring", - "scnt": ""]) - } else { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "SignIn", withExtension: "json", subdirectory: "Fixtures/Login_SMS_NoNumbers")!, - statusCode: 409, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - } - case .authOptions: - return fixture(for: .authOptions, - fileURL: Bundle.module.url(forResource: "AuthOptions", withExtension: "json", subdirectory: "Fixtures/Login_SMS_NoNumbers")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - default: - XCTFail("Unexpected request to \(convertible.pmkRequest.url!)") - return .init(error: PMKError.invalidCallingConvention) - } - } - - let expectation = self.expectation(description: "promise rejects") - - let client = Client() - client.login(accountName: "test@example.com", password: "ABC123") - .tap { result in - guard case .rejected(let error as AppleAPI.Client.Error) = result else { - XCTFail("login fulfilled, but should have rejected with .noTrustedPhoneNumbers error") - return - } - XCTAssertEqual(error, AppleAPI.Client.Error.noTrustedPhoneNumbers) - expectation.fulfill() - } - .cauterize() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(log, """ - Two-factor authentication is enabled for this account. - - - """) - } - - func test_Login_Service_Temporarily_Unavailable() { - var log = "" - Current.logging.log = { log.append($0 + "\n") } - - var readLineCount = 0 - Current.shell.readLine = { prompt in - defer { readLineCount += 1 } - - Current.logging.log(prompt) - - // security code - return "000000" - } - - Current.network.dataTask = { convertible in - - switch convertible.pmkRequest.url! { - case .itcServiceKey: - return fixture(for: .itcServiceKey, - fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, - statusCode: 200, - headers: ["Content-Type": "application/json"]) - case .signIn: - if convertible.pmkRequest.httpMethod == "GET" { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "Federate", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-HC-Bits": "10", - "X-Apple-HC-Challenge": "somestring", - "scnt": ""]) - } else { - return fixture(for: .signIn, - fileURL: Bundle.module.url(forResource: "SignIn", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, - statusCode: 503, - headers: ["Content-Type": "text/html", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - } - case .authOptions: - return fixture(for: .authOptions, - fileURL: Bundle.module.url(forResource: "AuthOptions", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .submitSecurityCode(.device(code: "000000")): - return fixture(for: .submitSecurityCode(.device(code: "000000")), - statusCode: 204, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - case .trust: - return fixture(for: .trust, - statusCode: 204, - headers: [:]) - case .olympusSession: - return fixture(for: .olympusSession, - fileURL: Bundle.module.url(forResource: "OlympusSession", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, - statusCode: 200, - headers: ["Content-Type": "application/json", - "X-Apple-ID-Session-Id": "", - "scnt": ""]) - default: - print(convertible.pmkRequest.url!) - XCTFail() - return .init(error: PMKError.invalidCallingConvention) - } - } - - let expectation = self.expectation(description: "promise fulfills") - - let client = Client() - client.login(accountName: "test@example.com", password: "ABC123") - .tap { result in - guard case .rejected(let error as AppleAPI.Client.Error) = result else { - XCTFail("login fulfilled, but should have rejected with .noTrustedPhoneNumbers error") - return - } - XCTAssertEqual(error, AppleAPI.Client.Error.serviceTemporarilyUnavailable) - expectation.fulfill() - } - .cauterize() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(log, "") - } - - - func testValidHashCashMint() { - let bits: UInt = 11 - let resource = "4d74fb15eb23f465f1f6fcbf534e5877" - let testDate = "20230223170600" - - let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate) - XCTAssertEqual(stamp, "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373") - } - func testValidHashCashMint2() { - let bits: UInt = 10 - let resource = "bb63edf88d2f9c39f23eb4d6f0281158" - let testDate = "20230224001754" - - let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate) - XCTAssertEqual(stamp, "1:10:20230224001754:bb63edf88d2f9c39f23eb4d6f0281158::866") - } -} diff --git a/Tests/AppleAPITests/Environment+Mock.swift b/Tests/AppleAPITests/Environment+Mock.swift deleted file mode 100644 index 57cfa9a3..00000000 --- a/Tests/AppleAPITests/Environment+Mock.swift +++ /dev/null @@ -1,29 +0,0 @@ -@testable import AppleAPI -import Foundation -import PromiseKit - -extension Environment { - static var mock = Environment( - shell: .mock, - network: .mock, - logging: .mock - ) -} - -extension Shell { - static var mock = Shell( - readLine: { _ in return nil } - ) -} - -extension Network { - static var mock = Network( - dataTask: { url in return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } - ) -} - -extension Logging { - static var mock = Logging( - log: { print($0) } - ) -} diff --git a/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/AuthOptions.json b/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/AuthOptions.json deleted file mode 100644 index f521ff87..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/AuthOptions.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "trustedPhoneNumbers" : [ { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - } ], - "securityCode" : { - "length" : 6, - "tooManyCodesSent" : false, - "tooManyCodesValidated" : false, - "securityCodeLocked" : false, - "securityCodeCooldown" : false - }, - "authenticationType" : "hsa2", - "recoveryUrl" : "https://iforgot.apple.com/phone/add?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "cantUsePhoneNumberUrl" : "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "recoveryWebUrl" : "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "repairPhoneNumberUrl" : "https://gsa.apple.com/appleid/account/manage/repair/verify/phone", - "repairPhoneNumberWebUrl" : "https://appleid.apple.com/widget/account/repair?#!repair", - "aboutTwoFactorAuthenticationUrl" : "https://support.apple.com/kb/HT204921", - "autoVerified" : false, - "showAutoVerificationUI" : false, - "managedAccount" : false, - "trustedPhoneNumber" : { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - }, - "hsa2Account" : true, - "restrictedAccount" : false, - "supportsRecovery" : true -} diff --git a/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/ITCServiceKey.json deleted file mode 100644 index 33c00bf8..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/ITCServiceKey.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "authServiceUrl" : "https://idmsa.apple.com/appleauth", - "authServiceKey" : "NNNNN" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/OlympusSession.json deleted file mode 100644 index 07fb0cbc..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/OlympusSession.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "user" : { - "fullName" : "Test User", - "firstName" : "Test", - "lastName" : "User", - "emailAddress" : "test@example.com", - "prsId" : "000000000" - }, - "provider" : { - "providerId" : 00000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL", - "pla" : [ { - "id" : "1BC01216-52D4-43DC-8555-195F4454C348", - "version" : "5014", - "types" : [ "contractContentTypeDisplay.iOSFreeApps", "contractContentTypeDisplay.MacOSXFreeApplications" ], - "contractCountryOfOrigins" : [ "CAN" ] - } ] - }, - "theme" : "APPSTORE_CONNECT", - "availableProviders" : [ { - "providerId" : 000000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL" - } ], - "backingType" : "ITC", - "backingTypes" : [ "ITC" ], - "roles" : [ "ADMIN", "LEGAL" ], - "unverifiedRoles" : [ ], - "featureFlags" : [ "showWwdrUserRoles", "adpRad", "apiKeys" ], - "agreeToTerms" : true, - "termsSignatures" : [ "ASC", "RAD" ], - "modules" : [ { - "key" : "Apps", - "name" : "ITC.HomePage.Apps.IconText", - "localizedName" : "My Apps", - "url" : "https://appstoreconnect.apple.com/apps", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Apps@2x.d3ce493e56172e92aed6.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "AppAnalytics", - "name" : "ITC.HomePage.AppAnalytics.IconText", - "localizedName" : "App Analytics", - "url" : "https://analytics.itunes.apple.com/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/AppAnalytics@2x.e19f711d943cb42d65b2.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "SalesTrends", - "name" : "ITC.HomePage.SalesTrends.IconText", - "localizedName" : "Sales and Trends", - "url" : "https://appstoreconnect.apple.com/trends", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/SalesTrends@2x.b1f802112426525d990a.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "FinancialReports", - "name" : "ITC.HomePage.FinancialReports.IconText", - "localizedName" : "Payments and Financial Reports", - "url" : "https://appstoreconnect.apple.com/itc/payments_and_financial_reports", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/FinancialReports@2x.a7b266a5136cc65c643b.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Account", - "name" : "ITC.HomePage.Account.IconText", - "localizedName" : "Users and Access", - "url" : "https://appstoreconnect.apple.com/access/users", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ManageUsers@2x.81511f3933fb2fb4b20d.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "ContractsTaxBanking", - "name" : "ITC.HomePage.ContractsTaxBanking.IconText", - "localizedName" : "Agreements, Tax, and Banking", - "url" : "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/da/jumpTo?page=contracts", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ContractsTaxBanking@2x.74466eb8570dd797602e.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Resources", - "name" : "ITC.HomePage.Resources.IconText", - "localizedName" : "Resources and Help", - "url" : "https://developer.apple.com/app-store-connect/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Resources@2x.3c8d0d8c08e876cf9470.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - } ], - "helpLinks" : [ { - "key" : "AllAsc", - "url" : "https://help.apple.com/app-store-connect/", - "localizedText" : "App Store Connect Resources" - }, { - "key" : "Xcode", - "url" : "https://help.apple.com/xcode/mac/current/", - "localizedText" : "Xcode Help" - }, { - "key" : "SupportContact", - "url" : "https://developer.apple.com/support/", - "localizedText" : "Support and Contact" - } ], - "userProfile" : [ { - "key" : "signIn", - "url" : "https://appstoreconnect.apple.com/login", - "localizedText" : "Sign In" - }, { - "key" : "personalDetails", - "url" : "https://appstoreconnect.apple.com/access/users/07E3E586-44B1-48D3-BF8D-430F754F1BAA/settings", - "localizedText" : "Edit Profile" - }, { - "key" : "signOut", - "url" : "https://appstoreconnect.apple.com/logout", - "localizedText" : "Sign Out" - } ], - "pccDto" : null, - "publicUserId" : "07E3E586-44B1-48D3-BF8D-430F754F1BAA", - "ofacState" : null -} diff --git a/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/SignIn.json b/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/SignIn.json deleted file mode 100644 index 18a6cc09..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_2FA_IncorrectPassword/SignIn.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "serviceErrors" : [ { - "code" : "-20101", - "message" : "Your Apple ID or password was incorrect.", - "suppressDismissal" : false - } ] -} diff --git a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/AuthOptions.json b/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/AuthOptions.json deleted file mode 100644 index f521ff87..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/AuthOptions.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "trustedPhoneNumbers" : [ { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - } ], - "securityCode" : { - "length" : 6, - "tooManyCodesSent" : false, - "tooManyCodesValidated" : false, - "securityCodeLocked" : false, - "securityCodeCooldown" : false - }, - "authenticationType" : "hsa2", - "recoveryUrl" : "https://iforgot.apple.com/phone/add?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "cantUsePhoneNumberUrl" : "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "recoveryWebUrl" : "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "repairPhoneNumberUrl" : "https://gsa.apple.com/appleid/account/manage/repair/verify/phone", - "repairPhoneNumberWebUrl" : "https://appleid.apple.com/widget/account/repair?#!repair", - "aboutTwoFactorAuthenticationUrl" : "https://support.apple.com/kb/HT204921", - "autoVerified" : false, - "showAutoVerificationUI" : false, - "managedAccount" : false, - "trustedPhoneNumber" : { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - }, - "hsa2Account" : true, - "restrictedAccount" : false, - "supportsRecovery" : true -} diff --git a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/Federate.json b/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/Federate.json deleted file mode 100644 index 10fb36e7..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/Federate.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "authType" : "hsa2" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/ITCServiceKey.json deleted file mode 100644 index 33c00bf8..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/ITCServiceKey.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "authServiceUrl" : "https://idmsa.apple.com/appleauth", - "authServiceKey" : "NNNNN" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/OlympusSession.json deleted file mode 100644 index 07fb0cbc..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/OlympusSession.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "user" : { - "fullName" : "Test User", - "firstName" : "Test", - "lastName" : "User", - "emailAddress" : "test@example.com", - "prsId" : "000000000" - }, - "provider" : { - "providerId" : 00000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL", - "pla" : [ { - "id" : "1BC01216-52D4-43DC-8555-195F4454C348", - "version" : "5014", - "types" : [ "contractContentTypeDisplay.iOSFreeApps", "contractContentTypeDisplay.MacOSXFreeApplications" ], - "contractCountryOfOrigins" : [ "CAN" ] - } ] - }, - "theme" : "APPSTORE_CONNECT", - "availableProviders" : [ { - "providerId" : 000000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL" - } ], - "backingType" : "ITC", - "backingTypes" : [ "ITC" ], - "roles" : [ "ADMIN", "LEGAL" ], - "unverifiedRoles" : [ ], - "featureFlags" : [ "showWwdrUserRoles", "adpRad", "apiKeys" ], - "agreeToTerms" : true, - "termsSignatures" : [ "ASC", "RAD" ], - "modules" : [ { - "key" : "Apps", - "name" : "ITC.HomePage.Apps.IconText", - "localizedName" : "My Apps", - "url" : "https://appstoreconnect.apple.com/apps", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Apps@2x.d3ce493e56172e92aed6.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "AppAnalytics", - "name" : "ITC.HomePage.AppAnalytics.IconText", - "localizedName" : "App Analytics", - "url" : "https://analytics.itunes.apple.com/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/AppAnalytics@2x.e19f711d943cb42d65b2.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "SalesTrends", - "name" : "ITC.HomePage.SalesTrends.IconText", - "localizedName" : "Sales and Trends", - "url" : "https://appstoreconnect.apple.com/trends", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/SalesTrends@2x.b1f802112426525d990a.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "FinancialReports", - "name" : "ITC.HomePage.FinancialReports.IconText", - "localizedName" : "Payments and Financial Reports", - "url" : "https://appstoreconnect.apple.com/itc/payments_and_financial_reports", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/FinancialReports@2x.a7b266a5136cc65c643b.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Account", - "name" : "ITC.HomePage.Account.IconText", - "localizedName" : "Users and Access", - "url" : "https://appstoreconnect.apple.com/access/users", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ManageUsers@2x.81511f3933fb2fb4b20d.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "ContractsTaxBanking", - "name" : "ITC.HomePage.ContractsTaxBanking.IconText", - "localizedName" : "Agreements, Tax, and Banking", - "url" : "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/da/jumpTo?page=contracts", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ContractsTaxBanking@2x.74466eb8570dd797602e.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Resources", - "name" : "ITC.HomePage.Resources.IconText", - "localizedName" : "Resources and Help", - "url" : "https://developer.apple.com/app-store-connect/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Resources@2x.3c8d0d8c08e876cf9470.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - } ], - "helpLinks" : [ { - "key" : "AllAsc", - "url" : "https://help.apple.com/app-store-connect/", - "localizedText" : "App Store Connect Resources" - }, { - "key" : "Xcode", - "url" : "https://help.apple.com/xcode/mac/current/", - "localizedText" : "Xcode Help" - }, { - "key" : "SupportContact", - "url" : "https://developer.apple.com/support/", - "localizedText" : "Support and Contact" - } ], - "userProfile" : [ { - "key" : "signIn", - "url" : "https://appstoreconnect.apple.com/login", - "localizedText" : "Sign In" - }, { - "key" : "personalDetails", - "url" : "https://appstoreconnect.apple.com/access/users/07E3E586-44B1-48D3-BF8D-430F754F1BAA/settings", - "localizedText" : "Edit Profile" - }, { - "key" : "signOut", - "url" : "https://appstoreconnect.apple.com/logout", - "localizedText" : "Sign Out" - } ], - "pccDto" : null, - "publicUserId" : "07E3E586-44B1-48D3-BF8D-430F754F1BAA", - "ofacState" : null -} diff --git a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/SignIn.json b/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/SignIn.json deleted file mode 100644 index 10fb36e7..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_2FA_Succeeds/SignIn.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "authType" : "hsa2" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/AuthOptions.json b/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/AuthOptions.json deleted file mode 100644 index 2bbc4bcb..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/AuthOptions.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "trustedPhoneNumbers" : [ { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - }, - { - "obfuscatedNumber" : "(•••) •••-••01", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••01", - "id" : 2 - }], - "securityCode" : { - "length" : 6, - "tooManyCodesSent" : false, - "tooManyCodesValidated" : false, - "securityCodeLocked" : false, - "securityCodeCooldown" : false - }, - "authenticationType" : "hsa2", - "recoveryUrl" : "https://iforgot.apple.com/phone/add?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "cantUsePhoneNumberUrl" : "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "recoveryWebUrl" : "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "repairPhoneNumberUrl" : "https://gsa.apple.com/appleid/account/manage/repair/verify/phone", - "repairPhoneNumberWebUrl" : "https://appleid.apple.com/widget/account/repair?#!repair", - "aboutTwoFactorAuthenticationUrl" : "https://support.apple.com/kb/HT204921", - "autoVerified" : false, - "showAutoVerificationUI" : false, - "managedAccount" : false, - "noTrustedDevices" : true, - "trustedPhoneNumber" : { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - }, - "hsa2Account" : true, - "restrictedAccount" : false, - "supportsRecovery" : true -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/ITCServiceKey.json deleted file mode 100644 index 33c00bf8..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/ITCServiceKey.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "authServiceUrl" : "https://idmsa.apple.com/appleauth", - "authServiceKey" : "NNNNN" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/OlympusSession.json deleted file mode 100644 index 07fb0cbc..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/OlympusSession.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "user" : { - "fullName" : "Test User", - "firstName" : "Test", - "lastName" : "User", - "emailAddress" : "test@example.com", - "prsId" : "000000000" - }, - "provider" : { - "providerId" : 00000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL", - "pla" : [ { - "id" : "1BC01216-52D4-43DC-8555-195F4454C348", - "version" : "5014", - "types" : [ "contractContentTypeDisplay.iOSFreeApps", "contractContentTypeDisplay.MacOSXFreeApplications" ], - "contractCountryOfOrigins" : [ "CAN" ] - } ] - }, - "theme" : "APPSTORE_CONNECT", - "availableProviders" : [ { - "providerId" : 000000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL" - } ], - "backingType" : "ITC", - "backingTypes" : [ "ITC" ], - "roles" : [ "ADMIN", "LEGAL" ], - "unverifiedRoles" : [ ], - "featureFlags" : [ "showWwdrUserRoles", "adpRad", "apiKeys" ], - "agreeToTerms" : true, - "termsSignatures" : [ "ASC", "RAD" ], - "modules" : [ { - "key" : "Apps", - "name" : "ITC.HomePage.Apps.IconText", - "localizedName" : "My Apps", - "url" : "https://appstoreconnect.apple.com/apps", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Apps@2x.d3ce493e56172e92aed6.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "AppAnalytics", - "name" : "ITC.HomePage.AppAnalytics.IconText", - "localizedName" : "App Analytics", - "url" : "https://analytics.itunes.apple.com/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/AppAnalytics@2x.e19f711d943cb42d65b2.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "SalesTrends", - "name" : "ITC.HomePage.SalesTrends.IconText", - "localizedName" : "Sales and Trends", - "url" : "https://appstoreconnect.apple.com/trends", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/SalesTrends@2x.b1f802112426525d990a.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "FinancialReports", - "name" : "ITC.HomePage.FinancialReports.IconText", - "localizedName" : "Payments and Financial Reports", - "url" : "https://appstoreconnect.apple.com/itc/payments_and_financial_reports", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/FinancialReports@2x.a7b266a5136cc65c643b.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Account", - "name" : "ITC.HomePage.Account.IconText", - "localizedName" : "Users and Access", - "url" : "https://appstoreconnect.apple.com/access/users", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ManageUsers@2x.81511f3933fb2fb4b20d.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "ContractsTaxBanking", - "name" : "ITC.HomePage.ContractsTaxBanking.IconText", - "localizedName" : "Agreements, Tax, and Banking", - "url" : "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/da/jumpTo?page=contracts", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ContractsTaxBanking@2x.74466eb8570dd797602e.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Resources", - "name" : "ITC.HomePage.Resources.IconText", - "localizedName" : "Resources and Help", - "url" : "https://developer.apple.com/app-store-connect/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Resources@2x.3c8d0d8c08e876cf9470.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - } ], - "helpLinks" : [ { - "key" : "AllAsc", - "url" : "https://help.apple.com/app-store-connect/", - "localizedText" : "App Store Connect Resources" - }, { - "key" : "Xcode", - "url" : "https://help.apple.com/xcode/mac/current/", - "localizedText" : "Xcode Help" - }, { - "key" : "SupportContact", - "url" : "https://developer.apple.com/support/", - "localizedText" : "Support and Contact" - } ], - "userProfile" : [ { - "key" : "signIn", - "url" : "https://appstoreconnect.apple.com/login", - "localizedText" : "Sign In" - }, { - "key" : "personalDetails", - "url" : "https://appstoreconnect.apple.com/access/users/07E3E586-44B1-48D3-BF8D-430F754F1BAA/settings", - "localizedText" : "Edit Profile" - }, { - "key" : "signOut", - "url" : "https://appstoreconnect.apple.com/logout", - "localizedText" : "Sign Out" - } ], - "pccDto" : null, - "publicUserId" : "07E3E586-44B1-48D3-BF8D-430F754F1BAA", - "ofacState" : null -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/SignIn.json b/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/SignIn.json deleted file mode 100644 index 10fb36e7..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_IncorrectCode/SignIn.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "authType" : "hsa2" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/AuthOptions.json b/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/AuthOptions.json deleted file mode 100644 index 2bbc4bcb..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/AuthOptions.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "trustedPhoneNumbers" : [ { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - }, - { - "obfuscatedNumber" : "(•••) •••-••01", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••01", - "id" : 2 - }], - "securityCode" : { - "length" : 6, - "tooManyCodesSent" : false, - "tooManyCodesValidated" : false, - "securityCodeLocked" : false, - "securityCodeCooldown" : false - }, - "authenticationType" : "hsa2", - "recoveryUrl" : "https://iforgot.apple.com/phone/add?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "cantUsePhoneNumberUrl" : "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "recoveryWebUrl" : "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "repairPhoneNumberUrl" : "https://gsa.apple.com/appleid/account/manage/repair/verify/phone", - "repairPhoneNumberWebUrl" : "https://appleid.apple.com/widget/account/repair?#!repair", - "aboutTwoFactorAuthenticationUrl" : "https://support.apple.com/kb/HT204921", - "autoVerified" : false, - "showAutoVerificationUI" : false, - "managedAccount" : false, - "noTrustedDevices" : true, - "trustedPhoneNumber" : { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - }, - "hsa2Account" : true, - "restrictedAccount" : false, - "supportsRecovery" : true -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/ITCServiceKey.json deleted file mode 100644 index 33c00bf8..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/ITCServiceKey.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "authServiceUrl" : "https://idmsa.apple.com/appleauth", - "authServiceKey" : "NNNNN" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/OlympusSession.json deleted file mode 100644 index 07fb0cbc..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/OlympusSession.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "user" : { - "fullName" : "Test User", - "firstName" : "Test", - "lastName" : "User", - "emailAddress" : "test@example.com", - "prsId" : "000000000" - }, - "provider" : { - "providerId" : 00000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL", - "pla" : [ { - "id" : "1BC01216-52D4-43DC-8555-195F4454C348", - "version" : "5014", - "types" : [ "contractContentTypeDisplay.iOSFreeApps", "contractContentTypeDisplay.MacOSXFreeApplications" ], - "contractCountryOfOrigins" : [ "CAN" ] - } ] - }, - "theme" : "APPSTORE_CONNECT", - "availableProviders" : [ { - "providerId" : 000000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL" - } ], - "backingType" : "ITC", - "backingTypes" : [ "ITC" ], - "roles" : [ "ADMIN", "LEGAL" ], - "unverifiedRoles" : [ ], - "featureFlags" : [ "showWwdrUserRoles", "adpRad", "apiKeys" ], - "agreeToTerms" : true, - "termsSignatures" : [ "ASC", "RAD" ], - "modules" : [ { - "key" : "Apps", - "name" : "ITC.HomePage.Apps.IconText", - "localizedName" : "My Apps", - "url" : "https://appstoreconnect.apple.com/apps", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Apps@2x.d3ce493e56172e92aed6.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "AppAnalytics", - "name" : "ITC.HomePage.AppAnalytics.IconText", - "localizedName" : "App Analytics", - "url" : "https://analytics.itunes.apple.com/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/AppAnalytics@2x.e19f711d943cb42d65b2.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "SalesTrends", - "name" : "ITC.HomePage.SalesTrends.IconText", - "localizedName" : "Sales and Trends", - "url" : "https://appstoreconnect.apple.com/trends", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/SalesTrends@2x.b1f802112426525d990a.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "FinancialReports", - "name" : "ITC.HomePage.FinancialReports.IconText", - "localizedName" : "Payments and Financial Reports", - "url" : "https://appstoreconnect.apple.com/itc/payments_and_financial_reports", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/FinancialReports@2x.a7b266a5136cc65c643b.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Account", - "name" : "ITC.HomePage.Account.IconText", - "localizedName" : "Users and Access", - "url" : "https://appstoreconnect.apple.com/access/users", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ManageUsers@2x.81511f3933fb2fb4b20d.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "ContractsTaxBanking", - "name" : "ITC.HomePage.ContractsTaxBanking.IconText", - "localizedName" : "Agreements, Tax, and Banking", - "url" : "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/da/jumpTo?page=contracts", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ContractsTaxBanking@2x.74466eb8570dd797602e.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Resources", - "name" : "ITC.HomePage.Resources.IconText", - "localizedName" : "Resources and Help", - "url" : "https://developer.apple.com/app-store-connect/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Resources@2x.3c8d0d8c08e876cf9470.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - } ], - "helpLinks" : [ { - "key" : "AllAsc", - "url" : "https://help.apple.com/app-store-connect/", - "localizedText" : "App Store Connect Resources" - }, { - "key" : "Xcode", - "url" : "https://help.apple.com/xcode/mac/current/", - "localizedText" : "Xcode Help" - }, { - "key" : "SupportContact", - "url" : "https://developer.apple.com/support/", - "localizedText" : "Support and Contact" - } ], - "userProfile" : [ { - "key" : "signIn", - "url" : "https://appstoreconnect.apple.com/login", - "localizedText" : "Sign In" - }, { - "key" : "personalDetails", - "url" : "https://appstoreconnect.apple.com/access/users/07E3E586-44B1-48D3-BF8D-430F754F1BAA/settings", - "localizedText" : "Edit Profile" - }, { - "key" : "signOut", - "url" : "https://appstoreconnect.apple.com/logout", - "localizedText" : "Sign Out" - } ], - "pccDto" : null, - "publicUserId" : "07E3E586-44B1-48D3-BF8D-430F754F1BAA", - "ofacState" : null -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/SignIn.json b/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/SignIn.json deleted file mode 100644 index 10fb36e7..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_MultipleNumbers_Succeeds/SignIn.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "authType" : "hsa2" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/AuthOptions.json b/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/AuthOptions.json deleted file mode 100644 index 2910db7c..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/AuthOptions.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "trustedPhoneNumbers" : [], - "securityCode" : { - "length" : 6, - "tooManyCodesSent" : false, - "tooManyCodesValidated" : false, - "securityCodeLocked" : false, - "securityCodeCooldown" : false - }, - "authenticationType" : "hsa2", - "recoveryUrl" : "https://iforgot.apple.com/phone/add?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "cantUsePhoneNumberUrl" : "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "recoveryWebUrl" : "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "repairPhoneNumberUrl" : "https://gsa.apple.com/appleid/account/manage/repair/verify/phone", - "repairPhoneNumberWebUrl" : "https://appleid.apple.com/widget/account/repair?#!repair", - "aboutTwoFactorAuthenticationUrl" : "https://support.apple.com/kb/HT204921", - "autoVerified" : false, - "showAutoVerificationUI" : false, - "managedAccount" : false, - "noTrustedDevices" : true, - "hsa2Account" : true, - "restrictedAccount" : false, - "supportsRecovery" : true -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/ITCServiceKey.json deleted file mode 100644 index 33c00bf8..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/ITCServiceKey.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "authServiceUrl" : "https://idmsa.apple.com/appleauth", - "authServiceKey" : "NNNNN" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/OlympusSession.json deleted file mode 100644 index 07fb0cbc..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/OlympusSession.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "user" : { - "fullName" : "Test User", - "firstName" : "Test", - "lastName" : "User", - "emailAddress" : "test@example.com", - "prsId" : "000000000" - }, - "provider" : { - "providerId" : 00000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL", - "pla" : [ { - "id" : "1BC01216-52D4-43DC-8555-195F4454C348", - "version" : "5014", - "types" : [ "contractContentTypeDisplay.iOSFreeApps", "contractContentTypeDisplay.MacOSXFreeApplications" ], - "contractCountryOfOrigins" : [ "CAN" ] - } ] - }, - "theme" : "APPSTORE_CONNECT", - "availableProviders" : [ { - "providerId" : 000000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL" - } ], - "backingType" : "ITC", - "backingTypes" : [ "ITC" ], - "roles" : [ "ADMIN", "LEGAL" ], - "unverifiedRoles" : [ ], - "featureFlags" : [ "showWwdrUserRoles", "adpRad", "apiKeys" ], - "agreeToTerms" : true, - "termsSignatures" : [ "ASC", "RAD" ], - "modules" : [ { - "key" : "Apps", - "name" : "ITC.HomePage.Apps.IconText", - "localizedName" : "My Apps", - "url" : "https://appstoreconnect.apple.com/apps", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Apps@2x.d3ce493e56172e92aed6.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "AppAnalytics", - "name" : "ITC.HomePage.AppAnalytics.IconText", - "localizedName" : "App Analytics", - "url" : "https://analytics.itunes.apple.com/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/AppAnalytics@2x.e19f711d943cb42d65b2.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "SalesTrends", - "name" : "ITC.HomePage.SalesTrends.IconText", - "localizedName" : "Sales and Trends", - "url" : "https://appstoreconnect.apple.com/trends", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/SalesTrends@2x.b1f802112426525d990a.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "FinancialReports", - "name" : "ITC.HomePage.FinancialReports.IconText", - "localizedName" : "Payments and Financial Reports", - "url" : "https://appstoreconnect.apple.com/itc/payments_and_financial_reports", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/FinancialReports@2x.a7b266a5136cc65c643b.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Account", - "name" : "ITC.HomePage.Account.IconText", - "localizedName" : "Users and Access", - "url" : "https://appstoreconnect.apple.com/access/users", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ManageUsers@2x.81511f3933fb2fb4b20d.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "ContractsTaxBanking", - "name" : "ITC.HomePage.ContractsTaxBanking.IconText", - "localizedName" : "Agreements, Tax, and Banking", - "url" : "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/da/jumpTo?page=contracts", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ContractsTaxBanking@2x.74466eb8570dd797602e.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Resources", - "name" : "ITC.HomePage.Resources.IconText", - "localizedName" : "Resources and Help", - "url" : "https://developer.apple.com/app-store-connect/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Resources@2x.3c8d0d8c08e876cf9470.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - } ], - "helpLinks" : [ { - "key" : "AllAsc", - "url" : "https://help.apple.com/app-store-connect/", - "localizedText" : "App Store Connect Resources" - }, { - "key" : "Xcode", - "url" : "https://help.apple.com/xcode/mac/current/", - "localizedText" : "Xcode Help" - }, { - "key" : "SupportContact", - "url" : "https://developer.apple.com/support/", - "localizedText" : "Support and Contact" - } ], - "userProfile" : [ { - "key" : "signIn", - "url" : "https://appstoreconnect.apple.com/login", - "localizedText" : "Sign In" - }, { - "key" : "personalDetails", - "url" : "https://appstoreconnect.apple.com/access/users/07E3E586-44B1-48D3-BF8D-430F754F1BAA/settings", - "localizedText" : "Edit Profile" - }, { - "key" : "signOut", - "url" : "https://appstoreconnect.apple.com/logout", - "localizedText" : "Sign Out" - } ], - "pccDto" : null, - "publicUserId" : "07E3E586-44B1-48D3-BF8D-430F754F1BAA", - "ofacState" : null -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/SignIn.json b/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/SignIn.json deleted file mode 100644 index 10fb36e7..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_NoNumbers/SignIn.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "authType" : "hsa2" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/AuthOptions.json b/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/AuthOptions.json deleted file mode 100644 index 6bfa630e..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/AuthOptions.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "trustedPhoneNumbers" : [ { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - } ], - "securityCode" : { - "length" : 6, - "tooManyCodesSent" : false, - "tooManyCodesValidated" : false, - "securityCodeLocked" : false, - "securityCodeCooldown" : false - }, - "authenticationType" : "hsa2", - "recoveryUrl" : "https://iforgot.apple.com/phone/add?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "cantUsePhoneNumberUrl" : "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "recoveryWebUrl" : "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "repairPhoneNumberUrl" : "https://gsa.apple.com/appleid/account/manage/repair/verify/phone", - "repairPhoneNumberWebUrl" : "https://appleid.apple.com/widget/account/repair?#!repair", - "aboutTwoFactorAuthenticationUrl" : "https://support.apple.com/kb/HT204921", - "autoVerified" : false, - "showAutoVerificationUI" : false, - "managedAccount" : false, - "noTrustedDevices" : true, - "trustedPhoneNumber" : { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - }, - "hsa2Account" : true, - "restrictedAccount" : false, - "supportsRecovery" : true -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/ITCServiceKey.json deleted file mode 100644 index 33c00bf8..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/ITCServiceKey.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "authServiceUrl" : "https://idmsa.apple.com/appleauth", - "authServiceKey" : "NNNNN" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/OlympusSession.json deleted file mode 100644 index 07fb0cbc..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/OlympusSession.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "user" : { - "fullName" : "Test User", - "firstName" : "Test", - "lastName" : "User", - "emailAddress" : "test@example.com", - "prsId" : "000000000" - }, - "provider" : { - "providerId" : 00000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL", - "pla" : [ { - "id" : "1BC01216-52D4-43DC-8555-195F4454C348", - "version" : "5014", - "types" : [ "contractContentTypeDisplay.iOSFreeApps", "contractContentTypeDisplay.MacOSXFreeApplications" ], - "contractCountryOfOrigins" : [ "CAN" ] - } ] - }, - "theme" : "APPSTORE_CONNECT", - "availableProviders" : [ { - "providerId" : 000000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL" - } ], - "backingType" : "ITC", - "backingTypes" : [ "ITC" ], - "roles" : [ "ADMIN", "LEGAL" ], - "unverifiedRoles" : [ ], - "featureFlags" : [ "showWwdrUserRoles", "adpRad", "apiKeys" ], - "agreeToTerms" : true, - "termsSignatures" : [ "ASC", "RAD" ], - "modules" : [ { - "key" : "Apps", - "name" : "ITC.HomePage.Apps.IconText", - "localizedName" : "My Apps", - "url" : "https://appstoreconnect.apple.com/apps", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Apps@2x.d3ce493e56172e92aed6.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "AppAnalytics", - "name" : "ITC.HomePage.AppAnalytics.IconText", - "localizedName" : "App Analytics", - "url" : "https://analytics.itunes.apple.com/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/AppAnalytics@2x.e19f711d943cb42d65b2.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "SalesTrends", - "name" : "ITC.HomePage.SalesTrends.IconText", - "localizedName" : "Sales and Trends", - "url" : "https://appstoreconnect.apple.com/trends", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/SalesTrends@2x.b1f802112426525d990a.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "FinancialReports", - "name" : "ITC.HomePage.FinancialReports.IconText", - "localizedName" : "Payments and Financial Reports", - "url" : "https://appstoreconnect.apple.com/itc/payments_and_financial_reports", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/FinancialReports@2x.a7b266a5136cc65c643b.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Account", - "name" : "ITC.HomePage.Account.IconText", - "localizedName" : "Users and Access", - "url" : "https://appstoreconnect.apple.com/access/users", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ManageUsers@2x.81511f3933fb2fb4b20d.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "ContractsTaxBanking", - "name" : "ITC.HomePage.ContractsTaxBanking.IconText", - "localizedName" : "Agreements, Tax, and Banking", - "url" : "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/da/jumpTo?page=contracts", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ContractsTaxBanking@2x.74466eb8570dd797602e.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Resources", - "name" : "ITC.HomePage.Resources.IconText", - "localizedName" : "Resources and Help", - "url" : "https://developer.apple.com/app-store-connect/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Resources@2x.3c8d0d8c08e876cf9470.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - } ], - "helpLinks" : [ { - "key" : "AllAsc", - "url" : "https://help.apple.com/app-store-connect/", - "localizedText" : "App Store Connect Resources" - }, { - "key" : "Xcode", - "url" : "https://help.apple.com/xcode/mac/current/", - "localizedText" : "Xcode Help" - }, { - "key" : "SupportContact", - "url" : "https://developer.apple.com/support/", - "localizedText" : "Support and Contact" - } ], - "userProfile" : [ { - "key" : "signIn", - "url" : "https://appstoreconnect.apple.com/login", - "localizedText" : "Sign In" - }, { - "key" : "personalDetails", - "url" : "https://appstoreconnect.apple.com/access/users/07E3E586-44B1-48D3-BF8D-430F754F1BAA/settings", - "localizedText" : "Edit Profile" - }, { - "key" : "signOut", - "url" : "https://appstoreconnect.apple.com/logout", - "localizedText" : "Sign Out" - } ], - "pccDto" : null, - "publicUserId" : "07E3E586-44B1-48D3-BF8D-430F754F1BAA", - "ofacState" : null -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/SignIn.json b/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/SignIn.json deleted file mode 100644 index 10fb36e7..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_IncorrectCode/SignIn.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "authType" : "hsa2" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/AuthOptions.json b/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/AuthOptions.json deleted file mode 100644 index 6bfa630e..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/AuthOptions.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "trustedPhoneNumbers" : [ { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - } ], - "securityCode" : { - "length" : 6, - "tooManyCodesSent" : false, - "tooManyCodesValidated" : false, - "securityCodeLocked" : false, - "securityCodeCooldown" : false - }, - "authenticationType" : "hsa2", - "recoveryUrl" : "https://iforgot.apple.com/phone/add?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "cantUsePhoneNumberUrl" : "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "recoveryWebUrl" : "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "repairPhoneNumberUrl" : "https://gsa.apple.com/appleid/account/manage/repair/verify/phone", - "repairPhoneNumberWebUrl" : "https://appleid.apple.com/widget/account/repair?#!repair", - "aboutTwoFactorAuthenticationUrl" : "https://support.apple.com/kb/HT204921", - "autoVerified" : false, - "showAutoVerificationUI" : false, - "managedAccount" : false, - "noTrustedDevices" : true, - "trustedPhoneNumber" : { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - }, - "hsa2Account" : true, - "restrictedAccount" : false, - "supportsRecovery" : true -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/ITCServiceKey.json deleted file mode 100644 index 33c00bf8..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/ITCServiceKey.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "authServiceUrl" : "https://idmsa.apple.com/appleauth", - "authServiceKey" : "NNNNN" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/OlympusSession.json deleted file mode 100644 index 07fb0cbc..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/OlympusSession.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "user" : { - "fullName" : "Test User", - "firstName" : "Test", - "lastName" : "User", - "emailAddress" : "test@example.com", - "prsId" : "000000000" - }, - "provider" : { - "providerId" : 00000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL", - "pla" : [ { - "id" : "1BC01216-52D4-43DC-8555-195F4454C348", - "version" : "5014", - "types" : [ "contractContentTypeDisplay.iOSFreeApps", "contractContentTypeDisplay.MacOSXFreeApplications" ], - "contractCountryOfOrigins" : [ "CAN" ] - } ] - }, - "theme" : "APPSTORE_CONNECT", - "availableProviders" : [ { - "providerId" : 000000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL" - } ], - "backingType" : "ITC", - "backingTypes" : [ "ITC" ], - "roles" : [ "ADMIN", "LEGAL" ], - "unverifiedRoles" : [ ], - "featureFlags" : [ "showWwdrUserRoles", "adpRad", "apiKeys" ], - "agreeToTerms" : true, - "termsSignatures" : [ "ASC", "RAD" ], - "modules" : [ { - "key" : "Apps", - "name" : "ITC.HomePage.Apps.IconText", - "localizedName" : "My Apps", - "url" : "https://appstoreconnect.apple.com/apps", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Apps@2x.d3ce493e56172e92aed6.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "AppAnalytics", - "name" : "ITC.HomePage.AppAnalytics.IconText", - "localizedName" : "App Analytics", - "url" : "https://analytics.itunes.apple.com/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/AppAnalytics@2x.e19f711d943cb42d65b2.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "SalesTrends", - "name" : "ITC.HomePage.SalesTrends.IconText", - "localizedName" : "Sales and Trends", - "url" : "https://appstoreconnect.apple.com/trends", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/SalesTrends@2x.b1f802112426525d990a.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "FinancialReports", - "name" : "ITC.HomePage.FinancialReports.IconText", - "localizedName" : "Payments and Financial Reports", - "url" : "https://appstoreconnect.apple.com/itc/payments_and_financial_reports", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/FinancialReports@2x.a7b266a5136cc65c643b.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Account", - "name" : "ITC.HomePage.Account.IconText", - "localizedName" : "Users and Access", - "url" : "https://appstoreconnect.apple.com/access/users", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ManageUsers@2x.81511f3933fb2fb4b20d.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "ContractsTaxBanking", - "name" : "ITC.HomePage.ContractsTaxBanking.IconText", - "localizedName" : "Agreements, Tax, and Banking", - "url" : "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/da/jumpTo?page=contracts", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ContractsTaxBanking@2x.74466eb8570dd797602e.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Resources", - "name" : "ITC.HomePage.Resources.IconText", - "localizedName" : "Resources and Help", - "url" : "https://developer.apple.com/app-store-connect/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Resources@2x.3c8d0d8c08e876cf9470.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - } ], - "helpLinks" : [ { - "key" : "AllAsc", - "url" : "https://help.apple.com/app-store-connect/", - "localizedText" : "App Store Connect Resources" - }, { - "key" : "Xcode", - "url" : "https://help.apple.com/xcode/mac/current/", - "localizedText" : "Xcode Help" - }, { - "key" : "SupportContact", - "url" : "https://developer.apple.com/support/", - "localizedText" : "Support and Contact" - } ], - "userProfile" : [ { - "key" : "signIn", - "url" : "https://appstoreconnect.apple.com/login", - "localizedText" : "Sign In" - }, { - "key" : "personalDetails", - "url" : "https://appstoreconnect.apple.com/access/users/07E3E586-44B1-48D3-BF8D-430F754F1BAA/settings", - "localizedText" : "Edit Profile" - }, { - "key" : "signOut", - "url" : "https://appstoreconnect.apple.com/logout", - "localizedText" : "Sign Out" - } ], - "pccDto" : null, - "publicUserId" : "07E3E586-44B1-48D3-BF8D-430F754F1BAA", - "ofacState" : null -} diff --git a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/SignIn.json b/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/SignIn.json deleted file mode 100644 index 10fb36e7..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_SMS_SentAutomatically_Succeeds/SignIn.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "authType" : "hsa2" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/AuthOptions.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/AuthOptions.json deleted file mode 100644 index f521ff87..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/AuthOptions.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "trustedPhoneNumbers" : [ { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - } ], - "securityCode" : { - "length" : 6, - "tooManyCodesSent" : false, - "tooManyCodesValidated" : false, - "securityCodeLocked" : false, - "securityCodeCooldown" : false - }, - "authenticationType" : "hsa2", - "recoveryUrl" : "https://iforgot.apple.com/phone/add?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "cantUsePhoneNumberUrl" : "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "recoveryWebUrl" : "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", - "repairPhoneNumberUrl" : "https://gsa.apple.com/appleid/account/manage/repair/verify/phone", - "repairPhoneNumberWebUrl" : "https://appleid.apple.com/widget/account/repair?#!repair", - "aboutTwoFactorAuthenticationUrl" : "https://support.apple.com/kb/HT204921", - "autoVerified" : false, - "showAutoVerificationUI" : false, - "managedAccount" : false, - "trustedPhoneNumber" : { - "obfuscatedNumber" : "(•••) •••-••00", - "pushMode" : "sms", - "numberWithDialCode" : "+1 (•••) •••-••00", - "id" : 1 - }, - "hsa2Account" : true, - "restrictedAccount" : false, - "supportsRecovery" : true -} diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/Federate.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/Federate.json deleted file mode 100644 index 10fb36e7..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/Federate.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "authType" : "hsa2" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/ITCServiceKey.json deleted file mode 100644 index 33c00bf8..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/ITCServiceKey.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "authServiceUrl" : "https://idmsa.apple.com/appleauth", - "authServiceKey" : "NNNNN" -} diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/OlympusSession.json deleted file mode 100644 index 07fb0cbc..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/OlympusSession.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "user" : { - "fullName" : "Test User", - "firstName" : "Test", - "lastName" : "User", - "emailAddress" : "test@example.com", - "prsId" : "000000000" - }, - "provider" : { - "providerId" : 00000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL", - "pla" : [ { - "id" : "1BC01216-52D4-43DC-8555-195F4454C348", - "version" : "5014", - "types" : [ "contractContentTypeDisplay.iOSFreeApps", "contractContentTypeDisplay.MacOSXFreeApplications" ], - "contractCountryOfOrigins" : [ "CAN" ] - } ] - }, - "theme" : "APPSTORE_CONNECT", - "availableProviders" : [ { - "providerId" : 000000, - "name" : "Test User", - "contentTypes" : [ "SOFTWARE" ], - "subType" : "INDIVIDUAL" - } ], - "backingType" : "ITC", - "backingTypes" : [ "ITC" ], - "roles" : [ "ADMIN", "LEGAL" ], - "unverifiedRoles" : [ ], - "featureFlags" : [ "showWwdrUserRoles", "adpRad", "apiKeys" ], - "agreeToTerms" : true, - "termsSignatures" : [ "ASC", "RAD" ], - "modules" : [ { - "key" : "Apps", - "name" : "ITC.HomePage.Apps.IconText", - "localizedName" : "My Apps", - "url" : "https://appstoreconnect.apple.com/apps", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Apps@2x.d3ce493e56172e92aed6.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "AppAnalytics", - "name" : "ITC.HomePage.AppAnalytics.IconText", - "localizedName" : "App Analytics", - "url" : "https://analytics.itunes.apple.com/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/AppAnalytics@2x.e19f711d943cb42d65b2.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "SalesTrends", - "name" : "ITC.HomePage.SalesTrends.IconText", - "localizedName" : "Sales and Trends", - "url" : "https://appstoreconnect.apple.com/trends", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/SalesTrends@2x.b1f802112426525d990a.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "FinancialReports", - "name" : "ITC.HomePage.FinancialReports.IconText", - "localizedName" : "Payments and Financial Reports", - "url" : "https://appstoreconnect.apple.com/itc/payments_and_financial_reports", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/FinancialReports@2x.a7b266a5136cc65c643b.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Account", - "name" : "ITC.HomePage.Account.IconText", - "localizedName" : "Users and Access", - "url" : "https://appstoreconnect.apple.com/access/users", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ManageUsers@2x.81511f3933fb2fb4b20d.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "ContractsTaxBanking", - "name" : "ITC.HomePage.ContractsTaxBanking.IconText", - "localizedName" : "Agreements, Tax, and Banking", - "url" : "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/da/jumpTo?page=contracts", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ContractsTaxBanking@2x.74466eb8570dd797602e.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - }, { - "key" : "Resources", - "name" : "ITC.HomePage.Resources.IconText", - "localizedName" : "Resources and Help", - "url" : "https://developer.apple.com/app-store-connect/", - "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Resources@2x.3c8d0d8c08e876cf9470.png", - "down" : false, - "visible" : true, - "hasNotifications" : false - } ], - "helpLinks" : [ { - "key" : "AllAsc", - "url" : "https://help.apple.com/app-store-connect/", - "localizedText" : "App Store Connect Resources" - }, { - "key" : "Xcode", - "url" : "https://help.apple.com/xcode/mac/current/", - "localizedText" : "Xcode Help" - }, { - "key" : "SupportContact", - "url" : "https://developer.apple.com/support/", - "localizedText" : "Support and Contact" - } ], - "userProfile" : [ { - "key" : "signIn", - "url" : "https://appstoreconnect.apple.com/login", - "localizedText" : "Sign In" - }, { - "key" : "personalDetails", - "url" : "https://appstoreconnect.apple.com/access/users/07E3E586-44B1-48D3-BF8D-430F754F1BAA/settings", - "localizedText" : "Edit Profile" - }, { - "key" : "signOut", - "url" : "https://appstoreconnect.apple.com/logout", - "localizedText" : "Sign Out" - } ], - "pccDto" : null, - "publicUserId" : "07E3E586-44B1-48D3-BF8D-430F754F1BAA", - "ofacState" : null -} diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/SignIn.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/SignIn.json deleted file mode 100644 index 51e66d33..00000000 --- a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/SignIn.json +++ /dev/null @@ -1,15 +0,0 @@ - - - - 503 Service Temporarily Unavailable - - - -
-

503 Service Temporarily Unavailable

-
-
-
Apple
- - - diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index 067f9cec..5d38145a 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -1,99 +1,134 @@ -@testable import XcodesKit +@testable import XcodesCLIKit import Foundation -import PromiseKit +import XcodesKit +import XcodesLoginKit + +func syncXcodesKitMocks() { + configureXcodesKitFileContents { XcodesCLIKit.Current.files.contents(atPath: $0) } + configureXcodesKitArchs { _ in Shell.processOutputMock } +} extension Environment { - static var mock = Environment( - shell: .mock, - files: .mock, - network: .mock, - logging: .mock, - keychain: .mock - ) + static var mock: Environment { + Environment( + shell: .mock, + files: .mock, + network: .mock, + logging: .mock, + keychain: .mock + ) + } } extension Shell { - static var processOutputMock: ProcessOutput = (0, "", "") + static let processOutputMock: ProcessOutput = (0, "", "") - static var mock = Shell( - unxip: { _ in return Promise.value(Shell.processOutputMock) }, - mountDmg: { _ in return Promise.value(Shell.processOutputMock) }, - unmountDmg: { _ in return Promise.value(Shell.processOutputMock) }, - expandPkg: { _, _ in return Promise.value(Shell.processOutputMock) }, - createPkg: { _, _ in return Promise.value(Shell.processOutputMock) }, - installPkg: { _, _ in return Promise.value(Shell.processOutputMock) }, - installRuntimeImage: { _ in return Promise.value(Shell.processOutputMock) }, - spctlAssess: { _ in return Promise.value(Shell.processOutputMock) }, - codesignVerify: { _ in return Promise.value(Shell.processOutputMock) }, - devToolsSecurityEnable: { _ in return Promise.value(Shell.processOutputMock) }, - addStaffToDevelopersGroup: { _ in return Promise.value(Shell.processOutputMock) }, - acceptXcodeLicense: { _, _ in return Promise.value(Shell.processOutputMock) }, - runFirstLaunch: { _, _ in return Promise.value(Shell.processOutputMock) }, - buildVersion: { return Promise.value(Shell.processOutputMock) }, - xcodeBuildVersion: { _ in return Promise.value(Shell.processOutputMock) }, - getUserCacheDir: { return Promise.value(Shell.processOutputMock) }, - touchInstallCheck: { _, _, _ in return Promise.value(Shell.processOutputMock) }, - installedRuntimes: { return Promise.value(Shell.processOutputMock) }, - validateSudoAuthentication: { return Promise.value(Shell.processOutputMock) }, - // Deliberately using real implementation of authenticateSudoerIfNecessary since it depends on others that can be mocked - xcodeSelectPrintPath: { return Promise.value(Shell.processOutputMock) }, - xcodeSelectSwitch: { _, _ in return Promise.value(Shell.processOutputMock) }, - isRoot: { true }, - readLine: { _ in return nil }, - readSecureLine: { _, _ in return nil }, - env: { _ in nil }, - exit: { _ in }, - isatty: { true } - ) + static var mock: Shell { + Shell( + unxip: { _ in Shell.processOutputMock }, + mountDmg: { _ in Shell.processOutputMock }, + unmountDmg: { _ in Shell.processOutputMock }, + expandPkg: { _, _ in Shell.processOutputMock }, + createPkg: { _, _ in Shell.processOutputMock }, + installPkg: { _, _ in Shell.processOutputMock }, + installRuntimeImage: { _ in Shell.processOutputMock }, + spctlAssess: { _ in Shell.processOutputMock }, + codesignVerify: { _ in Shell.processOutputMock }, + devToolsSecurityEnable: { _ in Shell.processOutputMock }, + addStaffToDevelopersGroup: { _ in Shell.processOutputMock }, + acceptXcodeLicense: { _, _ in Shell.processOutputMock }, + runFirstLaunch: { _, _ in Shell.processOutputMock }, + buildVersion: { Shell.processOutputMock }, + xcodeBuildVersion: { _ in Shell.processOutputMock }, + archs: { _ in Shell.processOutputMock }, + getUserCacheDir: { Shell.processOutputMock }, + touchInstallCheck: { _, _, _ in Shell.processOutputMock }, + installedRuntimes: { Shell.processOutputMock }, + validateSudoAuthentication: { Shell.processOutputMock }, + // Deliberately using real implementation of authenticateSudoerIfNecessary since it depends on others that can be mocked + xcodeSelectPrintPath: { Shell.processOutputMock }, + xcodeSelectSwitch: { _, _ in Shell.processOutputMock }, + isRoot: { true }, + machineArchitecture: { "arm64" }, + readLine: { _ in return nil }, + readLongLine: { _ in return nil }, + readSecureLine: { _, _ in return nil }, + env: { _ in nil }, + exit: { _ in }, + isatty: { true } + ) + } } extension Files { - static var mock = Files( - fileExistsAtPath: { _ in return true }, - attributesOfItemAtPath: { _ in [:] }, - moveItem: { _, _ in return }, - contentsAtPath: { path in - if path.contains("Info.plist") { - let url = Bundle.module.url(forResource: "Stub-0.0.0.Info", withExtension: "plist", subdirectory: "Fixtures")! - return try? Data(contentsOf: url) - } - else if path.contains("version.plist") { - let url = Bundle.module.url(forResource: "Stub.version", withExtension: "plist", subdirectory: "Fixtures")! - return try? Data(contentsOf: url) - } - else { - return nil - } - }, - write: { _, _ in }, - removeItem: { _ in }, - trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") }, - createFile: { _, _, _ in return true }, - createDirectory: { _, _, _ in }, - contentsOfDirectory: { _ in [] }, - installedXcodes: { _ in [] } - ) + static var mock: Files { + Files( + fileExistsAtPath: { _ in return true }, + attributesOfItemAtPath: { _ in [:] }, + moveItem: { _, _ in return }, + contentsAtPath: { path in + if path.contains("Info.plist") { + let url = Bundle.module.url(forResource: "Stub-0.0.0.Info", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else if path.contains("version.plist") { + let url = Bundle.module.url(forResource: "Stub.version", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else { + return nil + } + }, + write: { _, _ in }, + removeItem: { _ in }, + trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") }, + createFile: { _, _, _ in return true }, + createDirectory: { _, _, _ in }, + contentsOfDirectory: { _ in [] }, + installedXcodes: { _ in [] } + ) + } } extension Network { - static var mock = Network( - dataTask: { url in return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) }, - downloadTask: { url, saveLocation, _ in return (Progress(), Promise.value((saveLocation, HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))) }, - validateSession: { Promise() }, - login: { _, _ in Promise() } - ) + static var mock: Network { + Network( + loadData: { urlRequest in + return ( + data: Data(), + response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + }, + downloadTask: { url, saveLocation, _ in + return ( + Progress(), + Task { + (saveLocation, HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + ) + }, + validateSession: {}, + login: { _, _ in }, + checkIsFederated: { _ in FederationResponse(federated: false) }, + validateFederatedCallbackURL: { _ in } + ) + } } extension Logging { - static var mock = Logging( - log: { print($0) } - ) + static var mock: Logging { + Logging( + log: { print($0) } + ) + } } extension Keychain { - static var mock = Keychain( - getString: { _ in return nil }, - set: { _, _ in }, - remove: { _ in } - ) + static var mock: Keychain { + Keychain( + getString: { _ in return nil }, + set: { _, _ in }, + remove: { _ in } + ) + } } diff --git a/Tests/XcodesKitTests/Fixtures/FastlaneCookies.yml b/Tests/XcodesKitTests/Fixtures/FastlaneCookies.yml deleted file mode 100644 index 9cc62586..00000000 --- a/Tests/XcodesKitTests/Fixtures/FastlaneCookies.yml +++ /dev/null @@ -1 +0,0 @@ ----\n- !ruby/object:HTTP::Cookie\n name: myacinfo\n value: myacinfo_dummy\n domain: apple.com\n for_domain: true\n path: "/"\n secure: true\n httponly: true\n expires: \n max_age: \n created_at: 2022-12-12 16:29:24.988867000 +00:00\n accessed_at: 2022-12-12 16:29:24.990398000 +00:00\n- !ruby/object:HTTP::Cookie\n name: DES5a8153bb0fcd039d87286b59d3accea2\n value: DES5a8153bb0fcd039d87286b59d3accea2_dummy\n domain: idmsa.apple.com\n for_domain: true\n path: "/"\n secure: true\n httponly: true\n expires: \n max_age: 2592000\n created_at: 2022-12-09 14:20:51.920971000 +00:00\n accessed_at: 2022-12-12 16:29:23.114185000 +00:00\n- !ruby/object:HTTP::Cookie\n name: dqsid\n value: dqsid_dummy\n domain: appstoreconnect.apple.com\n for_domain: false\n path: "/"\n secure: true\n httponly: true\n expires: \n max_age: 1800\n created_at: &1 2022-12-12 16:29:26.523387000 +00:00\n accessed_at: *1\n diff --git a/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt index de3babf3..54b18668 100644 --- a/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt +++ b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtime_NoBetas.txt @@ -34,9 +34,7 @@ watchOS 7.4 watchOS 8.0 watchOS 8.3 watchOS 8.5 (Bundled with selected Xcode) -watchOS 9.0-beta4 (Installed) -watchOS 9.0 (20R362) -watchOS 9.0 (UnknownBuildNumber) (Installed) +watchOS 9.0 watchOS 9.1 watchOS 9.4 -- tvOS -- @@ -59,3 +57,5 @@ tvOS 16.4 -- visionOS -- Note: Bundled runtimes are indicated for the currently selected Xcode, more bundled runtimes may exist in other Xcode(s) + +Showing runtimes for this Mac by default: Apple Silicon (arm64). Include beta runtimes with `--include-betas`, or switch architecture with `--architecture arm64`, `--architecture x86_64`, `--architecture appleSilicon`, or `--architecture universal`. diff --git a/Tests/XcodesKitTests/Fixtures/LogOutput-Runtimes.txt b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtimes.txt index cedde682..c4c9b36e 100644 --- a/Tests/XcodesKitTests/Fixtures/LogOutput-Runtimes.txt +++ b/Tests/XcodesKitTests/Fixtures/LogOutput-Runtimes.txt @@ -37,9 +37,7 @@ watchOS 7.4 watchOS 8.0 watchOS 8.3 watchOS 8.5 (Bundled with selected Xcode) -watchOS 9.0-beta4 (Installed) -watchOS 9.0 (20R362) -watchOS 9.0 (UnknownBuildNumber) (Installed) +watchOS 9.0 watchOS 9.1 watchOS 9.4 watchOS 10.0-beta1 @@ -67,3 +65,5 @@ tvOS 17.0-beta2 visionOS 1.0-beta1 Note: Bundled runtimes are indicated for the currently selected Xcode, more bundled runtimes may exist in other Xcode(s) + +Showing runtimes for this Mac by default: Apple Silicon (arm64). Switch architecture with `--architecture arm64`, `--architecture x86_64`, `--architecture appleSilicon`, or `--architecture universal`. diff --git a/Tests/XcodesKitTests/LockedBox.swift b/Tests/XcodesKitTests/LockedBox.swift new file mode 100644 index 00000000..3856bd12 --- /dev/null +++ b/Tests/XcodesKitTests/LockedBox.swift @@ -0,0 +1,54 @@ +import Foundation +import os + +final class LockedBox: Sendable { + private let storedValue: OSAllocatedUnfairLock + + init(_ value: Value) { + self.storedValue = OSAllocatedUnfairLock(initialState: value) + } + + var value: Value { + storedValue.withLock { $0 } + } + + func set(_ value: Value) { + storedValue.withLock { $0 = value } + } + + func update(_ body: @Sendable (inout Value) -> Void) { + storedValue.withLock { + body(&$0) + } + } +} + +extension LockedBox where Value == String { + func append(_ string: String) { + update { $0.append(string) } + } +} + +extension LockedBox where Value == Int { + @discardableResult + func increment() -> Int { + storedValue.withLock { + $0 += 1 + return $0 + } + } + + @discardableResult + func incrementAfterRead() -> Int { + storedValue.withLock { value in + defer { value += 1 } + return value + } + } +} + +extension LockedBox where Value == [String] { + func append(_ string: String) { + update { $0.append(string) } + } +} diff --git a/Tests/XcodesKitTests/Models+FirstWithVersionTests.swift b/Tests/XcodesKitTests/Models+FirstWithVersionTests.swift index df6d8dd5..55d354c6 100644 --- a/Tests/XcodesKitTests/Models+FirstWithVersionTests.swift +++ b/Tests/XcodesKitTests/Models+FirstWithVersionTests.swift @@ -1,7 +1,8 @@ import Path import XCTest import Version -@testable import XcodesKit +import XcodesKit +@testable import XcodesCLIKit final class ModelsFirstWithVersionTests: XCTestCase { let xcodes = [ diff --git a/Tests/XcodesKitTests/RuntimeTests.swift b/Tests/XcodesKitTests/RuntimeTests.swift index 04f6c893..c6b9b4db 100644 --- a/Tests/XcodesKitTests/RuntimeTests.swift +++ b/Tests/XcodesKitTests/RuntimeTests.swift @@ -1,36 +1,35 @@ import XCTest import Version -import PromiseKit -import PMKFoundation import Path -import AppleAPI -import Rainbow -@testable import XcodesKit +@preconcurrency import Rainbow +@testable import XcodesCLIKit final class RuntimeTests: XCTestCase { var runtimeList: RuntimeList! var runtimeInstaller: RuntimeInstaller! - override class func setUp() { - super.setUp() - PromiseKit.conf.Q.map = nil - PromiseKit.conf.Q.return = nil - } - override func setUp() { Current = .mock + syncXcodesKitMocks() let sessionService = AppleSessionService(configuration: Configuration()) runtimeList = RuntimeList() runtimeInstaller = RuntimeInstaller(runtimeList: runtimeList, sessionService: sessionService) } func mockDownloadables() { - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == .downloadableRuntimes { - let url = Bundle.module.url(forResource: "DownloadableRuntimes", withExtension: "plist", subdirectory: "Fixtures")! - let downloadsData = try! Data(contentsOf: url) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + let url = Bundle.module.url(forResource: "DownloadableRuntimes", withExtension: "plist", subdirectory: "Fixtures")! + let downloadsData = try! Data(contentsOf: url) + mockDownloadables(data: downloadsData) + } + + func mockDownloadables(data downloadsData: Data) { + XcodesCLIKit.Current.network.loadData = { urlRequest in + if urlRequest.url! == .downloadableRuntimes { + return (data: downloadsData, response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + if urlRequest.url?.absoluteString.hasPrefix("https://developerservices2.apple.com/services/download") == true { + return (data: Data(), response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } fatalError("wrong url") } @@ -53,14 +52,14 @@ final class RuntimeTests: XCTestCase { """ - return Promise.value((0, plist, "")) + return (0, plist, "") } } func test_installedRuntimes() async throws { Current.shell.installedRuntimes = { let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! - return Promise.value((0, try! String(contentsOf: url), "")) + return (0, try! String(contentsOf: url), "") } let values = try await runtimeList.installedRuntimes() let givenIDs = [ @@ -79,41 +78,79 @@ final class RuntimeTests: XCTestCase { func test_downloadableRuntimes() async throws { mockDownloadables() - let values = try await runtimeList.downloadableRuntimes().downloadables + let values = try await runtimeList.downloadableRuntimes() XCTAssertEqual(values.count, 60) } func test_downloadableRuntimesNoBetas() async throws { mockDownloadables() - let values = try await runtimeList.downloadableRuntimes().downloadables.filter { $0.betaNumber == nil } + let values = try await runtimeList.downloadableRuntimes().filter { $0.betaNumber == nil } XCTAssertFalse(values.contains { $0.name.lowercased().contains("beta") }) XCTAssertEqual(values.count, 52) } func test_printAvailableRuntimes() async throws { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } mockDownloadables() Current.shell.installedRuntimes = { let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! - return Promise.value((0, try! String(contentsOf: url), "")) + return (0, try! String(contentsOf: url), "") } try await runtimeInstaller.printAvailableRuntimes(includeBetas: true) let outputUrl = Bundle.module.url(forResource: "LogOutput-Runtimes", withExtension: "txt", subdirectory: "Fixtures")! - XCTAssertEqual(log, try String(contentsOf: outputUrl)) + XCTAssertEqual(log.value, try String(contentsOf: outputUrl)) } func test_printAvailableRuntimes_NoBetas() async throws { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } mockDownloadables() Current.shell.installedRuntimes = { let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! - return Promise.value((0, try! String(contentsOf: url), "")) + return (0, try! String(contentsOf: url), "") } try await runtimeInstaller.printAvailableRuntimes(includeBetas: false) let outputUrl = Bundle.module.url(forResource: "LogOutput-Runtime_NoBetas", withExtension: "txt", subdirectory: "Fixtures")! - XCTAssertEqual(log, try String(contentsOf: outputUrl)) + XCTAssertEqual(log.value, try String(contentsOf: outputUrl)) + } + + func test_printAvailableRuntimes_WithArchitectureFilter_DoesNotPrintOptions() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + mockDownloadables() + Current.shell.installedRuntimes = { + let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! + return (0, try! String(contentsOf: url), "") + } + + try await runtimeInstaller.printAvailableRuntimes(includeBetas: false, architectures: [.variant(.universal)]) + + XCTAssertFalse(log.value.contains("Options:")) + } + + func test_printAvailableRuntimes_ColorsInstalledStatus() async throws { + let originalOutputTarget = Rainbow.outputTarget + let originalEnabled = Rainbow.enabled + Rainbow.outputTarget = .console + Rainbow.enabled = true + defer { + Rainbow.outputTarget = originalOutputTarget + Rainbow.enabled = originalEnabled + } + + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + mockDownloadables() + Current.shell.installedRuntimes = { + let url = Bundle.module.url(forResource: "ShellOutput-InstalledRuntimes", withExtension: "json", subdirectory: "Fixtures")! + return (0, try! String(contentsOf: url), "") + } + + try await runtimeInstaller.printAvailableRuntimes(includeBetas: false) + + XCTAssertTrue(log.value.contains("(\("Installed".blue))")) + XCTAssertTrue(log.value.contains("(\("Bundled with selected Xcode".green))")) } func test_wrongIdentifier() async throws { @@ -128,11 +165,25 @@ final class RuntimeTests: XCTestCase { XCTAssertEqual(resultError, .unavailableRuntime(identifier)) } + func test_downloadRuntimeWithNoSourceSuggestsInstall() async throws { + mockDownloadables() + let identifier = "iOS 18.0-beta1" + var resultError: RuntimeInstaller.Error? = nil + + do { + try await runtimeInstaller.downloadRuntime(identifier: identifier, to: .xcodesCaches, with: .urlSession) + } catch { + resultError = error as? RuntimeInstaller.Error + } + + XCTAssertEqual(resultError, .missingRuntimeSource(identifier)) + } + func test_rootNeededIfPackage() async throws { mockDownloadables() - XcodesKit.Current.shell.isRoot = { false } + XcodesCLIKit.Current.shell.isRoot = { false } let identifier = "iOS 15.5" - let runtime = try await runtimeList.downloadableRuntimes().downloadables.first { $0.visibleIdentifier == identifier }! + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == identifier }! var resultError: RuntimeInstaller.Error? = nil do { try await runtimeInstaller.downloadAndInstallRuntime(identifier: identifier, to: .xcodesCaches, with: .urlSession, shouldDelete: true) @@ -146,9 +197,9 @@ final class RuntimeTests: XCTestCase { func test_rootNotNeededIfDiskImage() async throws { mockDownloadables() - XcodesKit.Current.shell.isRoot = { false } + XcodesCLIKit.Current.shell.isRoot = { false } let identifier = "iOS 16.0" - let runtime = try await runtimeList.downloadableRuntimes().downloadables.first { $0.visibleIdentifier == identifier }! + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == identifier }! var resultError: RuntimeInstaller.Error? = nil do { try await runtimeInstaller.downloadAndInstallRuntime(identifier: identifier, to: .xcodesCaches, with: .urlSession, shouldDelete: true) @@ -163,32 +214,122 @@ final class RuntimeTests: XCTestCase { func test_downloadOrUseExistingArchive_ReturnsExistingArchive() async throws { Current.files.fileExistsAtPath = { _ in return true } mockDownloadables() - let runtime = try await runtimeList.downloadableRuntimes().downloadables.first { $0.visibleIdentifier == "iOS 15.5" }! - var xcodeDownloadURL: URL? + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 15.5" }! + let xcodeDownloadURL = LockedBox(nil) Current.network.downloadTask = { url, _, _ in - xcodeDownloadURL = url.pmkRequest.url - return (Progress(), Promise(error: PMKError.invalidCallingConvention)) + xcodeDownloadURL.set(url.url) + return (Progress(), Task { throw URLError(.unknown) }) } let url = try await runtimeInstaller.downloadOrUseExistingArchive(runtime: runtime, to: .xcodesCaches, downloader: .urlSession) let fileName = URL(string: runtime.source!)!.lastPathComponent XCTAssertEqual(url, Path.xcodesCaches.join(fileName).url) - XCTAssertNil(xcodeDownloadURL) + XCTAssertNil(xcodeDownloadURL.value) } func test_downloadOrUseExistingArchive_DownloadsArchive() async throws { Current.files.fileExistsAtPath = { _ in return false } mockDownloadables() - var xcodeDownloadURL: URL? + let xcodeDownloadURL = LockedBox(nil) Current.network.downloadTask = { url, destination, _ in - xcodeDownloadURL = url.pmkRequest.url - return (Progress(), Promise.value((destination, HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))) + xcodeDownloadURL.set(url.url) + return ( + Progress(), + Task { + (destination, HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + ) } - let runtime = try await runtimeList.downloadableRuntimes().downloadables.first { $0.visibleIdentifier == "iOS 15.5" }! + let runtime = try await runtimeList.downloadableRuntimes().first { $0.visibleIdentifier == "iOS 15.5" }! let fileName = URL(string: runtime.source!)!.lastPathComponent let url = try await runtimeInstaller.downloadOrUseExistingArchive(runtime: runtime, to: .xcodesCaches, downloader: .urlSession) XCTAssertEqual(url, Path.xcodesCaches.join(fileName).url) - XCTAssertEqual(xcodeDownloadURL, URL(string: runtime.source!)!) + XCTAssertEqual(xcodeDownloadURL.value, URL(string: runtime.source!)!) + } + + func test_downloadRuntimePrefersMachineDefaultArchitectureWhenIdentifiersMatch() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + Current.files.fileExistsAtPath = { _ in false } + Current.shell.machineArchitecture = { "arm64" } + Current.shell.isatty = { false } + mockDownloadables(data: Self.duplicateArchitectureRuntimePlistData()) + let xcodeDownloadURL = LockedBox(nil) + Current.network.downloadTask = { url, destination, _ in + xcodeDownloadURL.set(url.url) + return ( + Progress(), + Task { + (destination, HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + ) + } + + try await runtimeInstaller.downloadRuntime(identifier: "iOS 16.0", to: .xcodesCaches, with: .urlSession) + + XCTAssertEqual(xcodeDownloadURL.value, URL(string: "https://example.com/arm64.dmg")!) + XCTAssertTrue(log.value.contains("Downloading Runtime iOS 16.0 - Apple Silicon (arm64)")) + } + + func test_downloadRuntimeDoesNotPrintDefaultArchitectureWhenArchitectureIsSpecified() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + Current.files.fileExistsAtPath = { _ in false } + Current.shell.machineArchitecture = { "arm64" } + Current.shell.isatty = { false } + mockDownloadables(data: Self.duplicateArchitectureRuntimePlistData()) + Current.network.downloadTask = { url, destination, _ in + return ( + Progress(), + Task { + (destination, HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + ) + } + + try await runtimeInstaller.downloadRuntime(identifier: "iOS 16.0", to: .xcodesCaches, with: .urlSession, architectures: [.variant(.appleSilicon)]) + + XCTAssertTrue(log.value.contains("Downloading Runtime iOS 16.0")) + XCTAssertFalse(log.value.contains("Apple Silicon (arm64)")) + } + + func test_downloadAndInstallRuntimeTreatsDuplicateXcodebuildRuntimeAsAlreadyInstalled() async throws { + let log = LockedBox("") + let attempts = LockedBox(0) + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + Current.shell.isatty = { false } + Current.files.contentsAtPath = { path in + guard path == "/Library/Developer/CoreSimulator/images/images.plist" else { return nil } + return Self.installedRuntimeImagesPlistData() + } + mockDownloadables(data: Self.cryptexRuntimePlistData()) + runtimeInstaller = RuntimeInstaller( + runtimeList: runtimeList, + sessionService: AppleSessionService(configuration: Configuration()), + xcodebuildRuntimeInstall: { _, _, _ in + attempts.increment() + throw ProcessExecutionError( + process: Process(), + terminationStatus: 70, + standardOutput: """ + Finding content... + Downloading iOS 16.0 Simulator (20A360) (arm64): Error: Error Domain=SimDiskImageErrorDomain Code=5 "Duplicate of B9DF5553-BDD3-49DF-B82B-96CCA8CB8F70" + """, + standardError: "" + ) + }, + selectedXcodeVersion: { Version(major: 26, minor: 0, patch: 0) } + ) + + try await runtimeInstaller.downloadAndInstallRuntime( + identifier: "iOS 16.0", + to: .xcodesCaches, + with: .urlSession, + shouldDelete: true + ) + + XCTAssertEqual(attempts.value, 1) + XCTAssertTrue(log.value.contains("Runtime iOS 16.0 - Apple Silicon (arm64) is already installed")) } func test_installStepsForPackage() async throws { @@ -202,10 +343,10 @@ final class RuntimeTests: XCTestCase { "creating_pkg", "installing_pkg" ] - var doneSteps: [String] = [] - Current.shell.mountDmg = { _ in doneSteps.append("mounting"); return .value((0, mockDMGPathPlist(), "")) } - Current.shell.expandPkg = { _, _ in doneSteps.append("expanding_pkg"); return .value(Shell.processOutputMock) } - Current.shell.unmountDmg = { _ in doneSteps.append("unmounting"); return .value(Shell.processOutputMock) } + let doneSteps = LockedBox<[String]>([]) + Current.shell.mountDmg = { _ in doneSteps.append("mounting"); return (0, mockDMGPathPlist(), "") } + Current.shell.expandPkg = { _, _ in doneSteps.append("expanding_pkg"); return Shell.processOutputMock } + Current.shell.unmountDmg = { _ in doneSteps.append("unmounting"); return Shell.processOutputMock } Current.files.contentsAtPath = { path in guard path.contains("PackageInfo") else { return nil } doneSteps.append("gettingInfo") @@ -213,50 +354,220 @@ final class RuntimeTests: XCTestCase { return try? Data(contentsOf: url) } Current.files.write = { data, path in - guard path.path.contains("PackageInfo") else { fatalError() } + guard path.path.contains("PackageInfo") else { return } doneSteps.append("wrtitingInfo") let url = Bundle.module.url(forResource: "PackageInfo_after", withExtension: nil, subdirectory: "Fixtures")! let newString = String(data: data, encoding: .utf8) XCTAssertEqual(try? String(contentsOf: url, encoding: .utf8), String(data: data, encoding: .utf8)) XCTAssertTrue(newString?.contains("/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 15.5.simruntime") == true) } - Current.shell.createPkg = { _, _ in doneSteps.append("creating_pkg"); return .value(Shell.processOutputMock) } - Current.shell.installPkg = { _, _ in doneSteps.append("installing_pkg"); return .value(Shell.processOutputMock) } + Current.shell.createPkg = { _, _ in doneSteps.append("creating_pkg"); return Shell.processOutputMock } + Current.shell.installPkg = { _, _ in doneSteps.append("installing_pkg"); return Shell.processOutputMock } try await runtimeInstaller.downloadAndInstallRuntime(identifier: "iOS 15.5", to: .xcodesCaches, with: .urlSession, shouldDelete: true) - XCTAssertEqual(expectedSteps, doneSteps) + XCTAssertEqual(expectedSteps, doneSteps.value) } func test_installStepsForImage() async throws { mockDownloadables() - var didInstall = false + let didInstall = LockedBox(false) Current.shell.installRuntimeImage = { _ in - didInstall = true - return .value(Shell.processOutputMock) + didInstall.set(true) + return Shell.processOutputMock } try await runtimeInstaller.downloadAndInstallRuntime(identifier: "iOS 16.0", to: .xcodesCaches, with: .urlSession, shouldDelete: true) - XCTAssertTrue(didInstall) + XCTAssertTrue(didInstall.value) } func test_deletesArchiveWhenFinished() async throws { mockDownloadables() - var removed = false + let removed = LockedBox(false) Current.files.removeItem = { itemURL in - removed = true + removed.set(true) } try await runtimeInstaller.downloadAndInstallRuntime(identifier: "iOS 16.0", to: .xcodesCaches, with: .urlSession, shouldDelete: true) - XCTAssertTrue(removed) + XCTAssertTrue(removed.value) } func test_KeepArchiveWhenFinished() async throws { mockDownloadables() - var removed = false + let removed = LockedBox(false) Current.files.removeItem = { itemURL in - removed = true + removed.set(true) } try await runtimeInstaller.downloadAndInstallRuntime(identifier: "iOS 16.0", to: .xcodesCaches, with: .urlSession, shouldDelete: false) - XCTAssertFalse(removed) + XCTAssertFalse(removed.value) + } +} + +private extension RuntimeTests { + static func cryptexRuntimePlistData() -> Data { + Data(""" + + + + + sdkToSimulatorMappings + + sdkToSeedMappings + + refreshInterval + 3600 + downloadables + + + category + simulator + simulatorVersion + + buildUpdate + 20A360 + version + 16.0 + + architectures + + arm64 + + dictionaryVersion + 1 + contentType + cryptexDiskImage + platform + com.apple.platform.iphoneos + identifier + com.apple.dmg.iPhoneSimulatorSDK16_0_arm64 + version + 16.0 + fileSize + 42 + name + iOS 16.0 Simulator Runtime + + + version + 2 + + + """.utf8) + } + + static func installedRuntimeImagesPlistData() -> Data { + Data(""" + + + + + images + + + uuid + B9DF5553-BDD3-49DF-B82B-96CCA8CB8F70 + path + + relative + file:///Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime + + runtimeInfo + + build + 20A360 + supportedArchitectures + + arm64 + + + + + + + """.utf8) + } + + static func duplicateArchitectureRuntimePlistData() -> Data { + Data(""" + + + + + sdkToSimulatorMappings + + sdkToSeedMappings + + refreshInterval + 3600 + downloadables + + + category + simulator + simulatorVersion + + buildUpdate + 20A360 + version + 16.0 + + source + https://example.com/universal.dmg + architectures + + arm64 + x86_64 + + dictionaryVersion + 1 + contentType + diskImage + platform + com.apple.platform.iphoneos + identifier + com.apple.CoreSimulator.SimRuntime.iOS-16-0 + version + 16.0 + fileSize + 42 + name + iOS 16.0 + + + category + simulator + simulatorVersion + + buildUpdate + 20A360 + version + 16.0 + + source + https://example.com/arm64.dmg + architectures + + arm64 + + dictionaryVersion + 1 + contentType + diskImage + platform + com.apple.platform.iphoneos + identifier + com.apple.CoreSimulator.SimRuntime.iOS-16-0-arm64 + version + 16.0 + fileSize + 42 + name + iOS 16.0 + + + version + 2 + + + """.utf8) } } diff --git a/Tests/XcodesKitTests/Version+GemTests.swift b/Tests/XcodesKitTests/Version+GemTests.swift index dfa64df8..b5d2e76e 100644 --- a/Tests/XcodesKitTests/Version+GemTests.swift +++ b/Tests/XcodesKitTests/Version+GemTests.swift @@ -1,6 +1,6 @@ import XCTest import Version -@testable import XcodesKit +@testable import XcodesCLIKit class VersionGemTests: XCTestCase { func test_InitGemVersion() { diff --git a/Tests/XcodesKitTests/Version+XcodeTests.swift b/Tests/XcodesKitTests/Version+XcodeTests.swift index 532b6319..3233eba5 100644 --- a/Tests/XcodesKitTests/Version+XcodeTests.swift +++ b/Tests/XcodesKitTests/Version+XcodeTests.swift @@ -1,6 +1,7 @@ import XCTest import Version -@testable import XcodesKit +import XcodesKit +@testable import XcodesCLIKit class VersionXcodeTests: XCTestCase { func test_InitXcodeVersion() { diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 1282d926..900e15ed 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -1,11 +1,17 @@ import XCTest import Version -import PromiseKit -import PMKFoundation import Path -import AppleAPI -import Rainbow -@testable import XcodesKit +@preconcurrency import Rainbow +import XcodesLoginKit +import XcodesKit +import struct XcodesKit.Downloads +import struct XcodesKit.Download +@testable import XcodesCLIKit + +private func configureRainbowForTest(outputTarget: OutputTarget, enabled: Bool) { + Rainbow.outputTarget = outputTarget + Rainbow.enabled = enabled +} final class XcodesKitTests: XCTestCase { static let mockXcode = Xcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) @@ -14,16 +20,10 @@ final class XcodesKitTests: XCTestCase { var xcodeInstaller: XcodeInstaller! var sessionService: AppleSessionService! - override class func setUp() { - super.setUp() - PromiseKit.conf.Q.map = nil - PromiseKit.conf.Q.return = nil - } - override func setUp() { Current = .mock - Rainbow.outputTarget = .unknown - Rainbow.enabled = false + syncXcodesKitMocks() + configureRainbowForTest(outputTarget: .unknown, enabled: false) sessionService = AppleSessionService(configuration: Configuration()) xcodeList = XcodeList() xcodeInstaller = XcodeInstaller(xcodeList: xcodeList, sessionService: sessionService) @@ -51,90 +51,254 @@ final class XcodesKitTests: XCTestCase { XCTAssertEqual(info.bundleIdentifier, "com.apple.dt.Xcode") } - func test_DownloadOrUseExistingArchive_ReturnsExistingArchive() { + func test_DownloadOrUseExistingArchive_ReturnsExistingArchive() async throws { Current.files.fileExistsAtPath = { _ in return true } - var xcodeDownloadURL: URL? + let xcodeDownloadURL = LockedBox(nil) Current.network.downloadTask = { url, _, _ in - xcodeDownloadURL = url.pmkRequest.url - return (Progress(), Promise(error: PMKError.invalidCallingConvention)) + xcodeDownloadURL.set(url.url) + return (Progress(), Task { throw URLError(.unknown) }) } let xcode = Xcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - xcodeInstaller.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) - .tap { result in - guard case .fulfilled(let value) = result else { XCTFail("downloadOrUseExistingArchive rejected."); return } - XCTAssertEqual(value, Path.environmentApplicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) - XCTAssertNil(xcodeDownloadURL) - } - .cauterize() + let value = try await xcodeInstaller.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) + XCTAssertEqual(value, Path.environmentApplicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) + XCTAssertNil(xcodeDownloadURL.value) } - func test_DownloadOrUseExistingArchive_DownloadsArchive() { + func test_DownloadOrUseExistingArchive_DownloadsArchive() async throws { Current.files.fileExistsAtPath = { _ in return false } - var xcodeDownloadURL: URL? + let xcodeDownloadURL = LockedBox(nil) Current.network.downloadTask = { url, destination, _ in - xcodeDownloadURL = url.pmkRequest.url - return (Progress(), Promise.value((destination, HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))) + xcodeDownloadURL.set(url.url) + return ( + Progress(), + Task { + (destination, HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + ) } let xcode = Xcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - xcodeInstaller.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) - .tap { result in - guard case .fulfilled(let value) = result else { XCTFail("downloadOrUseExistingArchive rejected."); return } - XCTAssertEqual(value, Path.environmentApplicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) - XCTAssertEqual(xcodeDownloadURL, URL(string: "https://apple.com/xcode.xip")!) - } - .cauterize() + let value = try await xcodeInstaller.downloadOrUseExistingArchive(for: xcode, downloader: .urlSession, willInstall: true, progressChanged: { _ in }) + XCTAssertEqual(value, Path.environmentApplicationSupport.join("com.robotsandpencils.xcodes").join("Xcode-0.0.0.xip").url) + XCTAssertEqual(xcodeDownloadURL.value, URL(string: "https://apple.com/xcode.xip")!) + } + + func test_InstallLatestPrerelease_WithoutPrereleases_ThrowsNoPrereleaseVersionAvailable() async throws { + Current.files.contentsAtPath = { _ in nil } + Current.network.loadData = { request in + let releases = """ + [ + { + "name": "Xcode", + "version": { + "number": "1.0", + "release": { "release": true } + }, + "date": { "year": 2020, "month": 1, "day": 1 }, + "requires": "10.15", + "links": { + "download": { "url": "https://apple.com/Xcode.xip" } + } + } + ] + """ + return ( + data: Data(releases.utf8), + response: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + + do { + _ = try await xcodeInstaller.install( + .latestPrerelease, + dataSource: .xcodeReleases, + downloader: .urlSession, + destination: Path.root.join("Applications"), + emptyTrash: false, + noSuperuser: true + ) + XCTFail("Expected latest prerelease install to fail without prereleases") + } catch { + XCTAssertEqual(error as? XcodeInstaller.Error, .noPrereleaseVersionAvailable) + } } - func test_InstallArchivedXcode_SecurityAssessmentFails_Throws() { - Current.shell.spctlAssess = { _ in return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) } + func test_InstallLatest_WithUnsupportedMacOSVersion_WarnsAndContinues() async throws { + let log = LockedBox("") + Current.logging.log = { log.append($0 + "\n") } + Current.shell.codesignVerify = { _ in + ( + 0, + "", + """ + TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) + """ + ) + } + Current.network.loadData = { request in + let releases = """ + [ + { + "name": "Xcode", + "version": { + "number": "16.0", + "release": { "release": true } + }, + "date": { "year": 2024, "month": 9, "day": 16 }, + "requires": "15.0", + "links": { + "download": { "url": "https://apple.com/Xcode.xip" } + } + } + ] + """ + return ( + data: Data(releases.utf8), + response: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + xcodeInstaller = XcodeInstaller( + xcodeList: xcodeList, + sessionService: sessionService, + currentOSVersion: { OperatingSystemVersion(majorVersion: 14, minorVersion: 6, patchVersion: 0) } + ) + + _ = try await xcodeInstaller.install( + .latest, + dataSource: .xcodeReleases, + downloader: .urlSession, + destination: Path.root.join("Applications"), + emptyTrash: false, + noSuperuser: true + ) + + XCTAssertTrue(log.value.contains("Warning: Xcode 16.0 requires macOS 15.0 or later. This Mac is running macOS 14.6.0.")) + } + + func test_DownloadLatest_WithUnsupportedMacOSVersion_DoesNotThrow() async throws { + Current.files.contentsAtPath = { _ in nil } + Current.files.fileExistsAtPath = { _ in false } + let downloadedURL = LockedBox(nil) + Current.network.loadData = { request in + let releases = """ + [ + { + "name": "Xcode", + "version": { + "number": "16.0", + "release": { "release": true } + }, + "date": { "year": 2024, "month": 9, "day": 16 }, + "requires": "15.0", + "links": { + "download": { "url": "https://apple.com/Xcode.xip" } + } + } + ] + """ + return ( + data: Data(releases.utf8), + response: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + Current.network.downloadTask = { request, destination, _ in + downloadedURL.set(request.url) + return ( + Progress(), + Task { + (destination, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + ) + } + xcodeInstaller = XcodeInstaller( + xcodeList: xcodeList, + sessionService: sessionService, + currentOSVersion: { OperatingSystemVersion(majorVersion: 14, minorVersion: 6, patchVersion: 0) } + ) + + try await xcodeInstaller.download( + .latest, + dataSource: .xcodeReleases, + downloader: .urlSession, + destinationDirectory: Path.root.join("Downloads") + ) + + XCTAssertEqual(downloadedURL.value, URL(string: "https://apple.com/Xcode.xip")) + } + + func test_InstallArchivedXcode_SecurityAssessmentFails_Throws() async { + Current.shell.spctlAssess = { _ in throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) - .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.failedSecurityAssessment(xcode: installedXcode, output: "")) } + do { + _ = try await xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + XCTFail("Expected install to fail security assessment") + } catch { + XCTAssertEqual(error as? XcodeInstaller.Error, XcodeInstaller.Error.failedSecurityAssessment(xcode: installedXcode, output: "")) + } } - func test_InstallArchivedXcode_VerifySigningCertificateFails_Throws() { - Current.shell.codesignVerify = { _ in return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) } + func test_InstallArchivedXcode_VerifySigningCertificateFails_Throws() async { + Current.shell.codesignVerify = { _ in throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) - xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) - .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed(output: "")) } + do { + _ = try await xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + XCTFail("Expected install to fail code signing verification") + } catch { + XCTAssertEqual(error as? XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed(output: "")) + } } - func test_InstallArchivedXcode_VerifySigningCertificateDoesntMatch_Throws() { - Current.shell.codesignVerify = { _ in return Promise.value((0, "", "")) } + func test_InstallArchivedXcode_VerifySigningCertificateDoesntMatch_Throws() async { + Current.shell.codesignVerify = { _ in (0, "", "") } let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) - xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) - .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.unexpectedCodeSigningIdentity(identifier: "", certificateAuthority: [])) } + do { + _ = try await xcodeInstaller.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + XCTFail("Expected install to fail signing identity check") + } catch { + XCTAssertEqual(error as? XcodeInstaller.Error, XcodeInstaller.Error.unexpectedCodeSigningIdentity(identifier: "", certificateAuthority: [])) + } } - func test_InstallArchivedXcode_TrashesXIPWhenFinished() { - var trashedItemAtURL: URL? + func test_InstallArchivedXcode_TrashesXIPWhenFinished() async throws { + let trashedItemAtURL = LockedBox(nil) Current.files.trashItem = { itemURL in - trashedItemAtURL = itemURL + trashedItemAtURL.set(itemURL) return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash/\(itemURL.lastPathComponent)") } + Current.shell.codesignVerify = { _ in + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) + """) + } let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) let xipURL = URL(fileURLWithPath: "/Xcode-0.0.0.xip") - xcodeInstaller.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) - .ensure { XCTAssertEqual(trashedItemAtURL, xipURL) } - .cauterize() + _ = try await xcodeInstaller.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + XCTAssertEqual(trashedItemAtURL.value, xipURL) } - func test_InstallLogging_FullHappyPath() { - Rainbow.outputTarget = .console - Rainbow.enabled = true + func test_InstallLogging_FullHappyPath() async throws { + configureRainbowForTest(outputTarget: .console, enabled: true) - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // Don't have a valid session - Current.network.validateSession = { Promise(error: AppleAPI.Client.Error.invalidSession) } + Current.network.validateSession = { throw AuthenticationError.invalidSession } // It hasn't been downloaded Current.files.fileExistsAtPath = { path in if path == (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string { @@ -145,89 +309,76 @@ final class XcodesKitTests: XCTestCase { } } // It's an available release version - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { + XcodesCLIKit.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip")], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: downloadsData, response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: Data(), response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } // It downloads and updates progress - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) in + Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>) in let progress = Progress(totalUnitCount: 100) return (progress, - Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { + Task { + // Need this to run after the Task has returned to the caller. This makes the test async, requiring waiting for an expectation. + await MainActor.run { for i in 0...100 { progress.completedUnitCount = Int64(i) } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } + return (saveLocation: saveLocation, + response: HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) }) } // It's a valid .app Current.shell.codesignVerify = { _ in - return Promise.value( - ProcessOutput( - status: 0, - out: "", - err: """ - TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) - """)) + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) + """) } // Don't have superuser privileges the first time - var validateSudoAuthenticationCallCount = 0 - XcodesKit.Current.shell.validateSudoAuthentication = { - validateSudoAuthenticationCallCount += 1 - - if validateSudoAuthenticationCallCount == 1 { - return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) + let validateSudoAuthenticationCallCount = LockedBox(0) + XcodesCLIKit.Current.shell.validateSudoAuthentication = { + if validateSudoAuthenticationCallCount.increment() == 1 { + throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } else { - return Promise.value(Shell.processOutputMock) + return Shell.processOutputMock } } // User enters password - XcodesKit.Current.shell.readSecureLine = { prompt, _ in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readSecureLine = { prompt, _ in + XcodesCLIKit.Current.logging.log(prompt) return "password" } // User enters something - XcodesKit.Current.shell.readLine = { prompt in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readLine = { prompt in + XcodesCLIKit.Current.logging.log(prompt) return "asdf" } - let expectation = self.expectation(description: "Finished") - - xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) - .ensure { - let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath", withExtension: "txt", subdirectory: "Fixtures")! - XCTAssertEqual(log, try! String(contentsOf: url)) - expectation.fulfill() - } - .catch { - XCTFail($0.localizedDescription) - } - - waitForExpectations(timeout: 1.0) + _ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath", withExtension: "txt", subdirectory: "Fixtures")! + XCTAssertEqual(log.value, try String(contentsOf: url)) } - func test_InstallLogging_FullHappyPath_NoColor() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_InstallLogging_FullHappyPath_NoColor() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // Don't have a valid session - Current.network.validateSession = { Promise(error: AppleAPI.Client.Error.invalidSession) } + Current.network.validateSession = { throw AuthenticationError.invalidSession } // It hasn't been downloaded Current.files.fileExistsAtPath = { path in if path == (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string { @@ -238,93 +389,79 @@ final class XcodesKitTests: XCTestCase { } } // It's an available release version - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { + XcodesCLIKit.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip")], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: downloadsData, response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: Data(), response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } // It downloads and updates progress - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) in + Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>) in let progress = Progress(totalUnitCount: 100) return (progress, - Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { + Task { + // Need this to run after the Task has returned to the caller. This makes the test async, requiring waiting for an expectation. + await MainActor.run { for i in 0...100 { progress.completedUnitCount = Int64(i) } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } + return (saveLocation: saveLocation, + response: HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) }) } // It's a valid .app Current.shell.codesignVerify = { _ in - return Promise.value( - ProcessOutput( - status: 0, - out: "", - err: """ - TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) - """)) + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) + """) } // Don't have superuser privileges the first time - var validateSudoAuthenticationCallCount = 0 - XcodesKit.Current.shell.validateSudoAuthentication = { - validateSudoAuthenticationCallCount += 1 - - if validateSudoAuthenticationCallCount == 1 { - return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) + let validateSudoAuthenticationCallCount = LockedBox(0) + XcodesCLIKit.Current.shell.validateSudoAuthentication = { + if validateSudoAuthenticationCallCount.increment() == 1 { + throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } else { - return Promise.value(Shell.processOutputMock) + return Shell.processOutputMock } } // User enters password - XcodesKit.Current.shell.readSecureLine = { prompt, _ in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readSecureLine = { prompt, _ in + XcodesCLIKit.Current.logging.log(prompt) return "password" } // User enters something - XcodesKit.Current.shell.readLine = { prompt in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readLine = { prompt in + XcodesCLIKit.Current.logging.log(prompt) return "asdf" } - let expectation = self.expectation(description: "Finished") - - xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) - .ensure { - let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NoColor", withExtension: "txt", subdirectory: "Fixtures")! - XCTAssertEqual(log, try! String(contentsOf: url)) - expectation.fulfill() - } - .catch { - XCTFail($0.localizedDescription) - } - - waitForExpectations(timeout: 1.0) + _ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NoColor", withExtension: "txt", subdirectory: "Fixtures")! + XCTAssertEqual(log.value, try String(contentsOf: url)) } - func test_InstallLogging_FullHappyPath_NonInteractiveTerminal() { - Rainbow.outputTarget = .unknown - Rainbow.enabled = false - XcodesKit.Current.shell.isatty = { false } + func test_InstallLogging_FullHappyPath_NonInteractiveTerminal() async throws { + configureRainbowForTest(outputTarget: .unknown, enabled: false) + XcodesCLIKit.Current.shell.isatty = { false } - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // Don't have a valid session - Current.network.validateSession = { Promise(error: AppleAPI.Client.Error.invalidSession) } + Current.network.validateSession = { throw AuthenticationError.invalidSession } // It hasn't been downloaded Current.files.fileExistsAtPath = { path in if path == (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string { @@ -335,89 +472,76 @@ final class XcodesKitTests: XCTestCase { } } // It's an available release version - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { + XcodesCLIKit.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip")], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: downloadsData, response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: Data(), response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } // It downloads and updates progress - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) in + Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>) in let progress = Progress(totalUnitCount: 100) return (progress, - Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { + Task { + // Need this to run after the Task has returned to the caller. This makes the test async, requiring waiting for an expectation. + await MainActor.run { for i in 0...100 { progress.completedUnitCount = Int64(i) } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } + return (saveLocation: saveLocation, + response: HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) }) } // It's a valid .app Current.shell.codesignVerify = { _ in - return Promise.value( - ProcessOutput( - status: 0, - out: "", - err: """ - TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) - """)) + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) + """) } // Don't have superuser privileges the first time - var validateSudoAuthenticationCallCount = 0 - XcodesKit.Current.shell.validateSudoAuthentication = { - validateSudoAuthenticationCallCount += 1 - - if validateSudoAuthenticationCallCount == 1 { - return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) + let validateSudoAuthenticationCallCount = LockedBox(0) + XcodesCLIKit.Current.shell.validateSudoAuthentication = { + if validateSudoAuthenticationCallCount.increment() == 1 { + throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } else { - return Promise.value(Shell.processOutputMock) + return Shell.processOutputMock } } // User enters password - XcodesKit.Current.shell.readSecureLine = { prompt, _ in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readSecureLine = { prompt, _ in + XcodesCLIKit.Current.logging.log(prompt) return "password" } // User enters something - XcodesKit.Current.shell.readLine = { prompt in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readLine = { prompt in + XcodesCLIKit.Current.logging.log(prompt) return "asdf" } - let expectation = self.expectation(description: "Finished") - - xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) - .ensure { - let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NonInteractiveTerminal", withExtension: "txt", subdirectory: "Fixtures")! - XCTAssertEqual(log, try! String(contentsOf: url)) - expectation.fulfill() - } - .catch { - XCTFail($0.localizedDescription) - } - - waitForExpectations(timeout: 1.0) + _ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NonInteractiveTerminal", withExtension: "txt", subdirectory: "Fixtures")! + XCTAssertEqual(log.value, try String(contentsOf: url)) } - func test_InstallLogging_AlternativeDirectory() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_InstallLogging_AlternativeDirectory() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // Don't have a valid session - Current.network.validateSession = { Promise(error: AppleAPI.Client.Error.invalidSession) } + Current.network.validateSession = { throw AuthenticationError.invalidSession } // It hasn't been downloaded Current.files.fileExistsAtPath = { path in if path == (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string { @@ -428,107 +552,92 @@ final class XcodesKitTests: XCTestCase { } } // It's an available release version - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { + XcodesCLIKit.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip")], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: downloadsData, response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: Data(), response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } // It downloads and updates progress - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) in + Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>) in let progress = Progress(totalUnitCount: 100) return (progress, - Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { + Task { + // Need this to run after the Task has returned to the caller. This makes the test async, requiring waiting for an expectation. + await MainActor.run { for i in 0...100 { progress.completedUnitCount = Int64(i) } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } + return (saveLocation: saveLocation, + response: HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) }) } // It's a valid .app Current.shell.codesignVerify = { _ in - return Promise.value( - ProcessOutput( - status: 0, - out: "", - err: """ - TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) - """)) + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) + """) } // Don't have superuser privileges the first time - var validateSudoAuthenticationCallCount = 0 - XcodesKit.Current.shell.validateSudoAuthentication = { - validateSudoAuthenticationCallCount += 1 - - if validateSudoAuthenticationCallCount == 1 { - return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) + let validateSudoAuthenticationCallCount = LockedBox(0) + XcodesCLIKit.Current.shell.validateSudoAuthentication = { + if validateSudoAuthenticationCallCount.increment() == 1 { + throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } else { - return Promise.value(Shell.processOutputMock) + return Shell.processOutputMock } } // User enters password - XcodesKit.Current.shell.readSecureLine = { prompt, _ in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readSecureLine = { prompt, _ in + XcodesCLIKit.Current.logging.log(prompt) return "password" } // User enters something - XcodesKit.Current.shell.readLine = { prompt in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readLine = { prompt in + XcodesCLIKit.Current.logging.log(prompt) return "asdf" } - let expectation = self.expectation(description: "Finished") - - xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), emptyTrash: false, noSuperuser: false) - .ensure { - let url = Bundle.module.url(forResource: "LogOutput-AlternativeDirectory", withExtension: "txt", subdirectory: "Fixtures")! - let expectedText = try! String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string) - XCTAssertEqual(log, expectedText) - expectation.fulfill() - } - .catch { - XCTFail($0.localizedDescription) - } - - waitForExpectations(timeout: 1.0) + _ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), emptyTrash: false, noSuperuser: false) + let url = Bundle.module.url(forResource: "LogOutput-AlternativeDirectory", withExtension: "txt", subdirectory: "Fixtures")! + let expectedText = try String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string) + XCTAssertEqual(log.value, expectedText) } - func test_InstallLogging_IncorrectSavedPassword() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_InstallLogging_IncorrectSavedPassword() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // Don't have a valid session - Current.network.validateSession = { Promise(error: AppleAPI.Client.Error.invalidSession) } + Current.network.validateSession = { throw AuthenticationError.invalidSession } // XCODES_PASSWORD has incorrect password - var passwordEnvCallCount = 0 - XcodesKit.Current.shell.env = { key in + let passwordEnvCallCount = LockedBox(0) + XcodesCLIKit.Current.shell.env = { key in if key == "XCODES_PASSWORD" { - passwordEnvCallCount += 1 + passwordEnvCallCount.increment() return "old_password" } else { return nil } } - var loginCallCount = 0 - XcodesKit.Current.network.login = { _, _ in - defer { loginCallCount += 1 } - if loginCallCount == 0 { - return Promise(error: Client.Error.invalidUsernameOrPassword(username: "test@example.com")) + let loginCallCount = LockedBox(0) + XcodesCLIKit.Current.network.login = { _, _ in + if loginCallCount.incrementAfterRead() == 0 { + throw AuthenticationError.invalidUsernameOrPassword(username: "test@example.com") } - return Promise.value(()) } // It hasn't been downloaded Current.files.fileExistsAtPath = { path in @@ -540,108 +649,90 @@ final class XcodesKitTests: XCTestCase { } } // It's an available release version - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { + XcodesCLIKit.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip")], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: downloadsData, response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: Data(), response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } // It downloads and updates progress - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) in + Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>) in let progress = Progress(totalUnitCount: 100) return (progress, - Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { + Task { + // Need this to run after the Task has returned to the caller. This makes the test async, requiring waiting for an expectation. + await MainActor.run { for i in 0...100 { progress.completedUnitCount = Int64(i) } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } + return (saveLocation: saveLocation, + response: HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) }) } // It's a valid .app Current.shell.codesignVerify = { _ in - return Promise.value( - ProcessOutput( - status: 0, - out: "", - err: """ - TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) - """)) + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) + """) } // Don't have superuser privileges the first time - var validateSudoAuthenticationCallCount = 0 - XcodesKit.Current.shell.validateSudoAuthentication = { - validateSudoAuthenticationCallCount += 1 - - if validateSudoAuthenticationCallCount == 1 { - return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) + let validateSudoAuthenticationCallCount = LockedBox(0) + XcodesCLIKit.Current.shell.validateSudoAuthentication = { + if validateSudoAuthenticationCallCount.increment() == 1 { + throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } else { - return Promise.value(Shell.processOutputMock) + return Shell.processOutputMock } } // User enters password - var readSecureLineCallCount = 0 - XcodesKit.Current.shell.readSecureLine = { prompt, _ in - XcodesKit.Current.logging.log(prompt) - readSecureLineCallCount += 1 + let readSecureLineCallCount = LockedBox(0) + XcodesCLIKit.Current.shell.readSecureLine = { prompt, _ in + XcodesCLIKit.Current.logging.log(prompt) + readSecureLineCallCount.increment() return "password" } // User enters something - XcodesKit.Current.shell.readLine = { prompt in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readLine = { prompt in + XcodesCLIKit.Current.logging.log(prompt) return "test@example.com" } - let expectation = self.expectation(description: "Finished") - - xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) - .ensure { - let url = Bundle.module.url(forResource: "LogOutput-IncorrectSavedPassword", withExtension: "txt", subdirectory: "Fixtures")! - XCTAssertEqual(log, try! String(contentsOf: url)) - expectation.fulfill() - - XCTAssertEqual(passwordEnvCallCount, 2) - XCTAssertEqual(readSecureLineCallCount, 2) - } - .catch { - XCTFail($0.localizedDescription) - } - - waitForExpectations(timeout: 1.0) + _ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + let url = Bundle.module.url(forResource: "LogOutput-IncorrectSavedPassword", withExtension: "txt", subdirectory: "Fixtures")! + XCTAssertEqual(log.value, try String(contentsOf: url)) + XCTAssertEqual(passwordEnvCallCount.value, 2) + XCTAssertEqual(readSecureLineCallCount.value, 2) } - func test_InstallLogging_DamagedXIP() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_InstallLogging_DamagedXIP() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // Don't have a valid session - var validateSessionCallCount = 0 + let validateSessionCallCount = LockedBox(0) Current.network.validateSession = { - validateSessionCallCount += 1 - - if validateSessionCallCount == 1 { - return Promise(error: AppleAPI.Client.Error.invalidSession) - } else { - return Promise.value(()) + if validateSessionCallCount.increment() == 1 { + throw AuthenticationError.invalidSession } } // It has been downloaded - var unxipCallCount = 0 + let unxipCallCount = LockedBox(0) Current.files.fileExistsAtPath = { path in if path == (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string { - if unxipCallCount == 1 { + if unxipCallCount.value == 1 { return false } else { return true @@ -652,93 +743,79 @@ final class XcodesKitTests: XCTestCase { } } // It's an available release version - XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { + XcodesCLIKit.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip")], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: downloadsData, response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + return (data: Data(), response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } // It downloads and updates progress - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) in + Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>) in let progress = Progress(totalUnitCount: 100) return (progress, - Promise { resolver in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { + Task { + // Need this to run after the Task has returned to the caller. This makes the test async, requiring waiting for an expectation. + await MainActor.run { for i in 0...100 { progress.completedUnitCount = Int64(i) } - resolver.fulfill((saveLocation: saveLocation, - response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } + return (saveLocation: saveLocation, + response: HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) }) } // It's a valid .app Current.shell.codesignVerify = { _ in - return Promise.value( - ProcessOutput( - status: 0, - out: "", - err: """ - TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) - Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) - """)) + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[0]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[1]) + Authority=\(XcodeInstaller.XcodeCertificateAuthority[2]) + """) } // Don't have superuser privileges the first time - var validateSudoAuthenticationCallCount = 0 + let validateSudoAuthenticationCallCount = LockedBox(0) Current.shell.validateSudoAuthentication = { - validateSudoAuthenticationCallCount += 1 - - if validateSudoAuthenticationCallCount == 1 { - return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) + if validateSudoAuthenticationCallCount.increment() == 1 { + throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } else { - return Promise.value(Shell.processOutputMock) + return Shell.processOutputMock } } // User enters password Current.shell.readSecureLine = { prompt, _ in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.logging.log(prompt) return "password" } // User enters something - XcodesKit.Current.shell.readLine = { prompt in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readLine = { prompt in + XcodesCLIKit.Current.logging.log(prompt) return "asdf" } Current.shell.unxip = { _ in - unxipCallCount += 1 - if unxipCallCount == 1 { - return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: "The file \"Xcode-0.0.0.xip\" is damaged and can’t be expanded.")) + if unxipCallCount.increment() == 1 { + throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: "The file \"Xcode-0.0.0.xip\" is damaged and can’t be expanded.") } else { - return Promise.value(Shell.processOutputMock) + return Shell.processOutputMock } } - let expectation = self.expectation(description: "Finished") - - xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) - .ensure { - let url = Bundle.module.url(forResource: "LogOutput-DamagedXIP", withExtension: "txt", subdirectory: "Fixtures")! - let expectedText = try! String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string) - XCTAssertEqual(log, expectedText) - expectation.fulfill() - } - .catch { - XCTFail($0.localizedDescription) - } - - waitForExpectations(timeout: 1.0) + _ = try await xcodeInstaller.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), emptyTrash: false, noSuperuser: false) + let url = Bundle.module.url(forResource: "LogOutput-DamagedXIP", withExtension: "txt", subdirectory: "Fixtures")! + let expectedText = try String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string) + XCTAssertEqual(log.value, expectedText) } - func test_UninstallXcode() { + func test_UninstallXcode() async throws { // There are installed Xcodes let installedXcodes = [ InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!, @@ -769,33 +846,30 @@ final class XcodesKitTests: XCTestCase { } // The one that's going to be deleted is selected Current.shell.xcodeSelectPrintPath = { - Promise.value((status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "")) + (status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "") } // Trashing succeeds - var trashedItemAtURL: URL? + let trashedItemAtURL = LockedBox(nil) Current.files.trashItem = { itemURL in - trashedItemAtURL = itemURL + trashedItemAtURL.set(itemURL) return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash/\(itemURL.lastPathComponent)") } // Switching succeeds - var selectedPaths: [String] = [] + let selectedPaths = LockedBox<[String]>([]) Current.shell.xcodeSelectSwitch = { password, path in selectedPaths.append(path) - return Promise.value((status: 0, out: "", err: "")) + return (status: 0, out: "", err: "") } - xcodeInstaller.uninstallXcode("0.0.0", directory: Path.root.join("Applications"), emptyTrash: false) - .ensure { - XCTAssertEqual(selectedPaths, ["/Applications/Xcode-2.0.1.app"]) - XCTAssertEqual(trashedItemAtURL, installedXcodes[0].path.url) - } - .cauterize() + try await xcodeInstaller.uninstallXcode("0.0.0", directory: Path.root.join("Applications"), emptyTrash: false) + XCTAssertEqual(selectedPaths.value, []) + XCTAssertEqual(trashedItemAtURL.value, installedXcodes[0].path.url) } - func test_UninstallInteractively() { + func test_UninstallInteractively() async throws { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // There are installed Xcodes let installedXcodes = [ @@ -805,37 +879,33 @@ final class XcodesKitTests: XCTestCase { Current.files.installedXcodes = { _ in installedXcodes } // It prints the expected paths - var xcodeSelectPrintPathCallCount = 0 + let xcodeSelectPrintPathCallCount = LockedBox(0) Current.shell.xcodeSelectPrintPath = { - xcodeSelectPrintPathCallCount += 1 - if xcodeSelectPrintPathCallCount == 1 { - return Promise.value((status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "")) + if xcodeSelectPrintPathCallCount.increment() == 1 { + return (status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "") } else { - return Promise.value((status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "")) + return (status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "") } } // User enters an index - XcodesKit.Current.shell.readLine = { prompt in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readLine = { prompt in + XcodesCLIKit.Current.logging.log(prompt) return "1" } // Trashing succeeds - var trashedItemAtURL: URL? + let trashedItemAtURL = LockedBox(nil) Current.files.trashItem = { itemURL in - trashedItemAtURL = itemURL + trashedItemAtURL.set(itemURL) return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash/\(itemURL.lastPathComponent)") } - xcodeInstaller.uninstallXcode("999", directory: Path.root.join("Applications"), emptyTrash: false) - .ensure { - XCTAssertEqual(trashedItemAtURL, installedXcodes[0].path.url) - } - .cauterize() + try await xcodeInstaller.uninstallXcode("999", directory: Path.root.join("Applications"), emptyTrash: false) + XCTAssertEqual(trashedItemAtURL.value, installedXcodes[0].path.url) - XCTAssertEqual(log, """ + XCTAssertEqual(log.value, """ 999.0 is not installed. Available Xcode versions: 1) 0.0 @@ -846,82 +916,83 @@ final class XcodesKitTests: XCTestCase { """) } - func test_VerifySecurityAssessment_Fails() { - Current.shell.spctlAssess = { _ in return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) } + func test_VerifySecurityAssessment_Fails() async { + Current.shell.spctlAssess = { _ in throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - xcodeInstaller.verifySecurityAssessment(of: installedXcode) - .tap { result in XCTAssertFalse(result.isFulfilled) } - .cauterize() + do { + try await xcodeInstaller.verifySecurityAssessment(of: installedXcode) + XCTFail("Expected security assessment to fail") + } catch { + XCTAssertNotNil(error) + } } - func test_VerifySecurityAssessment_Succeeds() { - Current.shell.spctlAssess = { _ in return Promise.value((0, "", "")) } + func test_VerifySecurityAssessment_Succeeds() async throws { + Current.shell.spctlAssess = { _ in (0, "", "") } let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - xcodeInstaller.verifySecurityAssessment(of: installedXcode) - .tap { result in XCTAssertTrue(result.isFulfilled) } - .cauterize() + try await xcodeInstaller.verifySecurityAssessment(of: installedXcode) } func test_MigrateApplicationSupport_NoSupportFiles() { Current.files.fileExistsAtPath = { _ in return false } - var source: URL? - var destination: URL? - Current.files.moveItem = { source = $0; destination = $1 } - var removedItemAtURL: URL? - Current.files.removeItem = { removedItemAtURL = $0 } + let source = LockedBox(nil) + let destination = LockedBox(nil) + Current.files.moveItem = { source.set($0); destination.set($1) } + let removedItemAtURL = LockedBox(nil) + Current.files.removeItem = { removedItemAtURL.set($0) } migrateApplicationSupportFiles() - XCTAssertNil(source) - XCTAssertNil(destination) - XCTAssertNil(removedItemAtURL) + XCTAssertNil(source.value) + XCTAssertNil(destination.value) + XCTAssertNil(removedItemAtURL.value) } func test_MigrateApplicationSupport_OnlyOldSupportFiles() { Current.files.fileExistsAtPath = { return $0.contains("ca.brandonevans") } - var source: URL? - var destination: URL? - Current.files.moveItem = { source = $0; destination = $1 } - var removedItemAtURL: URL? - Current.files.removeItem = { removedItemAtURL = $0 } + let source = LockedBox(nil) + let destination = LockedBox(nil) + Current.files.moveItem = { source.set($0); destination.set($1) } + let removedItemAtURL = LockedBox(nil) + Current.files.removeItem = { removedItemAtURL.set($0) } migrateApplicationSupportFiles() - XCTAssertEqual(source, Path.environmentApplicationSupport.join("ca.brandonevans.xcodes").url) - XCTAssertEqual(destination, Path.environmentApplicationSupport.join("com.robotsandpencils.xcodes").url) - XCTAssertNil(removedItemAtURL) + XCTAssertEqual(source.value, Path.environmentApplicationSupport.join("ca.brandonevans.xcodes").url) + XCTAssertEqual(destination.value, Path.environmentApplicationSupport.join("com.robotsandpencils.xcodes").url) + XCTAssertNil(removedItemAtURL.value) } func test_MigrateApplicationSupport_OldAndNewSupportFiles() { Current.files.fileExistsAtPath = { _ in return true } - var source: URL? - var destination: URL? - Current.files.moveItem = { source = $0; destination = $1 } - var removedItemAtURL: URL? - Current.files.removeItem = { removedItemAtURL = $0 } + let source = LockedBox(nil) + let destination = LockedBox(nil) + Current.files.moveItem = { source.set($0); destination.set($1) } + let removedItemAtURL = LockedBox(nil) + Current.files.removeItem = { removedItemAtURL.set($0) } migrateApplicationSupportFiles() - XCTAssertNil(source) - XCTAssertNil(destination) - XCTAssertEqual(removedItemAtURL, Path.environmentApplicationSupport.join("ca.brandonevans.xcodes").url) + XCTAssertNil(source.value) + XCTAssertNil(destination.value) + XCTAssertEqual(removedItemAtURL.value, Path.environmentApplicationSupport.join("ca.brandonevans.xcodes").url) } func test_MigrateApplicationSupport_OnlyNewSupportFiles() { Current.files.fileExistsAtPath = { return $0.contains("com.robotsandpencils") } - var source: URL? - var destination: URL? - Current.files.moveItem = { source = $0; destination = $1 } - var removedItemAtURL: URL? - Current.files.removeItem = { removedItemAtURL = $0 } + let source = LockedBox(nil) + let destination = LockedBox(nil) + Current.files.moveItem = { source.set($0); destination.set($1) } + let removedItemAtURL = LockedBox(nil) + Current.files.removeItem = { removedItemAtURL.set($0) } migrateApplicationSupportFiles() - XCTAssertNil(source) - XCTAssertNil(destination) - XCTAssertNil(removedItemAtURL) + XCTAssertNil(source.value) + XCTAssertNil(destination.value) + XCTAssertNil(removedItemAtURL.value) } func test_ParsePrereleaseXcodes() { @@ -934,29 +1005,56 @@ final class XcodesKitTests: XCTestCase { XCTAssertEqual(xcodes[0].version, Version("11.0.0-beta+11M336W")) } - func test_SelectPrint() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_PrintAvailableXcodes_WithoutArchitectureFilter_PrintsMachineDefault() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + Current.shell.xcodeSelectPrintPath = { (status: 0, out: "", err: "") } + + try await xcodeInstaller.printAvailableXcodes([Self.mockXcode], installed: []) + + XCTAssertTrue(log.value.contains("Showing Xcodes for this Mac by default: Apple Silicon (arm64)")) + XCTAssertTrue(log.value.contains("Switch with `--architecture arm64`")) + } + + func test_PrintAvailableXcodes_WithArchitectureFilter_DoesNotPrintOptions() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + Current.shell.xcodeSelectPrintPath = { (status: 0, out: "", err: "") } + let universalXcode = Xcode( + version: Version("15.0.0")!, + url: URL(string: "https://apple.com/xcode.xip")!, + filename: "mock.xip", + releaseDate: nil, + architectures: [.arm64, .x86_64] + ) + + try await xcodeInstaller.printAvailableXcodes([universalXcode], installed: [], architectures: [.variant(.universal)]) + + XCTAssertFalse(log.value.contains("Options:")) + } + + func test_SelectPrint() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } Current.files.installedXcodes = { _ in [InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!, InstalledXcode(path: Path("/Applications/Xcode-2.0.0.app")!)!] } - Current.shell.xcodeSelectPrintPath = { Promise.value((status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "")) } + Current.shell.xcodeSelectPrintPath = { (status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "") } - selectXcode(shouldPrint: true, pathOrVersion: "", directory: Path.root.join("Applications")) - .cauterize() + try await selectXcodeAsync(shouldPrint: true, pathOrVersion: "", directory: Path.root.join("Applications")) - XCTAssertEqual(log, """ + XCTAssertEqual(log.value, """ /Applications/Xcode-2.0.0.app/Contents/Developer """) } - func test_SelectPath() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_SelectPath() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // There are installed Xcodes Current.files.installedXcodes = { _ in @@ -981,42 +1079,38 @@ final class XcodesKitTests: XCTestCase { } } // It prints the expected paths - var xcodeSelectPrintPathCallCount = 0 + let xcodeSelectPrintPathCallCount = LockedBox(0) Current.shell.xcodeSelectPrintPath = { - xcodeSelectPrintPathCallCount += 1 - if xcodeSelectPrintPathCallCount == 1 { - return Promise.value((status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "")) + if xcodeSelectPrintPathCallCount.increment() == 1 { + return (status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "") } else { - return Promise.value((status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "")) + return (status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "") } } // Don't have superuser privileges the first time - var validateSudoAuthenticationCallCount = 0 + let validateSudoAuthenticationCallCount = LockedBox(0) Current.shell.validateSudoAuthentication = { - validateSudoAuthenticationCallCount += 1 - - if validateSudoAuthenticationCallCount == 1 { - return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) + if validateSudoAuthenticationCallCount.increment() == 1 { + throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } else { - return Promise.value(Shell.processOutputMock) + return Shell.processOutputMock } } // User enters password Current.shell.readSecureLine = { prompt, _ in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.logging.log(prompt) return "password" } // It successfully switches Current.shell.xcodeSelectSwitch = { _, _ in - Promise.value((status: 0, out: "", err: "")) + (status: 0, out: "", err: "") } - selectXcode(shouldPrint: false, pathOrVersion: "/Applications/Xcode-0.0.0.app", directory: Path.root.join("Applications")) - .cauterize() + try await selectXcodeAsync(shouldPrint: false, pathOrVersion: "/Applications/Xcode-0.0.0.app", directory: Path.root.join("Applications")) - XCTAssertEqual(log, """ + XCTAssertEqual(log.value, """ xcodes requires superuser privileges to select an Xcode macOS User Password: Selected /Applications/Xcode-0.0.0.app/Contents/Developer @@ -1024,9 +1118,9 @@ final class XcodesKitTests: XCTestCase { """) } - func test_SelectInteractively() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_SelectInteractively() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // There are installed Xcodes Current.files.installedXcodes = { _ in @@ -1057,47 +1151,43 @@ final class XcodesKitTests: XCTestCase { return true } // It prints the expected paths - var xcodeSelectPrintPathCallCount = 0 + let xcodeSelectPrintPathCallCount = LockedBox(0) Current.shell.xcodeSelectPrintPath = { - xcodeSelectPrintPathCallCount += 1 - if xcodeSelectPrintPathCallCount == 1 { - return Promise.value((status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "")) + if xcodeSelectPrintPathCallCount.increment() == 1 { + return (status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "") } else { - return Promise.value((status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "")) + return (status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "") } } // User enters an index - XcodesKit.Current.shell.readLine = { prompt in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.shell.readLine = { prompt in + XcodesCLIKit.Current.logging.log(prompt) return "1" } // Don't have superuser privileges the first time - var validateSudoAuthenticationCallCount = 0 + let validateSudoAuthenticationCallCount = LockedBox(0) Current.shell.validateSudoAuthentication = { - validateSudoAuthenticationCallCount += 1 - - if validateSudoAuthenticationCallCount == 1 { - return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) + if validateSudoAuthenticationCallCount.increment() == 1 { + throw ProcessExecutionError(process: Process(), standardOutput: nil, standardError: nil) } else { - return Promise.value(Shell.processOutputMock) + return Shell.processOutputMock } } // User enters password Current.shell.readSecureLine = { prompt, _ in - XcodesKit.Current.logging.log(prompt) + XcodesCLIKit.Current.logging.log(prompt) return "password" } // It successfully switches Current.shell.xcodeSelectSwitch = { _, _ in - Promise.value((status: 0, out: "", err: "")) + (status: 0, out: "", err: "") } - selectXcode(shouldPrint: false, pathOrVersion: "", directory: Path.root.join("Applications")) - .cauterize() + try await selectXcodeAsync(shouldPrint: false, pathOrVersion: "", directory: Path.root.join("Applications")) - XCTAssertEqual(log, """ + XCTAssertEqual(log.value, """ Available Xcode versions: 1) 0.0 (ABC123) 2) 2.0.1 (ABC123) (Selected) @@ -1109,9 +1199,9 @@ final class XcodesKitTests: XCTestCase { """) } - func test_SelectUsingXcodeVersionFile() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_SelectUsingXcodeVersionFile() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // There are installed Xcodes Current.files.installedXcodes = { _ in @@ -1145,34 +1235,33 @@ final class XcodesKitTests: XCTestCase { return true } // It prints the expected paths - var xcodeSelectPrintPathCallCount = 0 + let xcodeSelectPrintPathCallCount = LockedBox(0) Current.shell.xcodeSelectPrintPath = { - defer { xcodeSelectPrintPathCallCount += 1 } - if xcodeSelectPrintPathCallCount == 0 { - return Promise.value((status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "")) - } else if xcodeSelectPrintPathCallCount == 1 { - return Promise.value((status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "")) - } else { + switch xcodeSelectPrintPathCallCount.incrementAfterRead() { + case 0: + return (status: 0, out: "/Applications/Xcode-0.0.0.app/Contents/Developer", err: "") + case 1: + return (status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "") + default: fatalError("Unexpected third invocation of xcode select") } } // It successfully switches Current.shell.xcodeSelectSwitch = { _, _ in - Promise.value((status: 0, out: "", err: "")) + (status: 0, out: "", err: "") } - selectXcode(shouldPrint: false, pathOrVersion: "", directory: Path.root.join("Applications")) - .cauterize() + try await selectXcodeAsync(shouldPrint: false, pathOrVersion: "", directory: Path.root.join("Applications")) - XCTAssertEqual(log, """ + XCTAssertEqual(log.value, """ Selected /Applications/Xcode-2.0.1.app/Contents/Developer """) } - func test_Installed_InteractiveTerminal() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_Installed_InteractiveTerminal() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // There are installed Xcodes Current.files.contentsAtPath = { path in @@ -1205,17 +1294,16 @@ final class XcodesKitTests: XCTestCase { // One is selected Current.shell.xcodeSelectPrintPath = { - Promise.value((status: 0, out: "/Applications/Xcode-2.0.1-Release.Candidate.app/Contents/Developer", err: "")) + (status: 0, out: "/Applications/Xcode-2.0.1-Release.Candidate.app/Contents/Developer", err: "") } // Standard output is an interactive terminal Current.shell.isatty = { true } - xcodeInstaller.printInstalledXcodes(directory: Path.root/"Applications") - .cauterize() + try await xcodeInstaller.printInstalledXcodes(directory: Path.root/"Applications") XCTAssertEqual( - log, + log.value, """ 0.0 (ABC123) /Applications/Xcode-0.0.0.app 2.0 (ABC123) /Applications/Xcode-2.0.0.app @@ -1225,9 +1313,9 @@ final class XcodesKitTests: XCTestCase { ) } - func test_Installed_NonInteractiveTerminal() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_Installed_NonInteractiveTerminal() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // There are installed Xcodes Current.files.contentsAtPath = { path in @@ -1260,17 +1348,16 @@ final class XcodesKitTests: XCTestCase { // One is selected Current.shell.xcodeSelectPrintPath = { - Promise.value((status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "")) + (status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "") } // Standard output is not an interactive terminal Current.shell.isatty = { false } - xcodeInstaller.printInstalledXcodes(directory: Path.root/"Applications") - .cauterize() + try await xcodeInstaller.printInstalledXcodes(directory: Path.root/"Applications") XCTAssertEqual( - log, + log.value, """ 0.0 (ABC123)\t/Applications/Xcode-0.0.0.app 2.0 (ABC123) (Selected)\t/Applications/Xcode-2.0.0.app @@ -1280,9 +1367,9 @@ final class XcodesKitTests: XCTestCase { ) } - func test_Installed_WithValidVersion_PrintsXcodePath() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_Installed_WithValidVersion_PrintsXcodePath() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // There are installed Xcodes Current.files.contentsAtPath = { path in @@ -1305,17 +1392,16 @@ final class XcodesKitTests: XCTestCase { // One is selected Current.shell.xcodeSelectPrintPath = { - Promise.value((status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "")) + (status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "") } // Standard output is not an interactive terminal Current.shell.isatty = { false } - xcodeInstaller.printXcodePath(ofVersion: "2", searchingIn: Path.root/"Applications") - .cauterize() + try await xcodeInstaller.printXcodePath(ofVersion: "2", searchingIn: Path.root/"Applications") XCTAssertEqual( - log, + log.value, """ /Applications/Xcode-2.0.0.app @@ -1323,9 +1409,9 @@ final class XcodesKitTests: XCTestCase { ) } - func test_Installed_WithUninstalledVersion_ThrowsError() { - var log = "" - XcodesKit.Current.logging.log = { log.append($0 + "\n") } + func test_Installed_WithUninstalledVersion_ThrowsError() async { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } // There are installed Xcodes Current.files.contentsAtPath = { path in @@ -1348,61 +1434,86 @@ final class XcodesKitTests: XCTestCase { // One is selected Current.shell.xcodeSelectPrintPath = { - Promise.value((status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "")) + (status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "") } // Standard output is not an interactive terminal Current.shell.isatty = { false } - xcodeInstaller.printXcodePath(ofVersion: "3", searchingIn: Path.root/"Applications") - .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.versionNotInstalled(Version(xcodeVersion: "3")!)) } + do { + try await xcodeInstaller.printXcodePath(ofVersion: "3", searchingIn: Path.root/"Applications") + XCTFail("Expected uninstalled version to throw") + } catch { + XCTAssertEqual(error as? XcodeInstaller.Error, XcodeInstaller.Error.versionNotInstalled(Version(xcodeVersion: "3")!)) + } } - func test_Signout_WithExistingSession() { - var keychainDidRemove = false + func test_Signout_WithExistingSession() async throws { + let keychainDidRemove = LockedBox(false) Current.keychain.remove = { _ in - keychainDidRemove = true + keychainDidRemove.set(true) } var customConfig = Configuration() customConfig.defaultUsername = "test@example.com" let customService = AppleSessionService(configuration: customConfig) - let expectation = self.expectation(description: "Signout complete") + try await customService.logout() - customService.logout() - .ensure { expectation.fulfill() } - .catch { - XCTFail($0.localizedDescription) - } - - waitForExpectations(timeout: 3.0) - - XCTAssertTrue(keychainDidRemove) + XCTAssertTrue(keychainDidRemove.value) } - func test_Signout_WithoutExistingSession() { + func test_Signout_WithoutExistingSession() async { var customConfig = Configuration() customConfig.defaultUsername = nil let customService = AppleSessionService(configuration: customConfig) - var capturedError: Error? + do { + try await customService.logout() + XCTFail("Expected signout to fail without an existing session") + } catch { + XCTAssertEqual(error as? AppleSessionService.Error, AppleSessionService.Error.notAuthenticated) + } + } - let expectation = self.expectation(description: "Signout complete") + func test_Signout_RemovesCookiesFromDownloadSession() async throws { + let cookie = try XCTUnwrap(HTTPCookie(properties: [ + .domain: "developer.apple.com", + .path: "/", + .name: "ADCDownloadAuth", + .value: "test", + .secure: "TRUE" + ])) + Current.network.session.configuration.httpCookieStorage?.setCookie(cookie) - customService.logout() - .ensure { expectation.fulfill() } - .catch { error in - capturedError = error - } - waitForExpectations(timeout: 1.0) + XCTAssertEqual(Current.network.session.configuration.httpCookieStorage?.cookies?.contains(cookie), true) - XCTAssertEqual(capturedError as? Client.Error, Client.Error.notAuthenticated) + await Current.network.signout() + + XCTAssertEqual(Current.network.session.configuration.httpCookieStorage?.cookies?.contains(cookie), false) + } + + func test_Signout_RemovesCookiesAfterDownloadSessionIsReplaced() async throws { + Current.network.session = URLSession(configuration: .ephemeral) + let cookie = try XCTUnwrap(HTTPCookie(properties: [ + .domain: "developer.apple.com", + .path: "/", + .name: "FASTLANE_SESSION", + .value: "test", + .secure: "TRUE" + ])) + Current.network.session.configuration.httpCookieStorage?.setCookie(cookie) + + XCTAssertEqual(Current.network.session.configuration.httpCookieStorage?.cookies?.contains(cookie), true) + + await Current.network.signout() + + XCTAssertEqual(Current.network.session.configuration.httpCookieStorage?.cookies?.contains(cookie), false) } func test_XcodeList_ShouldUpdate_NotWhenCacheFileIsRecent() { Current.files.contentsAtPath = { _ in try! JSONEncoder().encode([Self.mockXcode]) } - Current.files.attributesOfItemAtPath = { _ in [.modificationDate: Date(timeIntervalSinceNow: -3600*12)] } + Current.files.attributesOfItemAtPath = { _ in [.modificationDate: Date(timeIntervalSinceNow: -3600)] } let xcodesList = XcodeList() @@ -1411,7 +1522,7 @@ final class XcodesKitTests: XCTestCase { func test_XcodeList_ShouldUpdate_WhenCacheFileIsOld() { Current.files.contentsAtPath = { _ in try! JSONEncoder().encode([Self.mockXcode]) } - Current.files.attributesOfItemAtPath = { _ in [.modificationDate: Date(timeIntervalSinceNow: -3600*24*2)] } + Current.files.attributesOfItemAtPath = { _ in [.modificationDate: Date(timeIntervalSinceNow: -3600*6)] } let xcodesList = XcodeList() @@ -1451,32 +1562,4 @@ final class XcodesKitTests: XCTestCase { XCTAssert(xcodesList.availableXcodes == [Self.mockXcode]) } - func test_FastlaneCookieParser_ShouldParseCookies() throws { - let url = Bundle.module.url(forResource: "FastlaneCookies", withExtension: "yml", subdirectory: "Fixtures")! - - let cookieString = try String(contentsOf: url) - - let parser = FastlaneCookieParser() - let cookies = try parser.parse(cookieString: cookieString) - - XCTAssertEqual(cookies.count, 3) - - XCTAssertEqual(cookies[0].name, "myacinfo") - XCTAssertEqual(cookies[0].value, "myacinfo_dummy") - XCTAssertEqual(cookies[0].domain, ".apple.com") - XCTAssertEqual(cookies[0].path, "/") - XCTAssertEqual(cookies[0].isSecure, true) - - XCTAssertEqual(cookies[1].name, "DES5a8153bb0fcd039d87286b59d3accea2") - XCTAssertEqual(cookies[1].value, "DES5a8153bb0fcd039d87286b59d3accea2_dummy") - XCTAssertEqual(cookies[1].domain, ".idmsa.apple.com") - XCTAssertEqual(cookies[1].path, "/") - XCTAssertEqual(cookies[1].isSecure, true) - - XCTAssertEqual(cookies[2].name, "dqsid") - XCTAssertEqual(cookies[2].value, "dqsid_dummy") - XCTAssertEqual(cookies[2].domain, "appstoreconnect.apple.com") - XCTAssertEqual(cookies[2].path, "/") - XCTAssertEqual(cookies[2].isSecure, true) - } }