mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-29 09:35:47 +00:00
Like xcodes, storing the username in defaults so we know which item to look up in the keychain later. This also fixes the Xcode list update logic to not only validate the session but login with saved credentials if it fails.
271 lines
10 KiB
Swift
271 lines
10 KiB
Swift
import AppKit
|
|
import AppleAPI
|
|
import Combine
|
|
import Path
|
|
import PromiseKit
|
|
import LegibleError
|
|
import KeychainAccess
|
|
|
|
class AppState: ObservableObject {
|
|
private let list = XcodeList()
|
|
private let client = AppleAPI.Client()
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
@Published var authenticationState: AuthenticationState = .unauthenticated
|
|
@Published var allVersions: [XcodeVersion] = []
|
|
@Published var error: AlertContent?
|
|
@Published var presentingSignInAlert = false
|
|
@Published var secondFactorData: SecondFactorData?
|
|
|
|
// MARK: - Authentication
|
|
|
|
func validateSession() -> AnyPublisher<Void, Error> {
|
|
return client.validateSession()
|
|
.receive(on: DispatchQueue.main)
|
|
.handleEvents(receiveCompletion: { completion in
|
|
if case .failure = completion {
|
|
self.authenticationState = .unauthenticated
|
|
self.presentingSignInAlert = true
|
|
}
|
|
})
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
func loginIfNeeded() -> AnyPublisher<Void, Error> {
|
|
validateSession()
|
|
.catch { (error) -> AnyPublisher<Void, Error> in
|
|
guard
|
|
let username = Current.defaults.string(forKey: "username"),
|
|
let password = try? Current.keychain.getString(username)
|
|
else {
|
|
return Fail(error: error)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
return self.login(username: username, password: password)
|
|
.map { _ in Void() }
|
|
.eraseToAnyPublisher()
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
func login(username: String, password: String) {
|
|
login(username: username, password: password)
|
|
.sink(
|
|
receiveCompletion: { _ in },
|
|
receiveValue: { _ in }
|
|
)
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
func login(username: String, password: String) -> AnyPublisher<AuthenticationState, Error> {
|
|
try? Current.keychain.set(password, key: username)
|
|
Current.defaults.set(username, forKey: "username")
|
|
|
|
return client.login(accountName: username, password: password)
|
|
.receive(on: DispatchQueue.main)
|
|
.handleEvents(
|
|
receiveOutput: { authenticationState in
|
|
self.authenticationState = authenticationState
|
|
},
|
|
receiveCompletion: { completion in
|
|
self.handleAuthenticationFlowCompletion(completion)
|
|
}
|
|
)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
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)
|
|
)
|
|
}
|
|
|
|
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):
|
|
if case .invalidUsernameOrPassword = error as? AuthenticationError,
|
|
let username = Current.defaults.string(forKey: "username") {
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func logOut() {
|
|
let username = Current.defaults.string(forKey: "username")
|
|
Current.defaults.removeObject(forKey: "username")
|
|
if let username = username {
|
|
try? Current.keychain.remove(username)
|
|
}
|
|
AppleAPI.Current.network.session.configuration.httpCookieStorage?.removeCookies(since: .distantPast)
|
|
authenticationState = .unauthenticated
|
|
}
|
|
|
|
// MARK: - Load Xcode Versions
|
|
|
|
func update() {
|
|
update()
|
|
.sink(
|
|
receiveCompletion: { _ in },
|
|
receiveValue: { _ in }
|
|
)
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
public func update() -> AnyPublisher<[Xcode], Never> {
|
|
loginIfNeeded()
|
|
.flatMap {
|
|
// Wrap the Promise API in a Publisher for now
|
|
Deferred {
|
|
Future { promise in
|
|
self.list.update()
|
|
.done { promise(.success($0)) }
|
|
.catch { promise(.failure($0)) }
|
|
}
|
|
}
|
|
.handleEvents(
|
|
receiveCompletion: { completion in
|
|
if case let .failure(error) = completion {
|
|
self.error = AlertContent(title: "Update Error", message: error.legibleLocalizedDescription)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.catch { _ in
|
|
Just(self.list.availableXcodes)
|
|
}
|
|
.handleEvents(
|
|
receiveOutput: { [unowned self] xcodes in
|
|
self.updateAllVersions(xcodes)
|
|
}
|
|
)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
private func updateAllVersions(_ xcodes: [Xcode]) {
|
|
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
|
|
}
|
|
}
|
|
|
|
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: false,
|
|
path: installedXcode?.path.string
|
|
)
|
|
}
|
|
}
|
|
|
|
func install(id: String) {
|
|
// TODO:
|
|
}
|
|
|
|
func uninstall(id: String) {
|
|
// TODO:
|
|
}
|
|
|
|
func reveal(id: String) {
|
|
// TODO: show error if not
|
|
guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.xcodeDescription == id }) else { return }
|
|
NSWorkspace.shared.activateFileViewerSelecting([installedXcode.path.url])
|
|
}
|
|
|
|
func select(id: String) {
|
|
// TODO:
|
|
}
|
|
|
|
// MARK: - Nested Types
|
|
|
|
struct XcodeVersion: Identifiable {
|
|
let title: String
|
|
let installState: InstallState
|
|
let selected: Bool
|
|
let path: String?
|
|
var id: String { title }
|
|
var installed: Bool { installState == .installed }
|
|
}
|
|
|
|
enum InstallState: Equatable {
|
|
case notInstalled
|
|
case installing(Progress)
|
|
case installed
|
|
}
|
|
|
|
struct AlertContent: Identifiable {
|
|
var title: String
|
|
var message: String
|
|
var id: String { title + message }
|
|
}
|
|
|
|
struct SecondFactorData {
|
|
let option: TwoFactorOption
|
|
let authOptions: AuthOptionsResponse
|
|
let sessionData: AppleSessionData
|
|
}
|
|
}
|