diff options
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/AppState.swift | 100 | ||||
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 1 | ||||
| -rw-r--r-- | Hutch/Extensions/KeychainHelper.swift | 40 | ||||
| -rw-r--r-- | Hutch/Models/AccountEntry.swift | 8 | ||||
| -rw-r--r-- | Hutch/Views/More/AccountSwitcherView.swift | 92 | ||||
| -rw-r--r-- | Hutch/Views/More/AddAccountView.swift | 59 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreView.swift | 16 |
7 files changed, 309 insertions, 7 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index 9788c85..8613dc8 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -39,6 +39,14 @@ final class AppState { authPhase == .authenticated && currentUser != nil } + // MARK: - Multi-account + + /// All stored accounts. Loaded from Keychain; kept in sync on add/remove/switch. + private(set) var accounts: [AccountEntry] = [] + + /// The ID of the account currently in use. Persisted in UserDefaults. + private(set) var activeAccountID: String = "" + var selectedTab: Tab = .home // MARK: - Current user (populated after successful validation) @@ -68,18 +76,40 @@ final class AppState { /// Called once at app launch. If a token exists in Keychain, validates it /// silently. On failure, clears the token and falls through to unauthenticated. func validateOnLaunch() async { - guard client.hasToken else { + var storedAccounts = KeychainHelper.loadAccounts() + + if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() { + client.setToken(legacyToken) + if let user = try? await fetchMe() { + let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken) + storedAccounts = [entry] + try? KeychainHelper.saveAccounts(storedAccounts) + try? KeychainHelper.deleteToken() + } else { + try? KeychainHelper.deleteToken() + client.setToken(nil) + authPhase = .unauthenticated + return + } + } + + guard !storedAccounts.isEmpty else { authPhase = .unauthenticated return } + let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" + let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0] + + client.setToken(target.token) do { let user = try await fetchMe() + accounts = storedAccounts + activeAccountID = target.id currentUser = user authPhase = .authenticated await refreshNeedsAttentionSnapshot() } catch { - try? KeychainHelper.deleteToken() client.setToken(nil) currentUser = nil authPhase = .unauthenticated @@ -92,22 +122,71 @@ final class AppState { /// Validate a new token by querying meta.sr.ht, then persist it. /// Throws on network/GraphQL errors so the caller can display the message. func connect(with token: String) async throws { - // Temporarily set the token so the client can use it for the request. client.setToken(token) - do { let user = try await fetchMe() - try KeychainHelper.saveToken(token) + let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token) + accounts.append(entry) + activeAccountID = entry.id + UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) + try KeychainHelper.saveAccounts(accounts) currentUser = user authPhase = .authenticated await refreshNeedsAttentionSnapshot() } catch { - // Roll back — don't leave an invalid token in the client. client.setToken(nil) throw error } } + /// Validate a new token, add it as an account, and switch to it immediately. + func addAccount(token: String) async throws { + let tempClient = SRHTClient(token: token) + let user = try await fetchMe(using: tempClient) + let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token) + accounts.append(entry) + try KeychainHelper.saveAccounts(accounts) + try await switchAccount(to: entry.id) + } + + /// Switch the active account and fully refresh the app. + func switchAccount(to id: String) async throws { + guard let entry = accounts.first(where: { $0.id == id }) else { return } + + client.responseCache.clear() + currentUser = nil + pendingDeepLink = nil + pendingTabNavigation = nil + deepLinkError = nil + selectedTab = .home + + authPhase = .unauthenticated + + client.setToken(entry.token) + activeAccountID = entry.id + UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) + + let user = try await fetchMe() + currentUser = user + authPhase = .authenticated + await refreshNeedsAttentionSnapshot() + } + + /// Remove a stored account. Switches to another account if the removed account + /// was active; signs out fully if it was the last account. + func removeAccount(id: String) async { + accounts.removeAll { $0.id == id } + try? KeychainHelper.saveAccounts(accounts) + + guard id == activeAccountID else { return } + + if let next = accounts.first { + try? await switchAccount(to: next.id) + } else { + await signOut() + } + } + func signOut() async { clearSessionState() URLCache.shared.removeAllCachedResponses() @@ -215,7 +294,11 @@ final class AppState { } private func fetchMe() async throws -> User { - let result = try await client.execute( + try await fetchMe(using: client) + } + + private func fetchMe(using srhtClient: SRHTClient) async throws -> User { + let result = try await srhtClient.execute( service: .meta, query: Self.meQuery, responseType: MeResponse.self @@ -268,6 +351,9 @@ final class AppState { try? KeychainHelper.deleteAll() client.setToken(nil) client.responseCache.clear() + accounts = [] + activeAccountID = "" + UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID) currentUser = nil pendingDeepLink = nil pendingTabNavigation = nil diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift index 5c74de4..708b4ce 100644 --- a/Hutch/App/AppStorageKeys.swift +++ b/Hutch/App/AppStorageKeys.swift @@ -1,4 +1,5 @@ // Shared UserDefaults key constants enum AppStorageKeys { static let swipeActionsEnabled = "swipeActionsEnabled" + static let activeAccountID = "activeAccountID" } diff --git a/Hutch/Extensions/KeychainHelper.swift b/Hutch/Extensions/KeychainHelper.swift index 01f8e5a..d53c80e 100644 --- a/Hutch/Extensions/KeychainHelper.swift +++ b/Hutch/Extensions/KeychainHelper.swift @@ -5,6 +5,7 @@ enum KeychainHelper: Sendable { private static let service = "net.cleberg.Hutch" private static let tokenAccount = "srht-access-token" + private static let accountsAccount = "srht-accounts" // MARK: - Save @@ -57,6 +58,45 @@ enum KeychainHelper: Sendable { return token } + // MARK: - Multi-account list + + static func saveAccounts(_ accounts: [AccountEntry]) throws { + let data = try JSONEncoder().encode(accounts) + + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountsAccount + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountsAccount, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.saveFailed(status) + } + } + + static func loadAccounts() -> [AccountEntry] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountsAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return [] } + return (try? JSONDecoder().decode([AccountEntry].self, from: data)) ?? [] + } + // MARK: - Delete static func deleteToken() throws { diff --git a/Hutch/Models/AccountEntry.swift b/Hutch/Models/AccountEntry.swift new file mode 100644 index 0000000..551cd19 --- /dev/null +++ b/Hutch/Models/AccountEntry.swift @@ -0,0 +1,8 @@ +import Foundation + +/// A stored sr.ht account (token + resolved username). +struct AccountEntry: Codable, Identifiable, Equatable, Sendable { + let id: String + var username: String + var token: String +} diff --git a/Hutch/Views/More/AccountSwitcherView.swift b/Hutch/Views/More/AccountSwitcherView.swift new file mode 100644 index 0000000..6fe3c28 --- /dev/null +++ b/Hutch/Views/More/AccountSwitcherView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct AccountSwitcherView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var showAddAccount = false + @State private var isSwitching = false + @State private var switchError: String? + + var body: some View { + NavigationStack { + List { + Section { + ForEach(appState.accounts) { account in + Button { + guard account.id != appState.activeAccountID else { return } + switchTo(account) + } label: { + HStack { + Text(account.username) + .foregroundStyle(.primary) + Spacer() + if account.id == appState.activeAccountID { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + } + .disabled(isSwitching) + } + .onDelete { indexSet in + for index in indexSet { + let account = appState.accounts[index] + Task { await appState.removeAccount(id: account.id) } + } + } + } + + Section { + Button { + showAddAccount = true + } label: { + Label("Add Account", systemImage: "plus.circle") + } + .disabled(isSwitching) + } + } + .navigationTitle("Accounts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .overlay { + if isSwitching { + ZStack { + Color.black.opacity(0.25).ignoresSafeArea() + ProgressView("Switching…") + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } + } + .alert("Switch Failed", isPresented: Binding( + get: { switchError != nil }, + set: { if !$0 { switchError = nil } } + )) { + Button("OK") { switchError = nil } + } message: { + Text(switchError ?? "") + } + .sheet(isPresented: $showAddAccount) { + AddAccountView() + } + } + } + + private func switchTo(_ account: AccountEntry) { + isSwitching = true + Task { + do { + try await appState.switchAccount(to: account.id) + dismiss() + } catch { + switchError = error.localizedDescription + } + isSwitching = false + } + } +} diff --git a/Hutch/Views/More/AddAccountView.swift b/Hutch/Views/More/AddAccountView.swift new file mode 100644 index 0000000..cfaa2b2 --- /dev/null +++ b/Hutch/Views/More/AddAccountView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct AddAccountView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var token = "" + @State private var isConnecting = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + Form { + Section { + SecureField("Personal Access Token", text: $token) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } footer: { + Text("Generate a token at meta.sr.ht → OAuth2 clients.") + } + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + } + .navigationTitle("Add Account") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isConnecting) + } + ToolbarItem(placement: .confirmationAction) { + Button("Connect") { connect() } + .disabled(token.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting) + } + } + .interactiveDismissDisabled(isConnecting) + } + } + + private func connect() { + isConnecting = true + errorMessage = nil + let trimmed = token.trimmingCharacters(in: .whitespaces) + Task { + do { + try await appState.addAccount(token: trimmed) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + isConnecting = false + } + } +} diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index b7c5576..c981432 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -1,12 +1,16 @@ import SwiftUI struct MoreView: View { + @Environment(AppState.self) private var appState + private let unsupportedLinks: [(title: String, url: URL)] = [ ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), ("man.sr.ht", URL(string: "https://man.sr.ht")!), ("srht.site", URL(string: "https://srht.site")!) ] + @State private var showAccountSwitcher = false + var body: some View { List { Section { @@ -36,5 +40,17 @@ struct MoreView: View { } } .navigationTitle("More") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAccountSwitcher = true + } label: { + Image(systemName: "person.crop.circle") + } + } + } + .sheet(isPresented: $showAccountSwitcher) { + AccountSwitcherView() + } } } |
