gh-XcodesOrg-XcodesApp/Xcodes/Backend/AppState.swift
Brandon Evans 75457dd9eb
Show stored username in General preference pane
This reverts the change from 90c067997b so that the username is shown in situations where we don't have a valid session but could almost certainly get one. Instead, to achieve what that commit was trying to do, we'll instead remove the username from UserDefaults if auth fails with an invalid username or password error. I think this will more closely match what users expect in this UI.

I've added a comment in the UI explaining why it is the way it is. It might also be worth considering renaming AuthenticationState or its cases to better reflect that it's probably more about the (short-lived) session state than whether the user has signed in before and has stored their credentials.
2021-01-25 19:56:28 -07:00

513 lines
23 KiB
Swift

import AppKit
import AppleAPI
import Combine
import Path
import LegibleError
import KeychainAccess
import Path
import Version
class AppState: ObservableObject {
private let client = AppleAPI.Client()
// MARK: - Published Properties
@Published var authenticationState: AuthenticationState = .unauthenticated
@Published var availableXcodes: [AvailableXcode] = [] {
willSet {
updateAllXcodes(
availableXcodes: newValue,
installedXcodes: Current.files.installedXcodes(Path.root/"Applications"),
selectedXcodePath: selectedXcodePath
)
}
}
@Published var allXcodes: [Xcode] = []
@Published var selectedXcodePath: String? {
willSet {
updateAllXcodes(
availableXcodes: availableXcodes,
installedXcodes: Current.files.installedXcodes(Path.root/"Applications"),
selectedXcodePath: newValue
)
}
}
@Published var updatePublisher: AnyCancellable?
var isUpdating: Bool { updatePublisher != nil }
@Published var presentingSignInAlert = false
@Published var isProcessingAuthRequest = false
@Published var secondFactorData: SecondFactorData?
@Published var xcodeBeingConfirmedForUninstallation: Xcode?
@Published var xcodeBeingConfirmedForInstallCancellation: Xcode?
@Published var helperInstallState: HelperInstallState = .notInstalled
/// Whether the user is being prepared for the helper installation alert with an explanation.
/// This closure will be performed after the user chooses whether or not to proceed.
@Published var isPreparingUserForActionRequiringHelper: ((Bool) -> Void)?
// MARK: - Errors
@Published var error: Error?
@Published var authError: Error?
// MARK: - Publisher Cancellables
var cancellables = Set<AnyCancellable>()
private var installationPublishers: [Version: AnyCancellable] = [:]
private var selectPublisher: AnyCancellable?
private var uninstallPublisher: AnyCancellable?
// MARK: - Init
init() {
guard !isTesting else { return }
try? loadCachedAvailableXcodes()
checkIfHelperIsInstalled()
}
// 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 signInIfNeeded() -> 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.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<AuthenticationState, Error> {
try? Current.keychain.set(password, key: username)
Current.defaults.set(username, forKey: "username")
isProcessingAuthRequest = true
return client.login(accountName: username, password: password)
.receive(on: DispatchQueue.main)
.handleEvents(
receiveOutput: { authenticationState in
self.authenticationState = authenticationState
},
receiveCompletion: { completion in
self.handleAuthenticationFlowCompletion(completion)
self.isProcessingAuthRequest = false
}
)
.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) {
isProcessingAuthRequest = true
client.requestSMSSecurityCode(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
self.handleAuthenticationFlowCompletion(completion)
self.isProcessingAuthRequest = false
},
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) {
isProcessingAuthRequest = true
client.submitSecurityCode(code, sessionData: sessionData)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
self.handleAuthenticationFlowCompletion(completion)
self.isProcessingAuthRequest = false
},
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)
Current.defaults.removeObject(forKey: "username")
}
// This error message is not user friendly... need to extract some meaningful data in the different cases
self.authError = error
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: - Helper
/// Install the privileged helper if it isn't already installed.
///
/// The way this is done is a little roundabout, because it requires user interaction in an alert before installation should be attempted.
/// The first time this method is invoked should be with `shouldPrepareUserForHelperInstallation` set to true.
/// If the helper is already installed, then nothing will happen.
/// If the helper is not already installed, the user will be prepared for installation and this method will return early.
/// If they consent to installing the helper then this method will be invoked again with `shouldPrepareUserForHelperInstallation` set to false.
/// This will install the helper.
///
/// - Parameter shouldPrepareUserForHelperInstallation: Whether the user should be presented with an alert preparing them for helper installation.
func installHelperIfNecessary(shouldPrepareUserForHelperInstallation: Bool = true) {
guard helperInstallState == .installed || shouldPrepareUserForHelperInstallation == false else {
isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in
guard userConsented else { return }
self.installHelperIfNecessary(shouldPrepareUserForHelperInstallation: false)
}
return
}
installHelperIfNecessary()
.sink(
receiveCompletion: { [unowned self] completion in
if case let .failure(error) = completion {
self.error = error
}
},
receiveValue: {}
)
.store(in: &cancellables)
}
func installHelperIfNecessary() -> AnyPublisher<Void, Error> {
Result {
if helperInstallState == .notInstalled {
try Current.helper.install()
checkIfHelperIsInstalled()
}
}
.publisher
.subscribe(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
private func checkIfHelperIsInstalled() {
helperInstallState = .unknown
Current.helper.checkIfLatestHelperIsInstalled()
.receive(on: DispatchQueue.main)
.sink(
receiveValue: { installed in
self.helperInstallState = installed ? .installed : .notInstalled
}
)
.store(in: &cancellables)
}
// MARK: - Install
func install(id: Xcode.ID) {
guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return }
installationPublishers[id] = signInIfNeeded()
.flatMap { [unowned self] in
// signInIfNeeded might finish before the user actually authenticates if UI is involved.
// This publisher will wait for the @Published authentication state to change to authenticated or unauthenticated before finishing,
// indicating that the user finished what they were doing in the UI.
self.$authenticationState
.filter { state in
switch state {
case .authenticated, .unauthenticated: return true
case .waitingForSecondFactor: return false
}
}
.prefix(1)
.tryMap { state in
if state == .unauthenticated {
throw AuthenticationError.invalidSession
}
return Void()
}
}
.flatMap {
// This request would've already been made if the Apple data source were being used.
// That's not the case for the Xcode Releases data source.
// We need the cookies from its response in order to download Xcodes though,
// so perform it here first just to be sure.
Current.network.dataTask(with: URLRequest.downloads)
.receive(on: DispatchQueue.main)
.map { _ in Void() }
.mapError { $0 as Error }
}
.flatMap { [unowned self] in
self.install(.version(availableXcode), downloader: .aria2)
}
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [unowned self] completion in
self.installationPublishers[id] = nil
if case let .failure(error) = completion {
self.error = error
if let index = self.allXcodes.firstIndex(where: { $0.id == id }) {
self.allXcodes[index].installState = .notInstalled
}
}
},
receiveValue: { _ in }
)
}
func cancelInstall(id: Xcode.ID) {
guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return }
// Cancel the publisher
installationPublishers[id] = nil
// If the download is cancelled by the user, clean up the download files that aria2 creates.
// This isn't done as part of the publisher with handleEvents(receiveCancel:) because it shouldn't happen when e.g. the app quits.
let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))"
let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2")
try? Current.files.removeItem(at: expectedArchivePath.url)
try? Current.files.removeItem(at: aria2DownloadMetadataPath.url)
if let index = allXcodes.firstIndex(where: { $0.id == id }) {
allXcodes[index].installState = .notInstalled
}
}
// MARK: - Uninstall
func uninstall(id: Xcode.ID) {
guard
let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }),
uninstallPublisher == nil
else { return }
uninstallPublisher = uninstallXcode(path: installedXcode.path)
.flatMap { [unowned self] _ in
self.updateSelectedXcodePath()
}
.sink(
receiveCompletion: { [unowned self] completion in
if case let .failure(error) = completion {
self.error = error
}
self.uninstallPublisher = nil
},
receiveValue: { _ in }
)
}
func reveal(id: Xcode.ID) {
// TODO: show error if not
guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }) else { return }
NSWorkspace.shared.activateFileViewerSelecting([installedXcode.path.url])
}
/// Make an Xcode active, a.k.a select it, in the `xcode-select` sense.
///
/// The underlying work is done by the privileged helper, so we need to make sure that it's installed first.
/// The way this is done is a little roundabout, because it requires user interaction in an alert before the `selectPublisher` is subscribed to.
/// The first time this method is invoked should be with `shouldPrepareUserForHelperInstallation` set to true.
/// If the helper is already installed, the Xcode will be made active immediately.
/// If the helper is not already installed, the user will be prepared for installation and this method will return early.
/// If they consent to installing the helper then this method will be invoked again with `shouldPrepareUserForHelperInstallation` set to false.
/// This will install the helper and make the Xcode active.
///
/// - Parameter id: The identifier of the Xcode to make active.
/// - Parameter shouldPrepareUserForHelperInstallation: Whether the user should be presented with an alert preparing them for helper installation before making the Xcode version active.
func select(id: Xcode.ID, shouldPrepareUserForHelperInstallation: Bool = true) {
guard helperInstallState == .installed || shouldPrepareUserForHelperInstallation == false else {
isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in
guard userConsented else { return }
self.select(id: id, shouldPrepareUserForHelperInstallation: false)
}
return
}
guard
let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }),
selectPublisher == nil
else { return }
selectPublisher = installHelperIfNecessary()
.flatMap {
Current.helper.switchXcodePath(installedXcode.path.string)
}
.flatMap { [unowned self] _ in
self.updateSelectedXcodePath()
}
.sink(
receiveCompletion: { [unowned self] completion in
if case let .failure(error) = completion {
self.error = error
}
self.selectPublisher = nil
},
receiveValue: { _ in }
)
}
func open(id: Xcode.ID) {
guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }) else { return }
NSWorkspace.shared.openApplication(at: installedXcode.path.url, configuration: .init())
}
func copyPath(id: Xcode.ID) {
guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }) else { return }
NSPasteboard.general.declareTypes([.URL, .string], owner: nil)
NSPasteboard.general.writeObjects([installedXcode.path.url as NSURL])
NSPasteboard.general.setString(installedXcode.path.string, forType: .string)
}
func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) {
var adjustedAvailableXcodes = availableXcodes
// First, adjust all of the available Xcodes so that available and installed versions line up and the second part of this function works properly.
for installedXcode in installedXcodes {
// We can trust that build metadata identifiers are unique for each version of Xcode, so if we have it then it's all we need.
// If build metadata matches exactly, replace the available version with the installed version.
// This should handle both Xcode Releases versions which can have different prerelease identifiers and Apple versions which rarely have build metadata identifiers.
if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) {
adjustedAvailableXcodes[index].version = 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
// Not all prerelease Apple versions available online include build metadata
else if let index = adjustedAvailableXcodes.firstIndex(where: { availableXcode in
availableXcode.version.isEquivalent(to: installedXcode.version) &&
availableXcode.version.buildMetadataIdentifiers.isEmpty
}) {
adjustedAvailableXcodes[index].version = installedXcode.version
}
}
// Map all of the available versions into Xcode values that join available and installed Xcode data for display.
var newAllXcodes = adjustedAvailableXcodes
.map { availableXcode -> Xcode in
let installedXcode = installedXcodes.first(where: { installedXcode in
availableXcode.version.isEquivalent(to: installedXcode.version)
})
// If the existing install state is "installing", keep it
let existingXcodeInstallState = allXcodes.first { $0.version == availableXcode.version && $0.installState.installing }?.installState
// Otherwise, determine it from whether there's an installed Xcode
let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled
return Xcode(
version: availableXcode.version,
installState: existingXcodeInstallState ?? defaultXcodeInstallState,
selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true,
icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)),
requiredMacOSVersion: availableXcode.requiredMacOSVersion,
releaseNotesURL: availableXcode.releaseNotesURL,
sdks: availableXcode.sdks,
compilers: availableXcode.compilers
)
}
// If an installed version isn't listed in the available versions, add the installed version
// Xcode Releases should have all versions
// Apple didn't used to keep all prerelease versions around but has started to recently
for installedXcode in installedXcodes {
if !newAllXcodes.contains(where: { xcode in xcode.version.isEquivalent(to: installedXcode.version) }) {
newAllXcodes.append(
Xcode(
version: installedXcode.version,
installState: .installed(installedXcode.path),
selected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true,
icon: NSWorkspace.shared.icon(forFile: installedXcode.path.string)
)
)
}
}
self.allXcodes = newAllXcodes.sorted { $0.version > $1.version }
}
// MARK: - Private
private func uninstallXcode(path: Path) -> AnyPublisher<Void, Error> {
return Deferred {
Future { promise in
do {
try Current.files.trashItem(at: path.url)
promise(.success(()))
} catch {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
// MARK: - Nested Types
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
}
}