mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
Merge pull request #11 from RobotsAndPencils/auth
Implement authentication
This commit is contained in:
commit
99ba4d83cb
17 changed files with 943 additions and 387 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:/Users/brandon/Projects/XcodesApp/Xcodes.xcodeproj">
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
73
Xcodes/AttributedText.swift
Normal file
73
Xcodes/AttributedText.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
Xcodes/NSAttributedString+.swift
Normal file
28
Xcodes/NSAttributedString+.swift
Normal 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
|
||||
}
|
||||
}
|
||||
222
Xcodes/SignIn/PinCodeTextView.swift
Normal file
222
Xcodes/SignIn/PinCodeTextView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue