diff options
| author | Christian Cleberg <[email protected]> | 2026-04-01 11:36:01 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-01 11:37:35 -0500 |
| commit | 7b59ee6098c4649a51cafe4b58e3a675567e2c08 (patch) | |
| tree | 3ec7aa3c843c13d24a164d89f1d724ac48d2210a /Hutch/App | |
| parent | 3f03c433a7435e283244e94f09bd513bd45f3202 (diff) | |
| download | hutch-2.6.0.tar.gz hutch-2.6.0.tar.bz2 hutch-2.6.0.zip | |
feat: add multi-account supportv2.6.0
Implements: https://todo.sr.ht/~ccleberg/Hutch/11
Diffstat (limited to 'Hutch/App')
| -rw-r--r-- | Hutch/App/AppState.swift | 100 | ||||
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 1 |
2 files changed, 94 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" } |
