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() @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 { return client.validateSession() .receive(on: DispatchQueue.main) .handleEvents(receiveCompletion: { completion in if case .failure = completion { self.authenticationState = .unauthenticated self.presentingSignInAlert = true } }) .eraseToAnyPublisher() } func signInIfNeeded() -> AnyPublisher { validateSession() .catch { (error) -> AnyPublisher in guard let username = Current.defaults.string(forKey: "username"), let password = try? Current.keychain.getString(username) else { return Fail(error: error) .eraseToAnyPublisher() } return self.signIn(username: username, password: password) .map { _ in Void() } .eraseToAnyPublisher() } .eraseToAnyPublisher() } func signIn(username: String, password: String) { signIn(username: username, password: password) .sink( receiveCompletion: { _ in }, receiveValue: { _ in } ) .store(in: &cancellables) } func signIn(username: String, password: String) -> AnyPublisher { 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) { 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 signOut() { 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> { signInIfNeeded() .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 } }