summaryrefslogtreecommitdiff
path: root/Hutch/App
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-01 11:36:01 -0500
committerChristian Cleberg <[email protected]>2026-04-01 11:37:35 -0500
commit7b59ee6098c4649a51cafe4b58e3a675567e2c08 (patch)
tree3ec7aa3c843c13d24a164d89f1d724ac48d2210a /Hutch/App
parent3f03c433a7435e283244e94f09bd513bd45f3202 (diff)
downloadhutch-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.swift100
-rw-r--r--Hutch/App/AppStorageKeys.swift1
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"
}