From 287b5500fecfbdf23c9e0366d5bae3b3e8b7f2a3 Mon Sep 17 00:00:00 2001 From: Andrew Erickson Date: Fri, 30 Apr 2021 14:43:54 -0600 Subject: [PATCH] present sign in when credentials are required --- Xcodes.xcodeproj/project.pbxproj | 8 +++++ Xcodes/Backend/AppState.swift | 30 ++++++++++------ Xcodes/Frontend/Common/XcodesSheet.swift | 8 +++++ Xcodes/Frontend/MainWindow.swift | 30 ++++++++++++++-- .../Preferences/GeneralPreferencePane.swift | 34 +++++++++---------- .../SignIn/SignInCredentialsView.swift | 5 ++- Xcodes/Frontend/SignIn/SignedInView.swift | 24 +++++++++++++ 7 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 Xcodes/Frontend/Common/XcodesSheet.swift create mode 100644 Xcodes/Frontend/SignIn/SignedInView.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 39e22a7..ca0cfce 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 536CFDD2263C94DE00026CE0 /* SignedInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536CFDD1263C94DE00026CE0 /* SignedInView.swift */; }; + 536CFDD4263C9A8000026CE0 /* XcodesSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536CFDD3263C9A8000026CE0 /* XcodesSheet.swift */; }; 63EAA4EB259944450046AB8F /* ProgressButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63EAA4EA259944450046AB8F /* ProgressButton.swift */; }; CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; }; @@ -156,6 +158,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 536CFDD1263C94DE00026CE0 /* SignedInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignedInView.swift; sourceTree = ""; }; + 536CFDD3263C9A8000026CE0 /* XcodesSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesSheet.swift; sourceTree = ""; }; 63EAA4EA259944450046AB8F /* ProgressButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressButton.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; @@ -307,6 +311,7 @@ CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */, 63EAA4EA259944450046AB8F /* ProgressButton.swift */, CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */, + 536CFDD3263C9A8000026CE0 /* XcodesSheet.swift */, ); path = Common; sourceTree = ""; @@ -382,6 +387,7 @@ CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */, CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */, CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */, + 536CFDD1263C94DE00026CE0 /* SignedInView.swift */, ); path = SignIn; sourceTree = ""; @@ -755,6 +761,7 @@ CAFBDC4E2599B33D003DCC5A /* MainToolbar.swift in Sources */, CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */, CAA8589B25A2B83000ACF8C0 /* Aria2CError.swift in Sources */, + 536CFDD2263C94DE00026CE0 /* SignedInView.swift in Sources */, CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */, CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */, CAE4248C259A68B800B8B246 /* Optional+IsNotNil.swift in Sources */, @@ -814,6 +821,7 @@ CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */, CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */, 63EAA4EB259944450046AB8F /* ProgressButton.swift in Sources */, + 536CFDD4263C9A8000026CE0 /* XcodesSheet.swift in Sources */, CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */, CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */, CA9FF88125955C7000E47BAF /* AvailableXcode.swift in Sources */, diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6e4d622..22c2c97 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -38,7 +38,7 @@ class AppState: ObservableObject { } @Published var updatePublisher: AnyCancellable? var isUpdating: Bool { updatePublisher != nil } - @Published var presentingSignInAlert = false + @Published var presentedSheet: XcodesSheet? = nil @Published var isProcessingAuthRequest = false @Published var secondFactorData: SecondFactorData? @Published var xcodeBeingConfirmedForUninstallation: Xcode? @@ -65,6 +65,14 @@ class AppState: ObservableObject { var dataSource: DataSource { Current.defaults.string(forKey: "dataSource").flatMap(DataSource.init(rawValue:)) ?? .default } + + var savedUsername: String? { + Current.defaults.string(forKey: "username") + } + + var hasSavedUsername: Bool { + savedUsername != nil + } // MARK: - Init @@ -94,7 +102,7 @@ class AppState: ObservableObject { .handleEvents(receiveCompletion: { completion in if case .failure = completion { self.authenticationState = .unauthenticated - self.presentingSignInAlert = true + self.presentedSheet = .signIn } }) .eraseToAnyPublisher() @@ -104,7 +112,7 @@ class AppState: ObservableObject { validateSession() .catch { (error) -> AnyPublisher in guard - let username = Current.defaults.string(forKey: "username"), + let username = savedUsername, let password = try? Current.keychain.getString(username) else { return Fail(error: error) @@ -147,7 +155,7 @@ class AppState: ObservableObject { } func handleTwoFactorOption(_ option: TwoFactorOption, authOptions: AuthOptionsResponse, serviceKey: String, sessionID: String, scnt: String) { - self.presentingSignInAlert = false + self.presentedSheet = .twoFactor self.secondFactorData = SecondFactorData( option: option, authOptions: authOptions, @@ -198,7 +206,7 @@ class AppState: ObservableObject { switch completion { case let .failure(error): if case .invalidUsernameOrPassword = error as? AuthenticationError, - let username = Current.defaults.string(forKey: "username") { + let username = savedUsername { // 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") @@ -209,7 +217,7 @@ class AppState: ObservableObject { case .finished: switch self.authenticationState { case .authenticated, .unauthenticated: - self.presentingSignInAlert = false + self.presentedSheet = nil self.secondFactorData = nil case let .waitingForSecondFactor(option, authOptions, sessionData): self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) @@ -218,11 +226,10 @@ class AppState: ObservableObject { } func signOut() { - let username = Current.defaults.string(forKey: "username") - Current.defaults.removeObject(forKey: "username") - if let username = username { + if let username = savedUsername { try? Current.keychain.remove(username) } + Current.defaults.removeObject(forKey: "username") AppleAPI.Current.network.session.configuration.httpCookieStorage?.removeCookies(since: .distantPast) authenticationState = .unauthenticated } @@ -327,7 +334,10 @@ class AppState: ObservableObject { receiveCompletion: { [unowned self] completion in self.installationPublishers[id] = nil if case let .failure(error) = completion { - self.error = error + // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead + if error as? AuthenticationError != .invalidSession { + self.error = error + } if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { self.allXcodes[index].installState = .notInstalled } diff --git a/Xcodes/Frontend/Common/XcodesSheet.swift b/Xcodes/Frontend/Common/XcodesSheet.swift new file mode 100644 index 0000000..0a39add --- /dev/null +++ b/Xcodes/Frontend/Common/XcodesSheet.swift @@ -0,0 +1,8 @@ +import Foundation + +enum XcodesSheet: Identifiable { + case signIn + case twoFactor + + var id: Int { hashValue } +} diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index d003227..1089ecf 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -38,9 +38,15 @@ struct MainWindow: View { .navigationSubtitle(subtitleText) .frame(minWidth: 600, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity) .emittingError($appState.error, recoveryHandler: { _ in }) - .sheet(isPresented: $appState.secondFactorData.isNotNil) { - secondFactorView(appState.secondFactorData!) - .environmentObject(appState) + .sheet(item: $appState.presentedSheet) { sheet in + switch sheet { + case .signIn: + signInView() + .environmentObject(appState) + case .twoFactor: + secondFactorView(appState.secondFactorData!) + .environmentObject(appState) + } } // This overlay is only here to work around the one-alert-per-view limitation .overlay( @@ -111,6 +117,24 @@ struct MainWindow: View { SignInPhoneListView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) } } + + @ViewBuilder + private func signInView() -> some View { + if appState.hasSavedUsername { + VStack { + SignedInView() + .padding(32) + HStack { + Spacer() + Button("Close") { appState.presentedSheet = nil } + .keyboardShortcut(.cancelAction) + } + } + .padding() + } else { + SignInCredentialsView() + } + } } struct MainWindow_Previews: PreviewProvider { diff --git a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift index a251bfd..8cd710b 100644 --- a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift @@ -7,27 +7,25 @@ struct GeneralPreferencePane: View { var body: some View { VStack(alignment: .leading) { GroupBox(label: Text("Apple ID")) { - // If we have saved a username then we will show it here, - // even if we don't have a valid session right now, - // because we should be able to get a valid session if needed with the password in the keychain - // and a 2FA code from the user. - // Note that AppState.authenticationState is not necessarily .authenticated in this case, though. - if let username = Current.defaults.string(forKey: "username") { - HStack(alignment:.top, spacing: 10) { - Text(username) - Button("Sign Out", action: appState.signOut) - } - .frame(maxWidth: .infinity, alignment: .leading) - } else { - Button("Sign In", action: { self.appState.presentingSignInAlert = true }) - .frame(maxWidth: .infinity, alignment: .leading) - } + // If we have saved a username then we will show it here, + // even if we don't have a valid session right now, + // because we should be able to get a valid session if needed with the password in the keychain + // and a 2FA code from the user. + // Note that AppState.authenticationState is not necessarily .authenticated in this case, though. + if appState.hasSavedUsername { + SignedInView() + } else { + Button("Sign In", action: { self.appState.presentedSheet = .signIn }) + } } .groupBoxStyle(PreferencesGroupBoxStyle()) - .sheet(isPresented: $appState.presentingSignInAlert) { - SignInCredentialsView(isPresented: $appState.presentingSignInAlert) - .environmentObject(appState) + + Divider() + + GroupBox(label: Text("Notifications")) { + NotificationsView().environmentObject(appState) } + .groupBoxStyle(PreferencesGroupBoxStyle()) } .frame(width: 400) } diff --git a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift index accc54a..06c28ae 100644 --- a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift +++ b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift @@ -2,7 +2,6 @@ import SwiftUI struct SignInCredentialsView: View { @EnvironmentObject var appState: AppState - @Binding var isPresented: Bool @State private var username: String = "" @State private var password: String = "" @@ -26,7 +25,7 @@ struct SignInCredentialsView: View { HStack { Spacer() - Button("Cancel") { isPresented = false } + Button("Cancel") { appState.presentedSheet = nil } .keyboardShortcut(.cancelAction) ProgressButton(isInProgress: appState.isProcessingAuthRequest, action: { appState.signIn(username: username, password: password) }) { @@ -44,7 +43,7 @@ struct SignInCredentialsView: View { struct SignInCredentialsView_Previews: PreviewProvider { static var previews: some View { - SignInCredentialsView(isPresented: .constant(true)) + SignInCredentialsView() .environmentObject(AppState()) } } diff --git a/Xcodes/Frontend/SignIn/SignedInView.swift b/Xcodes/Frontend/SignIn/SignedInView.swift new file mode 100644 index 0000000..1cf5e5d --- /dev/null +++ b/Xcodes/Frontend/SignIn/SignedInView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct SignedInView: View { + @EnvironmentObject var appState: AppState + + private var username: String { + appState.savedUsername ?? "" + } + + var body: some View { + HStack(alignment:.top, spacing: 10) { + Text(username) + Button("Sign Out", action: appState.signOut) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct SignedInView_Previews: PreviewProvider { + static var previews: some View { + SignedInView() + .previewLayout(.sizeThatFits) + } +}