diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index a80af43..c1ba851 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; }; + CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */; }; + CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA735108257BF96D00EA9CF8 /* AttributedText.swift */; }; + CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */; }; CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; }; CAA1CB2F255A5262003FD669 /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2E255A5262003FD669 /* XcodesKit */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; @@ -40,6 +43,9 @@ CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = ""; }; CA538A0F255A4F3300E64DD7 /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = ""; }; + CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = ""; }; + CA735108257BF96D00EA9CF8 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = ""; }; + CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+.swift"; sourceTree = ""; }; CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CA8FB61C256E115700469DA5 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = ""; }; CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = XcodesTest.entitlements; sourceTree = ""; }; @@ -92,6 +98,7 @@ children = ( CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */, CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */, + CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */, CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */, CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */, ); @@ -130,6 +137,8 @@ CAA1CB50255A5D16003FD669 /* SignIn */, CA378F982466567600A58CE0 /* AppState.swift */, CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */, + CA735108257BF96D00EA9CF8 /* AttributedText.swift */, + CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */, CA44901E2463AD34003D8213 /* Tag.swift */, CAD2E7A52449575000113D76 /* Assets.xcassets */, CAD2E7AA2449575000113D76 /* Main.storyboard */, @@ -264,14 +273,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */, CA44901F2463AD34003D8213 /* Tag.swift in Sources */, CA378F992466567600A58CE0 /* AppState.swift in Sources */, CAD2E7A42449574E00113D76 /* ContentView.swift in Sources */, CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */, CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, + CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */, + CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */, CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1091406..919434a 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/Xcodes/AppState.swift b/Xcodes/AppState.swift index d34df78..2ba4907 100644 --- a/Xcodes/AppState.swift +++ b/Xcodes/AppState.swift @@ -22,6 +22,8 @@ class AppState: ObservableObject { case installing(Progress) case installed } + + @Published var authenticationState: AuthenticationState = .unauthenticated @Published var allVersions: [XcodeVersion] = [] struct AlertContent: Identifiable { @@ -32,53 +34,175 @@ class AppState: ObservableObject { @Published var error: AlertContent? @Published var presentingSignInAlert = false + @Published var secondFactorData: SecondFactorData? + + struct SecondFactorData { + let option: TwoFactorOption + let authOptions: AuthOptionsResponse + let sessionData: AppleSessionData + } + + private var cancellables = Set() + let client = AppleAPI.Client() func load() { // if list.shouldUpdate { + // Treat this implementation as a placeholder that can be thrown away. + // It's only here to make it easy to see that auth works. update() - .done { _ in - self.updateAllVersions() - } - .catch { error in - self.error = AlertContent(title: "Error", - message: error.localizedDescription) - } -// } + .sink( + receiveCompletion: { completion in + dump(completion) + }, + receiveValue: { xcodes in + let installedXcodes = Current.files.installedXcodes(Path.root/"Applications") + var allXcodeVersions = xcodes.map { $0.version } + for installedXcode in installedXcodes { + // If an installed version isn't listed online, add the installed version + if !allXcodeVersions.contains(where: { version in + version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) + }) { + allXcodeVersions.append(installedXcode.version) + } + // If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version with build metadata + else if let index = allXcodeVersions.firstIndex(where: { version in + version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) && + version.buildMetadataIdentifiers.isEmpty + }) { + allXcodeVersions[index] = installedXcode.version + } + } + + self.allVersions = allXcodeVersions + .sorted(by: >) + .map { xcodeVersion in + let installedXcode = installedXcodes.first(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) + return XcodeVersion( + title: xcodeVersion.xcodeDescription, + installState: installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) ? .installed : .notInstalled, + selected: installedXcode?.path.string.contains("12.2") == true, + path: installedXcode?.path.string + ) + } + } + ) + .store(in: &cancellables) +// .done { _ in +// self.updateAllVersions() +// } +// .catch { error in +// self.error = AlertContent(title: "Error", +// message: error.localizedDescription) +// } +//// } // else { // updateAllVersions() // } } - func validateSession() -> Promise { - return firstly { () -> Promise in - return Current.network.validateSession() - } - .recover { _ in - self.presentingSignInAlert = true - } + // MARK: - Authentication + + func validateSession() -> AnyPublisher { + return client.validateSession() + .handleEvents(receiveCompletion: { completion in + if case .failure = completion { + self.authenticationState = .unauthenticated + self.presentingSignInAlert = true + } + }) + .eraseToAnyPublisher() } - func continueLogin(username: String, password: String) -> Promise { - firstly { () -> Promise in - self.installer.login(username, password: password) - } - .recover { error -> Promise in - XcodesKit.Current.logging.log(error.legibleLocalizedDescription) + func login(username: String, password: String) { + client.login(accountName: username, password: password) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { completion in + self.handleAuthenticationFlowCompletion(completion) + }, + receiveValue: { authenticationState in + self.authenticationState = authenticationState + } + ) + .store(in: &cancellables) + } + + func handleTwoFactorOption(_ option: TwoFactorOption, authOptions: AuthOptionsResponse, serviceKey: String, sessionID: String, scnt: String) { + self.presentingSignInAlert = false + self.secondFactorData = SecondFactorData( + option: option, + authOptions: authOptions, + sessionData: AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + ) + } - if case Client.Error.invalidUsernameOrPassword = error { - self.presentingSignInAlert = true + func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { + client.requestSMSSecurityCode(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData) + .sink( + receiveCompletion: { completion in + self.handleAuthenticationFlowCompletion(completion) + }, + receiveValue: { authenticationState in + self.authenticationState = authenticationState + if case let AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData) = authenticationState { + self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) + } + } + ) + .store(in: &cancellables) + } + + func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { + secondFactorData = SecondFactorData(option: .smsPendingChoice, authOptions: authOptions, sessionData: sessionData) + } + + func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) { + client.submitSecurityCode(code, sessionData: sessionData) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { completion in + self.handleAuthenticationFlowCompletion(completion) + }, + receiveValue: { authenticationState in + self.authenticationState = authenticationState + } + ) + .store(in: &cancellables) + } + + private func handleAuthenticationFlowCompletion(_ completion: Subscribers.Completion) { + switch completion { + case let .failure(error): + self.error = AlertContent(title: "Error signing in", message: error.legibleLocalizedDescription) + case .finished: + switch self.authenticationState { + case .authenticated, .unauthenticated: + self.presentingSignInAlert = false + self.secondFactorData = nil + case let .waitingForSecondFactor(option, authOptions, sessionData): + self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) } - return Promise(error: error) } } - public func update() -> Promise<[Xcode]> { - return firstly { () -> Promise in - validateSession() - } - .then { () -> Promise<[Xcode]> in - self.list.update() + // MARK: - + + public func update() -> AnyPublisher<[Xcode], Error> { +// return firstly { () -> Promise in +// validateSession() +// } +// .then { () -> Promise<[Xcode]> in +// self.list.update() +// } + // Wrap the Promise API in a Publisher for now + return Deferred { + Future { promise in + self.list.update() + .done { promise(.success($0)) } + .catch { promise(.failure($0)) } + } } + .eraseToAnyPublisher() } private func updateAllVersions() { diff --git a/Xcodes/AppleAPI/Package.swift b/Xcodes/AppleAPI/Package.swift index bfab7a9..b4c2d5c 100644 --- a/Xcodes/AppleAPI/Package.swift +++ b/Xcodes/AppleAPI/Package.swift @@ -5,24 +5,20 @@ import PackageDescription let package = Package( name: "AppleAPI", - platforms: [.macOS(.v10_13)], + platforms: [.macOS(.v10_15)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "AppleAPI", targets: ["AppleAPI"]), ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMajor(from: "6.8.3")), - .package(name: "PMKFoundation", url: "https://github.com/PromiseKit/Foundation.git", .upToNextMajor(from: "3.3.1")), - ], + dependencies: [], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "AppleAPI", - dependencies: ["PromiseKit", "PMKFoundation"]), + dependencies: []), .testTarget( name: "AppleAPITests", dependencies: ["AppleAPI"]), diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift index 8617915..cc135b6 100644 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift +++ b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift @@ -1,269 +1,266 @@ import Foundation -import PromiseKit -import PMKFoundation +import Combine public class Client { private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] public init() {} - public enum Error: Swift.Error, LocalizedError, Equatable { - case invalidSession - case invalidUsernameOrPassword(username: String) - case invalidPhoneNumberIndex(min: Int, max: Int, given: String?) - case incorrectSecurityCode - case unexpectedSignInResponse(statusCode: Int, message: String?) - case appleIDAndPrivacyAcknowledgementRequired - case noTrustedPhoneNumbers + // MARK: - Login - public var errorDescription: String? { - switch self { - case .invalidUsernameOrPassword(let username): - return "Invalid username and password combination. Attempted to sign in with username \(username)." - case .appleIDAndPrivacyAcknowledgementRequired: - return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement." - case .invalidPhoneNumberIndex(let min, let max, let given): - return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")." - case .noTrustedPhoneNumbers: - return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915." - default: - return String(describing: self) - } - } - } - - /// Use the olympus session endpoint to see if the existing session is still valid - public func validateSession() -> Promise { - return Current.network.dataTask(with: URLRequest.olympusSession) - .done { data, response in - guard - let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], - jsonObject["provider"] != nil - else { throw Error.invalidSession } - } - } - - public func login(accountName: String, password: String) -> Promise { + public func login(accountName: String, password: String) -> AnyPublisher { var serviceKey: String! - return firstly { () -> Promise<(data: Data, response: URLResponse)> in - Current.network.dataTask(with: URLRequest.itcServiceKey) - } - .then { (data, _) -> Promise<(data: Data, response: URLResponse)> in - struct ServiceKeyResponse: Decodable { - let authServiceKey: String + return Current.network.dataTask(with: URLRequest.itcServiceKey) + .map(\.data) + .decode(type: ServiceKeyResponse.self, decoder: JSONDecoder()) + .flatMap { serviceKeyResponse -> AnyPublisher in + serviceKey = serviceKeyResponse.authServiceKey + return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password)) + .mapError { $0 as Swift.Error } + .eraseToAnyPublisher() } - - let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data) - serviceKey = response.authServiceKey - - return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password)) - } - .then { (data, response) -> Promise in - struct SignInResponse: Decodable { - let authType: String? - let serviceErrors: [ServiceError]? - - struct ServiceError: Decodable, CustomStringConvertible { - let code: String - let message: String - - var description: String { - return "\(code): \(message)" + .flatMap { result -> AnyPublisher in + let (data, response) = result + return Just(data) + .decode(type: SignInResponse.self, decoder: JSONDecoder()) + .flatMap { responseBody -> AnyPublisher in + let httpResponse = response as! HTTPURLResponse + + switch httpResponse.statusCode { + case 200: + return Current.network.dataTask(with: URLRequest.olympusSession) + .map { _ in AuthenticationState.authenticated } + .mapError { $0 as Swift.Error } + .eraseToAnyPublisher() + case 401: + return Fail(error: AuthenticationError.invalidUsernameOrPassword(username: accountName)) + .eraseToAnyPublisher() + case 409: + return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) + case 412 where Client.authTypes.contains(responseBody.authType ?? ""): + return Fail(error: AuthenticationError.appleIDAndPrivacyAcknowledgementRequired) + .eraseToAnyPublisher() + default: + return Fail(error: AuthenticationError.unexpectedSignInResponse(statusCode: httpResponse.statusCode, + message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", "))) + .eraseToAnyPublisher() + } } - } + .eraseToAnyPublisher() } - - let httpResponse = response as! HTTPURLResponse - let responseBody = try JSONDecoder().decode(SignInResponse.self, from: data) - - switch httpResponse.statusCode { - case 200: - return Current.network.dataTask(with: URLRequest.olympusSession).asVoid() - case 401: - throw Error.invalidUsernameOrPassword(username: accountName) - case 409: - return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) - case 412 where Client.authTypes.contains(responseBody.authType ?? ""): - throw Error.appleIDAndPrivacyAcknowledgementRequired - default: - throw Error.unexpectedSignInResponse(statusCode: httpResponse.statusCode, - message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", ")) - } - } + .mapError { $0 as Swift.Error } + .eraseToAnyPublisher() } - func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> Promise { + func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher { let httpResponse = response as! HTTPURLResponse let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String) let scnt = (httpResponse.allHeaderFields["scnt"] as! String) - return firstly { () -> Promise in - return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) - .map { try JSONDecoder().decode(AuthOptionsResponse.self, from: $0.data) } - } - .then { authOptions -> Promise in - switch authOptions.kind { - case .twoStep: - Current.logging.log("Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or open an issue on GitHub explaining why this isn't an option for you here: https://github.com/RobotsAndPencils/xcodes/issues/new") - return Promise.value(()) - case .twoFactor: - return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) - case .unknown: - Current.logging.log("Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:") - String(data: data, encoding: .utf8).map { Current.logging.log($0) } - return Promise.value(()) + return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) + .map(\.data) + .decode(type: AuthOptionsResponse.self, decoder: JSONDecoder()) + .flatMap { authOptions -> AnyPublisher in + switch authOptions.kind { + case .twoStep: + return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication) + .eraseToAnyPublisher() + case .twoFactor: + return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) + .eraseToAnyPublisher() + case .unknown: + let possibleResponseString = String(data: data, encoding: .utf8) + return Fail(error: AuthenticationError.accountUsesUnknownAuthenticationKind(possibleResponseString)) + .eraseToAnyPublisher() + } } - } + .eraseToAnyPublisher() } - func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise { + func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> AnyPublisher { let option: TwoFactorOption // SMS was sent automatically if authOptions.smsAutomaticallySent { - option = .smsSent(authOptions.securityCode.length, authOptions.trustedPhoneNumbers!.first!) + option = .smsSent(authOptions.trustedPhoneNumbers!.first!) // SMS wasn't sent automatically because user needs to choose a phone to send to } else if authOptions.canFallBackToSMS { - option = .smsPendingChoice(authOptions.securityCode.length, authOptions.trustedPhoneNumbers ?? []) + option = .smsPendingChoice // Code is shown on trusted devices } else { - option = .codeSent(authOptions.securityCode.length) + option = .codeSent } - return handleTwoFactorOption(option, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + return Just(AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - func handleTwoFactorOption(_ option: TwoFactorOption, serviceKey: String, sessionID: String, scnt: String) -> Promise { - Current.logging.log("Two-factor authentication is enabled for this account.\n") - switch option { - case let .smsSent(codeLength, phoneNumber): - return firstly { () throws -> Promise<(data: Data, response: URLResponse)> in - let code = self.promptForSMSSecurityCode(length: codeLength, for: phoneNumber) - return Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code)) - .validateSecurityCodeResponse() - } - .then { (data, response) -> Promise in - self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - } - case let .smsPendingChoice(codeLength, trustedPhoneNumbers): - return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: trustedPhoneNumbers, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - case let .codeSent(codeLength): - let code = Current.shell.readLine(""" - Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to. - Enter the \(codeLength) digit code from one of your trusted devices: - """) ?? "" - - if code == "sms" { - // return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: authOp, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - } - - return firstly { - Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: .device(code: code))) - .validateSecurityCodeResponse() - - } - .then { (data, response) -> Promise in - self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - } + // MARK: - Continue 2FA + + public func requestSMSSecurityCode(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) -> AnyPublisher { + Result { + try URLRequest.requestSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, trustedPhoneID: trustedPhoneNumber.id) } + .publisher + .flatMap { request in + Current.network.dataTask(with: request) + .mapError { $0 as Error } + } + .map { _ in AuthenticationState.waitingForSecondFactor(.smsSent(trustedPhoneNumber), authOptions, sessionData) } + .eraseToAnyPublisher() } - 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]) + public func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) -> AnyPublisher { + Result { + try URLRequest.submitSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, code: code) } - .recover { error throws -> Promise in - guard case Error.invalidPhoneNumberIndex = error else { throw error } - Current.logging.log("\(error.localizedDescription)\n") - return self.selectPhoneNumberInteractively(from: trustedPhoneNumbers) - } - } - - func promptForSMSSecurityCode(length: Int, for trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber) -> SecurityCode { - let code = Current.shell.readLine("Enter the \(length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ") ?? "" - return .sms(code: code, phoneNumberId: trustedPhoneNumber.id) - } - - func handleWithPhoneNumberSelection(codeLength: Int, trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, serviceKey: String, sessionID: String, scnt: String) -> Promise { - return firstly { () throws -> Promise in - // I don't think this should ever be nil or empty, because 2FA requires at least one trusted phone number, - // but if it is nil or empty it's better to inform the user so they can try to address it instead of crashing. - guard let trustedPhoneNumbers = trustedPhoneNumbers, trustedPhoneNumbers.isEmpty == false else { - throw Error.noTrustedPhoneNumbers - } - - return selectPhoneNumberInteractively(from: trustedPhoneNumbers) - } - .then { trustedPhoneNumber in - Current.network.dataTask(with: try URLRequest.requestSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, trustedPhoneID: trustedPhoneNumber.id)) - .map { _ in - self.promptForSMSSecurityCode(length: codeLength, for: trustedPhoneNumber) - } - } - .then { code in - Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code)) - .validateSecurityCodeResponse() - } - .then { (data, response) -> Promise in - self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - } - } -} - -enum TwoFactorOption { - case smsSent(Int, AuthOptionsResponse.TrustedPhoneNumber) - case codeSent(Int) - case smsPendingChoice(Int, [AuthOptionsResponse.TrustedPhoneNumber]) -} - -public extension Promise where T == (data: Data, response: URLResponse) { - func validateSecurityCodeResponse() -> Promise { - validate() - .recover { error -> Promise<(data: Data, response: URLResponse)> in - switch error { - case PMKHTTPError.badStatusCode(let code, _, _): - if code == 401 { - throw Client.Error.incorrectSecurityCode - } else { - throw error + .publisher + .flatMap { request in + Current.network.dataTask(with: request) + .mapError { $0 as Error } + .tryMap { (data, response) throws -> (Data, URLResponse) in + guard let urlResponse = response as? HTTPURLResponse else { return (data, response) } + switch urlResponse.statusCode { + case 200..<300: + return (data, urlResponse) + case 401: + throw AuthenticationError.incorrectSecurityCode + case let code: + throw AuthenticationError.badStatusCode(code, data, urlResponse) } - default: - throw error } + .flatMap { (data, response) -> AnyPublisher in + self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) + } + } + .eraseToAnyPublisher() + } + + // MARK: - Session + + /// Use the olympus session endpoint to see if the existing session is still valid + public func validateSession() -> AnyPublisher { + return Current.network.dataTask(with: URLRequest.olympusSession) + .tryMap { (data, response) in + guard + let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + jsonObject["provider"] != nil + else { throw AuthenticationError.invalidSession } } + .eraseToAnyPublisher() + } + + func updateSession(serviceKey: String, sessionID: String, scnt: String) -> AnyPublisher { + return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) + .flatMap { (data, response) in + Current.network.dataTask(with: URLRequest.olympusSession) + .map { _ in AuthenticationState.authenticated } + } + .mapError { $0 as Error } + .eraseToAnyPublisher() } } -struct AuthOptionsResponse: Decodable { - let trustedPhoneNumbers: [TrustedPhoneNumber]? - let trustedDevices: [TrustedDevice]? - let securityCode: SecurityCodeInfo - let noTrustedDevices: Bool? +// MARK: - Types + +public enum AuthenticationState: Equatable { + case unauthenticated + case waitingForSecondFactor(TwoFactorOption, AuthOptionsResponse, AppleSessionData) + case authenticated +} + +public enum AuthenticationError: 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 accountUsesTwoStepAuthentication + case accountUsesUnknownAuthenticationKind(String?) + case badStatusCode(Int, Data, HTTPURLResponse) + + public var errorDescription: String? { + switch self { + case .invalidUsernameOrPassword(let username): + return "Invalid username and password combination. Attempted to sign in with username \(username)." + case .appleIDAndPrivacyAcknowledgementRequired: + return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement." + case .invalidPhoneNumberIndex(let min, let max, let given): + return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")." + case .accountUsesTwoStepAuthentication: + return "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" + case .accountUsesUnknownAuthenticationKind: + return "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:" + default: + return String(describing: self) + } + } +} + +public struct AppleSessionData: Equatable, Identifiable { + public let serviceKey: String + public let sessionID: String + public let scnt: String + + public var id: String { sessionID } + + public init(serviceKey: String, sessionID: String, scnt: String) { + self.serviceKey = serviceKey + self.sessionID = sessionID + self.scnt = scnt + } +} + +struct ServiceKeyResponse: Decodable { + let authServiceKey: String +} + +struct SignInResponse: Decodable { + let authType: String? let serviceErrors: [ServiceError]? - var kind: Kind { + struct ServiceError: Decodable, CustomStringConvertible { + let code: String + let message: String + + var description: String { + return "\(code): \(message)" + } + } +} + +public enum TwoFactorOption: Equatable { + case smsSent(AuthOptionsResponse.TrustedPhoneNumber) + case codeSent + case smsPendingChoice +} + +public struct AuthOptionsResponse: Equatable, Decodable { + public let trustedPhoneNumbers: [TrustedPhoneNumber]? + public let trustedDevices: [TrustedDevice]? + public let securityCode: SecurityCodeInfo + public let noTrustedDevices: Bool? + public let serviceErrors: [ServiceError]? + + public init( + trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, + trustedDevices: [AuthOptionsResponse.TrustedDevice]?, + securityCode: AuthOptionsResponse.SecurityCodeInfo, + noTrustedDevices: Bool? = nil, + serviceErrors: [ServiceError]? = nil + ) { + self.trustedPhoneNumbers = trustedPhoneNumbers + self.trustedDevices = trustedDevices + self.securityCode = securityCode + self.noTrustedDevices = noTrustedDevices + self.serviceErrors = serviceErrors + } + + public var kind: Kind { if trustedDevices != nil { return .twoStep } else if trustedPhoneNumbers != nil { @@ -277,34 +274,59 @@ struct AuthOptionsResponse: Decodable { // 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 { + public var canFallBackToSMS: Bool { noTrustedDevices == true } - var smsAutomaticallySent: Bool { + public var smsAutomaticallySent: Bool { trustedPhoneNumbers?.count == 1 && canFallBackToSMS } - struct TrustedPhoneNumber: Decodable { - let id: Int - let numberWithDialCode: String + public struct TrustedPhoneNumber: Equatable, Decodable, Identifiable { + public let id: Int + public let numberWithDialCode: String + + public init(id: Int, numberWithDialCode: String) { + self.id = id + self.numberWithDialCode = numberWithDialCode + } } - struct TrustedDevice: Decodable { - let id: String - let name: String - let modelName: String + public struct TrustedDevice: Equatable, Decodable { + public let id: String + public let name: String + public let modelName: String + + public init(id: String, name: String, modelName: String) { + self.id = id + self.name = name + self.modelName = modelName + } } - struct SecurityCodeInfo: Decodable { - let length: Int - let tooManyCodesSent: Bool - let tooManyCodesValidated: Bool - let securityCodeLocked: Bool - let securityCodeCooldown: Bool + public struct SecurityCodeInfo: Equatable, Decodable { + public let length: Int + public let tooManyCodesSent: Bool + public let tooManyCodesValidated: Bool + public let securityCodeLocked: Bool + public let securityCodeCooldown: Bool + + public init( + length: Int, + tooManyCodesSent: Bool = false, + tooManyCodesValidated: Bool = false, + securityCodeLocked: Bool = false, + securityCodeCooldown: Bool = false + ) { + self.length = length + self.tooManyCodesSent = tooManyCodesSent + self.tooManyCodesValidated = tooManyCodesValidated + self.securityCodeLocked = securityCodeLocked + self.securityCodeCooldown = securityCodeCooldown + } } - enum Kind { + public enum Kind: Equatable { case twoStep, twoFactor, unknown } } @@ -314,7 +336,7 @@ public struct ServiceError: Decodable, Equatable { let message: String } -enum SecurityCode { +public enum SecurityCode { case device(code: String) case sms(code: String, phoneNumberId: Int) diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift index 3977321..5c76749 100644 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift +++ b/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift @@ -1,6 +1,5 @@ import Foundation -import PromiseKit -import PMKFoundation +import Combine /** Lightweight dependency injection using global mutable state :P @@ -10,29 +9,18 @@ import PMKFoundation - 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 var dataTask: (URLRequest) -> URLSession.DataTaskPublisher = { Current.network.session.dataTaskPublisher(for: $0) } + public func dataTask(with request: URLRequest) -> URLSession.DataTaskPublisher { + dataTask(request) } } diff --git a/Xcodes/AttributedText.swift b/Xcodes/AttributedText.swift new file mode 100644 index 0000000..e15df80 --- /dev/null +++ b/Xcodes/AttributedText.swift @@ -0,0 +1,73 @@ +import SwiftUI + +/// A text view that supports NSAttributedStrings, based on NSTextView. +public struct AttributedText: View { + private let attributedString: NSAttributedString + private let linkTextAttributes: [NSAttributedString.Key: Any]? + @State private var actualSize: CGSize = .zero + + public init(_ attributedString: NSAttributedString, linkTextAttributes: [NSAttributedString.Key: Any]? = nil) { + self.attributedString = attributedString + self.linkTextAttributes = linkTextAttributes + } + + public var body: some View { + InnerAttributedStringText( + attributedString: self.attributedString, + actualSize: $actualSize + ) + // Limit the height to what's needed for the text + .frame(height: actualSize.height) + } +} + +// MARK: InnerAttributedStringText + +fileprivate struct InnerAttributedStringText: NSViewRepresentable { + private let attributedString: NSAttributedString + @Binding var actualSize: CGSize + + internal init(attributedString: NSAttributedString, actualSize: Binding) { + self.attributedString = attributedString + self._actualSize = actualSize + } + + func makeNSView(context: NSViewRepresentableContext) -> NSTextView { + let textView = NSTextView() + textView.backgroundColor = .clear + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainerInset = .zero + textView.isEditable = false + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.isSelectable = true + + return textView + } + + func updateNSView(_ label: NSTextView, context _: NSViewRepresentableContext) { + // This must happen on the next run loop so that we don't update the view hierarchy while already in the middle of an update + DispatchQueue.main.async { + label.textStorage?.setAttributedString(attributedString) + // Calculates the height based on the current frame + label.layoutManager?.ensureLayout(for: label.textContainer!) + actualSize = label.layoutManager!.usedRect(for: label.textContainer!).size + } + } +} + +import SwiftUI +struct AttributedText_Previews: PreviewProvider { + static var linkExample: NSAttributedString { + let string = "The next word is a link. This is some more text to test how this wraps when it's too long." + let s = NSMutableAttributedString(string: string) + s.addAttribute(.link, value: URL(string: "https://robotsandpencils.com")!, range: NSRange(string.range(of: "link")!, in: string)) + return s + } + + static var previews: some SwiftUI.View { + Group { + // Previews don't work unless they're running, because detecting and setting the size happens on the next run loop + AttributedText(linkExample) + } + } +} diff --git a/Xcodes/ContentView.swift b/Xcodes/ContentView.swift index ff4048b..8164f16 100644 --- a/Xcodes/ContentView.swift +++ b/Xcodes/ContentView.swift @@ -67,8 +67,13 @@ struct ContentView: View { } } .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(action: { self.appState.update() }) { + ToolbarItemGroup(placement: .primaryAction) { + Button("Login", action: { self.appState.presentingSignInAlert = true }) + .sheet(isPresented: $appState.presentingSignInAlert) { + SignInCredentialsView(isPresented: $appState.presentingSignInAlert) + .environmentObject(appState) + } + Button(action: { self.appState.load() }) { Image(systemName: "arrow.clockwise") } .keyboardShortcut(KeyEquivalent("r")) @@ -102,11 +107,23 @@ struct ContentView: View { primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: row.id) }), secondaryButton: .cancel(Text("Cancel"))) } - .sheet(isPresented: $appState.presentingSignInAlert) { - SignInCredentialsView(isPresented: $appState.presentingSignInAlert) + .sheet(isPresented: $appState.secondFactorData.isNotNil) { + secondFactorView(appState.secondFactorData!) .environmentObject(appState) } } + + @ViewBuilder + func secondFactorView(_ secondFactorData: AppState.SecondFactorData) -> some View { + switch secondFactorData.option { + case .codeSent: + SignIn2FAView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + case .smsSent(let trustedPhoneNumber): + SignInSMSView(isPresented: $appState.secondFactorData.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + case .smsPendingChoice: + SignInPhoneListView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + } + } } struct ContentView_Previews: PreviewProvider { @@ -127,3 +144,11 @@ struct ContentView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) } } + +extension Optional { + /// Note that this is lossy when setting, so you can really only set it to nil, but this is sufficient for mapping `Binding` to `Binding` for Alerts, Popovers, etc. + var isNotNil: Bool { + get { self != nil } + set { self = newValue ? self : nil } + } +} diff --git a/Xcodes/NSAttributedString+.swift b/Xcodes/NSAttributedString+.swift new file mode 100644 index 0000000..8d42f60 --- /dev/null +++ b/Xcodes/NSAttributedString+.swift @@ -0,0 +1,28 @@ +import Foundation + +public extension NSAttributedString { + func addingAttribute(_ attribute: NSAttributedString.Key, value: Any, range: NSRange) -> NSAttributedString { + let copy = mutableCopy() as! NSMutableAttributedString + copy.addAttribute(attribute, value: value, range: range) + return copy + } + + func addingAttribute(_ attribute: NSAttributedString.Key, value: Any) -> NSAttributedString { + addingAttribute(attribute, value: value, range: NSRange(string.startIndex ..< string.endIndex, in: string)) + } + + /// Detects URLs and adds a NSAttributedString.Key.link attribute with the URL value + func convertingURLsToLinkAttributes() -> NSAttributedString { + guard + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue), + let copy = self.mutableCopy() as? NSMutableAttributedString + else { return self } + + let matches = detector.matches(in: self.string, options: [], range: NSRange(string.startIndex.. NSViewType { + let view = PinCodeTextView(numberOfDigits: numberOfDigits, itemSpacing: 10) + view.codeDidChange = { c in code = c } + return view + } + + func updateNSView(_ nsView: NSViewType, context: Context) { + nsView.code = (0.. Void)? = nil + + private let numberOfDigits: Int + private let stackView: NSStackView = .init(frame: .zero) + private var characterViews: [PinCodeCharacterTextField] = [] + + // MARK: - Initializers + + init( + numberOfDigits: Int, + itemSpacing: CGFloat + ) { + self.numberOfDigits = numberOfDigits + super.init(frame: .zero) + + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = itemSpacing + stackView.orientation = .horizontal + stackView.distribution = .fillEqually + stackView.alignment = .centerY + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.topAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.leadingAnchor.constraint(greaterThanOrEqualTo: self.leadingAnchor), + stackView.trailingAnchor.constraint(greaterThanOrEqualTo: self.trailingAnchor), + stackView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + ]) + + self.code = (0.. Bool { + if commandSelector == #selector(deleteBackward(_:)) { + // If empty, move to previous or first character view + if textView.string.isEmpty { + if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) { + window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter]) + } else { + window?.makeFirstResponder(characterViews[0]) + } + + return true + } + } + + // Perform default behaviour + return false + } + + func controlTextDidChange(_ obj: Notification) { + guard + let field = obj.object as? NSTextField, + isEnabled, + let fieldIndex = characterViews.firstIndex(where: { $0 === field }) + else { return } + + let newFieldText = field.stringValue + + let lastCharacter: Character? + if newFieldText.isEmpty { + lastCharacter = nil + } else { + lastCharacter = newFieldText[newFieldText.index(before: newFieldText.endIndex)] + } + + code[fieldIndex] = lastCharacter + + if lastCharacter != nil { + if fieldIndex >= characterViews.count - 1 { + resignFirstResponder() + } else { + window?.makeFirstResponder(characterViews[fieldIndex + 1]) + } + } else { + if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) { + window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter]) + } else { + window?.makeFirstResponder(characterViews[0]) + } + } + } + + // MARK: NSResponder + + override var acceptsFirstResponder: Bool { + true + } + + override func becomeFirstResponder() -> Bool { + characterViews.first?.becomeFirstResponder() ?? false + } +} + +// MARK: - PinCodeCharacterTextField + +class PinCodeCharacterTextField: NSTextField { + var character: Character? = nil { + didSet { + stringValue = character.map(String.init) ?? "" + } + } + private var lastSize: NSSize? + + init() { + super.init(frame: .zero) + + wantsLayer = true + alignment = .center + maximumNumberOfLines = 1 + font = .boldSystemFont(ofSize: 48) + + setContentHuggingPriority(.required, for: .vertical) + setContentHuggingPriority(.required, for: .horizontal) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func textDidChange(_ notification: Notification) { + super.textDidChange(notification) + self.invalidateIntrinsicContentSize() + } + + // This is kinda cheating + // Assuming that 0 is the widest and tallest character in 0-9 + override var intrinsicContentSize: NSSize { + var size = NSAttributedString( + string: "0", + attributes: [ .font : self.font! ] + ) + .size() + // I guess the cell should probably be doing this sizing in order to take into account everything outside of simply the text's frame, but for some reason I can't find a way to do that which works... + size.width += 8 + size.height += 8 + return size + } +} diff --git a/Xcodes/SignIn/SignIn2FAView.swift b/Xcodes/SignIn/SignIn2FAView.swift index deb46b6..329a108 100644 --- a/Xcodes/SignIn/SignIn2FAView.swift +++ b/Xcodes/SignIn/SignIn2FAView.swift @@ -5,20 +5,28 @@ struct SignIn2FAView: View { @EnvironmentObject var appState: AppState @Binding var isPresented: Bool @State private var code: String = "" + let authOptions: AuthOptionsResponse + let sessionData: AppleSessionData var body: some View { VStack(alignment: .leading) { - Text("Enter the \(6) digit code from one of your trusted devices:") + Text("Enter the \(authOptions.securityCode.length) digit code from one of your trusted devices:") HStack { - TextField("\(6) digit code", text: $code) - } - - HStack { - Button("Cancel", action: { isPresented = false }) - Button("Send SMS", action: {}) Spacer() - Button("Continue", action: {}) + PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length) + Spacer() + } + .padding() + + HStack { + Button("Cancel", action: { isPresented = false }) + .keyboardShortcut(.cancelAction) + Button("Send SMS", action: { appState.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData) }) + Spacer() + Button("Continue", action: { appState.submitSecurityCode(.device(code: code), sessionData: sessionData) }) + .keyboardShortcut(.defaultAction) + .disabled(code.count != authOptions.securityCode.length) } } .padding() @@ -27,7 +35,15 @@ struct SignIn2FAView: View { struct SignIn2FAView_Previews: PreviewProvider { static var previews: some View { - SignIn2FAView(isPresented: .constant(true)) + SignIn2FAView( + isPresented: .constant(true), + authOptions: AuthOptionsResponse( + trustedPhoneNumbers: nil, + trustedDevices: nil, + securityCode: .init(length: 6) + ), + sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "") + ) .environmentObject(AppState()) } } diff --git a/Xcodes/SignIn/SignInCredentialsView.swift b/Xcodes/SignIn/SignInCredentialsView.swift index f1de68b..05a2a2b 100644 --- a/Xcodes/SignIn/SignInCredentialsView.swift +++ b/Xcodes/SignIn/SignInCredentialsView.swift @@ -28,7 +28,7 @@ struct SignInCredentialsView: View { Spacer() Button("Cancel") { isPresented = false } .keyboardShortcut(.cancelAction) - Button("Next") { appState.continueLogin(username: username, password: password) } + Button("Next") { appState.login(username: username, password: password) } .disabled(username.isEmpty) .keyboardShortcut(.defaultAction) } diff --git a/Xcodes/SignIn/SignInPhoneListView.swift b/Xcodes/SignIn/SignInPhoneListView.swift index 5348a06..562882d 100644 --- a/Xcodes/SignIn/SignInPhoneListView.swift +++ b/Xcodes/SignIn/SignInPhoneListView.swift @@ -1,23 +1,36 @@ import SwiftUI +import AppleAPI struct SignInPhoneListView: View { @EnvironmentObject var appState: AppState @Binding var isPresented: Bool - var phoneNumbers: [String] + @State private var selectedPhoneNumberID: AuthOptionsResponse.TrustedPhoneNumber.ID? + let authOptions: AuthOptionsResponse + let sessionData: AppleSessionData var body: some View { VStack(alignment: .leading) { - Text("Select a trusted phone number to receive a code via SMS: ") - - List(phoneNumbers, id: \.self) { - Text($0) + if let phoneNumbers = authOptions.trustedPhoneNumbers, !phoneNumbers.isEmpty { + Text("Select a trusted phone number to receive a \(authOptions.securityCode.length) digit code via SMS:") + + List(phoneNumbers, selection: $selectedPhoneNumberID) { + Text($0.numberWithDialCode) + } + .frame(height: 200) + } else { + AttributedText( + NSAttributedString(string: "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.") + .convertingURLsToLinkAttributes() + ) } - .frame(height: 200) HStack { Button("Cancel", action: { isPresented = false }) + .keyboardShortcut(.cancelAction) Spacer() - Button("Continue", action: {}) + Button("Continue", action: { appState.requestSMS(to: authOptions.trustedPhoneNumbers!.first { $0.id == selectedPhoneNumberID }!, authOptions: authOptions, sessionData: sessionData) }) + .keyboardShortcut(.defaultAction) + .disabled(selectedPhoneNumberID == nil) } } .padding() @@ -26,6 +39,24 @@ struct SignInPhoneListView: View { struct SignInPhoneListView_Previews: PreviewProvider { static var previews: some View { - SignInPhoneListView(isPresented: .constant(true), phoneNumbers: ["123-456-7890"]) + Group { + SignInPhoneListView( + isPresented: .constant(true), + authOptions: AuthOptionsResponse( + trustedPhoneNumbers: [.init(id: 0, numberWithDialCode: "(•••) •••-••90")], + trustedDevices: nil, + securityCode: .init(length: 6)), + sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "") + ) + + SignInPhoneListView( + isPresented: .constant(true), + authOptions: AuthOptionsResponse( + trustedPhoneNumbers: [], + trustedDevices: nil, + securityCode: .init(length: 6)), + sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "") + ) + } } } diff --git a/Xcodes/SignIn/SignInSMSView.swift b/Xcodes/SignIn/SignInSMSView.swift index 07f5a97..11804b5 100644 --- a/Xcodes/SignIn/SignInSMSView.swift +++ b/Xcodes/SignIn/SignInSMSView.swift @@ -1,22 +1,32 @@ import SwiftUI +import AppleAPI struct SignInSMSView: View { @EnvironmentObject var appState: AppState @Binding var isPresented: Bool @State private var code: String = "" + let trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber + let authOptions: AuthOptionsResponse + let sessionData: AppleSessionData var body: some View { VStack(alignment: .leading) { - Text("Enter the \(6) digit code sent to \("phone number"): ") + Text("Enter the \(authOptions.securityCode.length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ") HStack { - TextField("\(6) digit code", text: $code) + Spacer() + PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length) + Spacer() } + .padding() HStack { Button("Cancel", action: { isPresented = false }) + .keyboardShortcut(.cancelAction) Spacer() - Button("Continue", action: {}) + Button("Continue", action: { appState.submitSecurityCode(.sms(code: code, phoneNumberId: trustedPhoneNumber.id), sessionData: sessionData) }) + .keyboardShortcut(.defaultAction) + .disabled(code.count != authOptions.securityCode.length) } } .padding() @@ -25,7 +35,16 @@ struct SignInSMSView: View { struct SignInSMSView_Previews: PreviewProvider { static var previews: some View { - SignInSMSView(isPresented: .constant(true)) - .environmentObject(AppState()) + SignInSMSView( + isPresented: .constant(true), + trustedPhoneNumber: .init(id: 0, numberWithDialCode: "(•••) •••-••90"), + authOptions: AuthOptionsResponse( + trustedPhoneNumbers: nil, + trustedDevices: nil, + securityCode: .init(length: 6) + ), + sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "") + ) + .environmentObject(AppState()) } } diff --git a/Xcodes/XcodesKit/Package.swift b/Xcodes/XcodesKit/Package.swift index 6bdda8e..9e200a4 100644 --- a/Xcodes/XcodesKit/Package.swift +++ b/Xcodes/XcodesKit/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "XcodesKit", - platforms: [.macOS(.v10_13)], + platforms: [.macOS(.v10_15)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift index bdb10af..5db9b5e 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift @@ -246,12 +246,12 @@ public struct Network { return downloadTask(convertible, saveLocation, resumeData) } - public var validateSession: () -> Promise = client.validateSession - - public var login: (String, String) -> Promise = { client.login(accountName: $0, password: $1) } - public func login(accountName: String, password: String) -> Promise { - login(accountName, password) - } +// public var validateSession: () -> Promise = client.validateSession +// +// public var login: (String, String) -> Promise = { client.login(accountName: $0, password: $1) } +// public func login(accountName: String, password: String) -> Promise { +// login(accountName, password) +// } } public struct Logging { diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift b/Xcodes/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift index 2ade8f1..f89d7f8 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift @@ -248,9 +248,9 @@ public final class XcodeInstaller { private func downloadXcode(version: Version, downloader: Downloader) -> Promise<(Xcode, URL)> { return firstly { () -> Promise in - loginIfNeeded().map { version } - } - .then { version -> Promise in +// loginIfNeeded().map { version } +// } +// .then { version -> Promise in if self.xcodeList.shouldUpdate { return self.xcodeList.update().map { _ in version } } @@ -282,60 +282,60 @@ public final class XcodeInstaller { } } - func loginIfNeeded(withUsername existingUsername: String? = nil) -> Promise { - return firstly { () -> Promise in - return Current.network.validateSession() - } - .recover { error -> Promise in - guard - let username = existingUsername ?? self.findUsername() ?? Current.shell.readLine(prompt: "Apple ID: "), - let password = self.findPassword(withUsername: username) ?? Current.shell.readSecureLine(prompt: "Apple ID Password: ") - else { throw Error.missingUsernameOrPassword } - - return firstly { () -> Promise in - self.login(username, password: password) - } - .recover { error -> Promise in - Current.logging.log(error.legibleLocalizedDescription) - - if case Client.Error.invalidUsernameOrPassword = error { - Current.logging.log("Try entering your password again") - return self.loginIfNeeded(withUsername: username) - } - else { - return Promise(error: error) - } - } - } - } - - public func login(_ username: String, password: String) -> Promise { - return firstly { () -> Promise in - Current.network.login(accountName: username, password: password) - } - .recover { error -> Promise in - - if let error = error as? Client.Error { - switch error { - case .invalidUsernameOrPassword(_): - // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. - try? Current.keychain.remove(username) - default: - break - } - } - - return Promise(error: error) - } - .done { _ in - try? Current.keychain.set(password, key: username) - - if self.configuration.defaultUsername != username { - self.configuration.defaultUsername = username - try? self.configuration.save() - } - } - } +// func loginIfNeeded(withUsername existingUsername: String? = nil) -> Promise { +// return firstly { () -> Promise in +// return Current.network.validateSession() +// } +// .recover { error -> Promise in +// guard +// let username = existingUsername ?? self.findUsername() ?? Current.shell.readLine(prompt: "Apple ID: "), +// let password = self.findPassword(withUsername: username) ?? Current.shell.readSecureLine(prompt: "Apple ID Password: ") +// else { throw Error.missingUsernameOrPassword } +// +// return firstly { () -> Promise in +// self.login(username, password: password) +// } +// .recover { error -> Promise in +// Current.logging.log(error.legibleLocalizedDescription) +// +// if case Client.AuthenticationErrro.invalidUsernameOrPassword = error { +// Current.logging.log("Try entering your password again") +// return self.loginIfNeeded(withUsername: username) +// } +// else { +// return Promise(error: error) +// } +// } +// } +// } +// +// public func login(_ username: String, password: String) -> Promise { +// return firstly { () -> Promise in +// Current.network.login(accountName: username, password: password) +// } +// .recover { error -> Promise in +// +// if let error = error as? Client.AuthenticationErrro { +// switch error { +// case .invalidUsernameOrPassword(_): +// // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. +// try? Current.keychain.remove(username) +// default: +// break +// } +// } +// +// return Promise(error: error) +// } +// .done { _ in +// try? Current.keychain.set(password, key: username) +// +// if self.configuration.defaultUsername != username { +// self.configuration.defaultUsername = username +// try? self.configuration.save() +// } +// } +// } let xcodesUsername = "XCODES_USERNAME" let xcodesPassword = "XCODES_PASSWORD" @@ -511,12 +511,12 @@ public final class XcodeInstaller { } public func update() -> Promise<[Xcode]> { - return firstly { () -> Promise in - loginIfNeeded() - } - .then { () -> Promise<[Xcode]> in +// return firstly { () -> Promise in +// loginIfNeeded() +// } +// .then { () -> Promise<[Xcode]> in self.xcodeList.update() - } +// } } public func updateAndPrint(destination: Path) -> Promise {