Merge pull request #11 from RobotsAndPencils/auth

Implement authentication
This commit is contained in:
Brandon Evans 2020-12-22 16:20:36 -07:00 committed by GitHub
commit 99ba4d83cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 943 additions and 387 deletions

View file

@ -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 = "<group>"; };
CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = "<group>"; };
CA538A0F255A4F3300E64DD7 /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = "<group>"; };
CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = "<group>"; };
CA735108257BF96D00EA9CF8 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = "<group>"; };
CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+.swift"; sourceTree = "<group>"; };
CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
CA8FB61C256E115700469DA5 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = "<group>"; };
CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = XcodesTest.entitlements; sourceTree = "<group>"; };
@ -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;

View file

@ -2,6 +2,6 @@
<Workspace
version = "1.0">
<FileRef
location = "self:/Users/brandon/Projects/XcodesApp/Xcodes.xcodeproj">
location = "self:">
</FileRef>
</Workspace>

View file

@ -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<AnyCancellable>()
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<Void> {
return firstly { () -> Promise<Void> in
return Current.network.validateSession()
}
.recover { _ in
self.presentingSignInAlert = true
}
// MARK: - Authentication
func validateSession() -> AnyPublisher<Void, Error> {
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<Void> {
firstly { () -> Promise<Void> in
self.installer.login(username, password: password)
}
.recover { error -> Promise<Void> 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<Error>) {
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<Void> in
validateSession()
}
.then { () -> Promise<[Xcode]> in
self.list.update()
// MARK: -
public func update() -> AnyPublisher<[Xcode], Error> {
// return firstly { () -> Promise<Void> 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() {

View file

@ -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"]),

View file

@ -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<Void> {
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<Void> {
public func login(accountName: String, password: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
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<URLSession.DataTaskPublisher.Output, Swift.Error> 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<Void> 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<AuthenticationState, Swift.Error> in
let (data, response) = result
return Just(data)
.decode(type: SignInResponse.self, decoder: JSONDecoder())
.flatMap { responseBody -> AnyPublisher<AuthenticationState, Swift.Error> 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<Void> {
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
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<AuthOptionsResponse> 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<Void> 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<AuthenticationState, Error> 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<Void> {
func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> AnyPublisher<AuthenticationState, Error> {
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<Void> {
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<Void> 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<Void> in
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
// MARK: - Continue 2FA
public func requestSMSSecurityCode(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) -> AnyPublisher<AuthenticationState, Error> {
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<Void> {
return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
.then { (data, response) -> Promise<Void> in
Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
}
}
func selectPhoneNumberInteractively(from trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]) -> Promise<AuthOptionsResponse.TrustedPhoneNumber> {
return firstly { () throws -> Guarantee<AuthOptionsResponse.TrustedPhoneNumber> 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<AuthenticationState, Error> {
Result {
try URLRequest.submitSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, code: code)
}
.recover { error throws -> Promise<AuthOptionsResponse.TrustedPhoneNumber> 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<Void> {
return firstly { () throws -> Promise<AuthOptionsResponse.TrustedPhoneNumber> 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<Void> 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<T> {
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<AuthenticationState, Error> 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<Void, Error> {
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<AuthenticationState, Error> {
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)

View file

@ -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)
}
}

View file

@ -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<CGSize>) {
self.attributedString = attributedString
self._actualSize = actualSize
}
func makeNSView(context: NSViewRepresentableContext<Self>) -> 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<Self>) {
// 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)
}
}
}

View file

@ -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<Item?>` to `Binding<Bool>` for Alerts, Popovers, etc.
var isNotNil: Bool {
get { self != nil }
set { self = newValue ? self : nil }
}
}

View file

@ -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..<string.endIndex, in: string))
for match in matches where match.url != nil {
copy.addAttribute(.link, value: match.url!, range: match.range)
}
return copy
}
}

View file

@ -0,0 +1,222 @@
import Cocoa
import SwiftUI
struct PinCodeTextField: NSViewRepresentable {
typealias NSViewType = PinCodeTextView
@Binding var code: String
let numberOfDigits: Int
func makeNSView(context: Context) -> 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..<numberOfDigits).map { index in
if index < code.count {
let codeIndex = code.index(code.startIndex, offsetBy: index)
return code[codeIndex]
} else {
return nil
}
}
}
}
struct PinCodeTextField_Previews: PreviewProvider {
struct PreviewContainer: View {
@State private var code = "123"
var body: some View {
PinCodeTextField(code: $code, numberOfDigits: 6)
.padding()
}
}
static var previews: some View {
Group {
PreviewContainer()
}
}
}
// MARK: - PinCodeTextView
class PinCodeTextView: NSControl, NSTextFieldDelegate {
var code: [Character?] = [] {
didSet {
guard code != oldValue else { return }
if let handler = codeDidChange {
handler(String(code.compactMap { $0 }))
}
updateText()
}
}
var codeDidChange: ((String) -> 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..<numberOfDigits).map { _ in nil }
self.characterViews = (0..<numberOfDigits).map { _ in
let view = PinCodeCharacterTextField()
view.translatesAutoresizingMaskIntoConstraints = false
view.delegate = self
return view
}
characterViews.forEach {
stackView.addArrangedSubview($0)
stackView.heightAnchor.constraint(equalTo: $0.heightAnchor).isActive = true
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateText() {
characterViews.enumerated().forEach { (index, item) in
if (0..<code.count).contains(index) {
let _index = code.index(code.startIndex, offsetBy: index)
item.character = code[_index]
} else {
item.character = nil
}
}
}
// MARK: NSTextFieldDelegate
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> 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
}
}

View file

@ -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())
}
}

View file

@ -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)
}

View file

@ -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: "")
)
}
}
}

View file

@ -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())
}
}

View file

@ -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(

View file

@ -246,12 +246,12 @@ public struct Network {
return downloadTask(convertible, saveLocation, resumeData)
}
public var validateSession: () -> Promise<Void> = client.validateSession
public var login: (String, String) -> Promise<Void> = { client.login(accountName: $0, password: $1) }
public func login(accountName: String, password: String) -> Promise<Void> {
login(accountName, password)
}
// public var validateSession: () -> Promise<Void> = client.validateSession
//
// public var login: (String, String) -> Promise<Void> = { client.login(accountName: $0, password: $1) }
// public func login(accountName: String, password: String) -> Promise<Void> {
// login(accountName, password)
// }
}
public struct Logging {

View file

@ -248,9 +248,9 @@ public final class XcodeInstaller {
private func downloadXcode(version: Version, downloader: Downloader) -> Promise<(Xcode, URL)> {
return firstly { () -> Promise<Version> in
loginIfNeeded().map { version }
}
.then { version -> Promise<Version> in
// loginIfNeeded().map { version }
// }
// .then { version -> Promise<Version> 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<Void> {
return firstly { () -> Promise<Void> in
return Current.network.validateSession()
}
.recover { error -> Promise<Void> 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<Void> in
self.login(username, password: password)
}
.recover { error -> Promise<Void> 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<Void> {
return firstly { () -> Promise<Void> in
Current.network.login(accountName: username, password: password)
}
.recover { error -> Promise<Void> 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<Void> {
// return firstly { () -> Promise<Void> in
// return Current.network.validateSession()
// }
// .recover { error -> Promise<Void> 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<Void> in
// self.login(username, password: password)
// }
// .recover { error -> Promise<Void> 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<Void> {
// return firstly { () -> Promise<Void> in
// Current.network.login(accountName: username, password: password)
// }
// .recover { error -> Promise<Void> 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<Void> in
loginIfNeeded()
}
.then { () -> Promise<[Xcode]> in
// return firstly { () -> Promise<Void> in
// loginIfNeeded()
// }
// .then { () -> Promise<[Xcode]> in
self.xcodeList.update()
}
// }
}
public func updateAndPrint(destination: Path) -> Promise<Void> {