krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

7b59ee6098c4649a51cafe4b58e3a675567e2c08

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-01T16:36:01Z

feat: add multi-account support

Implements: https://todo.sr.ht/~ccleberg/Hutch/11
 Hutch.xcodeproj/project.pbxproj            |  16 ++---
 Hutch/App/AppState.swift                   | 100 +++++++++++++++++++++++++++--
 Hutch/App/AppStorageKeys.swift             |   1 +
 Hutch/Extensions/KeychainHelper.swift      |  40 ++++++++++++
 Hutch/Models/AccountEntry.swift            |   8 +++
 Hutch/Views/More/AccountSwitcherView.swift |  92 ++++++++++++++++++++++++++
 Hutch/Views/More/AddAccountView.swift      |  59 +++++++++++++++++
 Hutch/Views/More/MoreView.swift            |  16 +++++
 8 files changed, 317 insertions(+), 15 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 7251561..1edd877 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -491,7 +491,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 14;
+				CURRENT_PROJECT_VERSION = 15;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -508,7 +508,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.5.2;
+				MARKETING_VERSION = 2.6.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -528,7 +528,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 14;
+				CURRENT_PROJECT_VERSION = 15;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -545,7 +545,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.5.2;
+				MARKETING_VERSION = 2.6.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -608,7 +608,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 14;
+				CURRENT_PROJECT_VERSION = 15;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -618,7 +618,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.5.2;
+				MARKETING_VERSION = 2.6.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -637,7 +637,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 14;
+				CURRENT_PROJECT_VERSION = 15;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -647,7 +647,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.5.2;
+				MARKETING_VERSION = 2.6.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
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()
+        }
     }
 }