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/AppleAPI/Sources/AppleAPI/Client.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift index f04f069..462286d 100644 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift +++ b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift @@ -36,6 +36,10 @@ public class Client { case 401: return Fail(error: AuthenticationError.invalidUsernameOrPassword(username: accountName)) .eraseToAnyPublisher() + case 403: + let errorMessage = responseBody.serviceErrors?.first?.description.replacingOccurrences(of: "-20209: ", with: "") ?? "" + return Fail(error: AuthenticationError.accountLocked(errorMessage)) + .eraseToAnyPublisher() case 409: return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) case 412 where Client.authTypes.contains(responseBody.authType ?? ""): @@ -180,6 +184,7 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable { case appleIDAndPrivacyAcknowledgementRequired case accountUsesTwoStepAuthentication case accountUsesUnknownAuthenticationKind(String?) + case accountLocked(String) case badStatusCode(statusCode: Int, data: Data, response: HTTPURLResponse) public var errorDescription: String? { @@ -203,6 +208,8 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable { 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 explain why this isn't an option for you by making a new feature request in the Help menu." 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. If you continue to have problems, please submit a bug report in the Help menu." + case let .accountLocked(message): + return message case let .badStatusCode(statusCode, _, _): return "Received an unexpected status code: \(statusCode). If you continue to have problems, please submit a bug report in the Help menu." } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6e4d622..6833c2e 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 = self.savedUsername, let password = try? Current.keychain.getString(username) else { return Fail(error: error) @@ -119,6 +127,7 @@ class AppState: ObservableObject { } func signIn(username: String, password: String) { + authError = nil signIn(username: username, password: password) .sink( receiveCompletion: { _ in }, @@ -147,12 +156,12 @@ class AppState: ObservableObject { } 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) ) + self.presentedSheet = .twoFactor } func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { @@ -198,7 +207,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 +218,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 +227,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 +335,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..954b221 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,25 @@ struct MainWindow: View { SignInPhoneListView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) } } + + @ViewBuilder + private func signInView() -> some View { + if appState.authenticationState == .authenticated { + VStack { + SignedInView() + .padding(32) + HStack { + Spacer() + Button("Close") { appState.presentedSheet = nil } + .keyboardShortcut(.cancelAction) + } + } + .padding() + } else { + SignInCredentialsView() + .frame(width: 400) + } + } } struct MainWindow_Previews: PreviewProvider { diff --git a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift index a251bfd..2a9843a 100644 --- a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift @@ -7,27 +7,13 @@ 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 appState.authenticationState == .authenticated { + SignedInView() + } else { + Button("Sign In", action: { self.appState.presentedSheet = .signIn }) + } } .groupBoxStyle(PreferencesGroupBoxStyle()) - .sheet(isPresented: $appState.presentingSignInAlert) { - SignInCredentialsView(isPresented: $appState.presentingSignInAlert) - .environmentObject(appState) - } } .frame(width: 400) } diff --git a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift index accc54a..593f735 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 = "" @@ -15,36 +14,49 @@ struct SignInCredentialsView: View { Text("Apple ID:") .frame(minWidth: 100, alignment: .trailing) TextField("example@icloud.com", text: $username) - .frame(width: 250) } HStack { Text("Password:") .frame(minWidth: 100, alignment: .trailing) SecureField("Required", text: $password) - .frame(width: 250) + } + if appState.authError != nil { + HStack { + Text("") + .frame(minWidth: 100) + Text(appState.authError?.legibleLocalizedDescription ?? "") + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.red) + } } HStack { Spacer() - Button("Cancel") { isPresented = false } - .keyboardShortcut(.cancelAction) - ProgressButton(isInProgress: appState.isProcessingAuthRequest, - action: { appState.signIn(username: username, password: password) }) { - Text("Next") + Button("Cancel") { + appState.authError = nil + appState.presentedSheet = nil } + .keyboardShortcut(.cancelAction) + ProgressButton( + isInProgress: appState.isProcessingAuthRequest, + action: { appState.signIn(username: username, password: password) }, + label: { + Text("Next") + } + ) .disabled(username.isEmpty || password.isEmpty) .keyboardShortcut(.defaultAction) } .frame(height: 25) } .padding() - .emittingError($appState.authError, recoveryHandler: { _ in }) } } struct SignInCredentialsView_Previews: PreviewProvider { static var previews: some View { - SignInCredentialsView(isPresented: .constant(true)) + SignInCredentialsView() .environmentObject(AppState()) + .previewLayout(.sizeThatFits) } } 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) + } +} diff --git a/Xcodes/Frontend/XcodeList/MainToolbar.swift b/Xcodes/Frontend/XcodeList/MainToolbar.swift index 392ae32..bedf3be 100644 --- a/Xcodes/Frontend/XcodeList/MainToolbar.swift +++ b/Xcodes/Frontend/XcodeList/MainToolbar.swift @@ -13,6 +13,11 @@ struct MainToolbarModifier: ViewModifier { private var toolbar: some ToolbarContent { ToolbarItemGroup(placement: .status) { + Button(action: { appState.presentedSheet = .signIn }, label: { + Label("Login", systemImage: "person.circle") + }) + .help("Login") + ProgressButton( isInProgress: appState.isUpdating, action: appState.update