Commit 7cc4459477

7cc4459477424877a2b75476f9675467ffbd6f8d

parent: 15d3b5bbf2

Unsigned

cmc <hello@cleberg.net> · 2026-04-01 16:36 UTC

feat: add multi-account support

Implements: https://todo.sr.ht/~ccleberg/Hutch/11

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -491,7 +491,7 @@
491491 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
492492 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
493493 CODE_SIGN_STYLE = Automatic;
494 CURRENT_PROJECT_VERSION = 14;
494 CURRENT_PROJECT_VERSION = 15;
495495 DEVELOPMENT_TEAM = ZCNAX3VL9D;
496496 ENABLE_PREVIEWS = YES;
497497 GENERATE_INFOPLIST_FILE = YES;
@@ -508,7 +508,7 @@
508508 "$(inherited)",
509509 "@executable_path/Frameworks",
510510 );
511 MARKETING_VERSION = 2.5.2;
511 MARKETING_VERSION = 2.6.0;
512512 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
513513 PRODUCT_NAME = "$(TARGET_NAME)";
514514 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -528,7 +528,7 @@
528528 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
529529 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
530530 CODE_SIGN_STYLE = Automatic;
531 CURRENT_PROJECT_VERSION = 14;
531 CURRENT_PROJECT_VERSION = 15;
532532 DEVELOPMENT_TEAM = ZCNAX3VL9D;
533533 ENABLE_PREVIEWS = YES;
534534 GENERATE_INFOPLIST_FILE = YES;
@@ -545,7 +545,7 @@
545545 "$(inherited)",
546546 "@executable_path/Frameworks",
547547 );
548 MARKETING_VERSION = 2.5.2;
548 MARKETING_VERSION = 2.6.0;
549549 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
550550 PRODUCT_NAME = "$(TARGET_NAME)";
551551 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -608,7 +608,7 @@
608608 APPLICATION_EXTENSION_API_ONLY = YES;
609609 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
610610 CODE_SIGN_STYLE = Automatic;
611 CURRENT_PROJECT_VERSION = 14;
611 CURRENT_PROJECT_VERSION = 15;
612612 DEVELOPMENT_TEAM = ZCNAX3VL9D;
613613 GENERATE_INFOPLIST_FILE = NO;
614614 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -618,7 +618,7 @@
618618 "@executable_path/Frameworks",
619619 "@executable_path/../../Frameworks",
620620 );
621 MARKETING_VERSION = 2.5.2;
621 MARKETING_VERSION = 2.6.0;
622622 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
623623 PRODUCT_NAME = "$(TARGET_NAME)";
624624 SKIP_INSTALL = YES;
@@ -637,7 +637,7 @@
637637 APPLICATION_EXTENSION_API_ONLY = YES;
638638 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
639639 CODE_SIGN_STYLE = Automatic;
640 CURRENT_PROJECT_VERSION = 14;
640 CURRENT_PROJECT_VERSION = 15;
641641 DEVELOPMENT_TEAM = ZCNAX3VL9D;
642642 GENERATE_INFOPLIST_FILE = NO;
643643 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -647,7 +647,7 @@
647647 "@executable_path/Frameworks",
648648 "@executable_path/../../Frameworks",
649649 );
650 MARKETING_VERSION = 2.5.2;
650 MARKETING_VERSION = 2.6.0;
651651 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
652652 PRODUCT_NAME = "$(TARGET_NAME)";
653653 SKIP_INSTALL = YES;
Hutch/App/AppState.swift +93 −7
@@ -39,6 +39,14 @@ final class AppState {
3939 authPhase == .authenticated && currentUser != nil
4040 }
4141
42 // MARK: - Multi-account
43
44 /// All stored accounts. Loaded from Keychain; kept in sync on add/remove/switch.
45 private(set) var accounts: [AccountEntry] = []
46
47 /// The ID of the account currently in use. Persisted in UserDefaults.
48 private(set) var activeAccountID: String = ""
49
4250 var selectedTab: Tab = .home
4351
4452 // MARK: - Current user (populated after successful validation)
@@ -68,18 +76,40 @@ final class AppState {
6876 /// Called once at app launch. If a token exists in Keychain, validates it
6977 /// silently. On failure, clears the token and falls through to unauthenticated.
7078 func validateOnLaunch() async {
71 guard client.hasToken else {
79 var storedAccounts = KeychainHelper.loadAccounts()
80
81 if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() {
82 client.setToken(legacyToken)
83 if let user = try? await fetchMe() {
84 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken)
85 storedAccounts = [entry]
86 try? KeychainHelper.saveAccounts(storedAccounts)
87 try? KeychainHelper.deleteToken()
88 } else {
89 try? KeychainHelper.deleteToken()
90 client.setToken(nil)
91 authPhase = .unauthenticated
92 return
93 }
94 }
95
96 guard !storedAccounts.isEmpty else {
7297 authPhase = .unauthenticated
7398 return
7499 }
75100
101 let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
102 let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0]
103
104 client.setToken(target.token)
76105 do {
77106 let user = try await fetchMe()
107 accounts = storedAccounts
108 activeAccountID = target.id
78109 currentUser = user
79110 authPhase = .authenticated
80111 await refreshNeedsAttentionSnapshot()
81112 } catch {
82 try? KeychainHelper.deleteToken()
83113 client.setToken(nil)
84114 currentUser = nil
85115 authPhase = .unauthenticated
@@ -92,22 +122,71 @@ final class AppState {
92122 /// Validate a new token by querying meta.sr.ht, then persist it.
93123 /// Throws on network/GraphQL errors so the caller can display the message.
94124 func connect(with token: String) async throws {
95 // Temporarily set the token so the client can use it for the request.
96125 client.setToken(token)
97
98126 do {
99127 let user = try await fetchMe()
100 try KeychainHelper.saveToken(token)
128 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
129 accounts.append(entry)
130 activeAccountID = entry.id
131 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
132 try KeychainHelper.saveAccounts(accounts)
101133 currentUser = user
102134 authPhase = .authenticated
103135 await refreshNeedsAttentionSnapshot()
104136 } catch {
105 // Roll back — don't leave an invalid token in the client.
106137 client.setToken(nil)
107138 throw error
108139 }
109140 }
110141
142 /// Validate a new token, add it as an account, and switch to it immediately.
143 func addAccount(token: String) async throws {
144 let tempClient = SRHTClient(token: token)
145 let user = try await fetchMe(using: tempClient)
146 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
147 accounts.append(entry)
148 try KeychainHelper.saveAccounts(accounts)
149 try await switchAccount(to: entry.id)
150 }
151
152 /// Switch the active account and fully refresh the app.
153 func switchAccount(to id: String) async throws {
154 guard let entry = accounts.first(where: { $0.id == id }) else { return }
155
156 client.responseCache.clear()
157 currentUser = nil
158 pendingDeepLink = nil
159 pendingTabNavigation = nil
160 deepLinkError = nil
161 selectedTab = .home
162
163 authPhase = .unauthenticated
164
165 client.setToken(entry.token)
166 activeAccountID = entry.id
167 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
168
169 let user = try await fetchMe()
170 currentUser = user
171 authPhase = .authenticated
172 await refreshNeedsAttentionSnapshot()
173 }
174
175 /// Remove a stored account. Switches to another account if the removed account
176 /// was active; signs out fully if it was the last account.
177 func removeAccount(id: String) async {
178 accounts.removeAll { $0.id == id }
179 try? KeychainHelper.saveAccounts(accounts)
180
181 guard id == activeAccountID else { return }
182
183 if let next = accounts.first {
184 try? await switchAccount(to: next.id)
185 } else {
186 await signOut()
187 }
188 }
189
111190 func signOut() async {
112191 clearSessionState()
113192 URLCache.shared.removeAllCachedResponses()
@@ -215,7 +294,11 @@ final class AppState {
215294 }
216295
217296 private func fetchMe() async throws -> User {
218 let result = try await client.execute(
297 try await fetchMe(using: client)
298 }
299
300 private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
301 let result = try await srhtClient.execute(
219302 service: .meta,
220303 query: Self.meQuery,
221304 responseType: MeResponse.self
@@ -268,6 +351,9 @@ final class AppState {
268351 try? KeychainHelper.deleteAll()
269352 client.setToken(nil)
270353 client.responseCache.clear()
354 accounts = []
355 activeAccountID = ""
356 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
271357 currentUser = nil
272358 pendingDeepLink = nil
273359 pendingTabNavigation = nil
Hutch/App/AppStorageKeys.swift +1
@@ -1,4 +1,5 @@
11// Shared UserDefaults key constants
22enum AppStorageKeys {
33 static let swipeActionsEnabled = "swipeActionsEnabled"
4 static let activeAccountID = "activeAccountID"
45}
Hutch/Extensions/KeychainHelper.swift +40
@@ -5,6 +5,7 @@ enum KeychainHelper: Sendable {
55
66 private static let service = "net.cleberg.Hutch"
77 private static let tokenAccount = "srht-access-token"
8 private static let accountsAccount = "srht-accounts"
89
910 // MARK: - Save
1011
@@ -57,6 +58,45 @@ enum KeychainHelper: Sendable {
5758 return token
5859 }
5960
61 // MARK: - Multi-account list
62
63 static func saveAccounts(_ accounts: [AccountEntry]) throws {
64 let data = try JSONEncoder().encode(accounts)
65
66 let deleteQuery: [String: Any] = [
67 kSecClass as String: kSecClassGenericPassword,
68 kSecAttrService as String: service,
69 kSecAttrAccount as String: accountsAccount
70 ]
71 SecItemDelete(deleteQuery as CFDictionary)
72
73 let addQuery: [String: Any] = [
74 kSecClass as String: kSecClassGenericPassword,
75 kSecAttrService as String: service,
76 kSecAttrAccount as String: accountsAccount,
77 kSecValueData as String: data,
78 kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
79 ]
80 let status = SecItemAdd(addQuery as CFDictionary, nil)
81 guard status == errSecSuccess else {
82 throw KeychainError.saveFailed(status)
83 }
84 }
85
86 static func loadAccounts() -> [AccountEntry] {
87 let query: [String: Any] = [
88 kSecClass as String: kSecClassGenericPassword,
89 kSecAttrService as String: service,
90 kSecAttrAccount as String: accountsAccount,
91 kSecReturnData as String: true,
92 kSecMatchLimit as String: kSecMatchLimitOne
93 ]
94 var result: AnyObject?
95 guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
96 let data = result as? Data else { return [] }
97 return (try? JSONDecoder().decode([AccountEntry].self, from: data)) ?? []
98 }
99
60100 // MARK: - Delete
61101
62102 static func deleteToken() throws {
Hutch/Models/AccountEntry.swift added +8
@@ -0,0 +1,8 @@
1import Foundation
2
3/// A stored sr.ht account (token + resolved username).
4struct AccountEntry: Codable, Identifiable, Equatable, Sendable {
5 let id: String
6 var username: String
7 var token: String
8}
Hutch/Views/More/AccountSwitcherView.swift added +92
@@ -0,0 +1,92 @@
1import SwiftUI
2
3struct AccountSwitcherView: View {
4 @Environment(AppState.self) private var appState
5 @Environment(\.dismiss) private var dismiss
6
7 @State private var showAddAccount = false
8 @State private var isSwitching = false
9 @State private var switchError: String?
10
11 var body: some View {
12 NavigationStack {
13 List {
14 Section {
15 ForEach(appState.accounts) { account in
16 Button {
17 guard account.id != appState.activeAccountID else { return }
18 switchTo(account)
19 } label: {
20 HStack {
21 Text(account.username)
22 .foregroundStyle(.primary)
23 Spacer()
24 if account.id == appState.activeAccountID {
25 Image(systemName: "checkmark")
26 .foregroundStyle(.tint)
27 }
28 }
29 }
30 .disabled(isSwitching)
31 }
32 .onDelete { indexSet in
33 for index in indexSet {
34 let account = appState.accounts[index]
35 Task { await appState.removeAccount(id: account.id) }
36 }
37 }
38 }
39
40 Section {
41 Button {
42 showAddAccount = true
43 } label: {
44 Label("Add Account", systemImage: "plus.circle")
45 }
46 .disabled(isSwitching)
47 }
48 }
49 .navigationTitle("Accounts")
50 .navigationBarTitleDisplayMode(.inline)
51 .toolbar {
52 ToolbarItem(placement: .confirmationAction) {
53 Button("Done") { dismiss() }
54 }
55 }
56 .overlay {
57 if isSwitching {
58 ZStack {
59 Color.black.opacity(0.25).ignoresSafeArea()
60 ProgressView("Switching…")
61 .padding()
62 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
63 }
64 }
65 }
66 .alert("Switch Failed", isPresented: Binding(
67 get: { switchError != nil },
68 set: { if !$0 { switchError = nil } }
69 )) {
70 Button("OK") { switchError = nil }
71 } message: {
72 Text(switchError ?? "")
73 }
74 .sheet(isPresented: $showAddAccount) {
75 AddAccountView()
76 }
77 }
78 }
79
80 private func switchTo(_ account: AccountEntry) {
81 isSwitching = true
82 Task {
83 do {
84 try await appState.switchAccount(to: account.id)
85 dismiss()
86 } catch {
87 switchError = error.localizedDescription
88 }
89 isSwitching = false
90 }
91 }
92}
Hutch/Views/More/AddAccountView.swift added +59
@@ -0,0 +1,59 @@
1import SwiftUI
2
3struct AddAccountView: View {
4 @Environment(AppState.self) private var appState
5 @Environment(\.dismiss) private var dismiss
6
7 @State private var token = ""
8 @State private var isConnecting = false
9 @State private var errorMessage: String?
10
11 var body: some View {
12 NavigationStack {
13 Form {
14 Section {
15 SecureField("Personal Access Token", text: $token)
16 .autocorrectionDisabled()
17 .textInputAutocapitalization(.never)
18 } footer: {
19 Text("Generate a token at meta.sr.ht → OAuth2 clients.")
20 }
21
22 if let errorMessage {
23 Section {
24 Text(errorMessage)
25 .foregroundStyle(.red)
26 }
27 }
28 }
29 .navigationTitle("Add Account")
30 .navigationBarTitleDisplayMode(.inline)
31 .toolbar {
32 ToolbarItem(placement: .cancellationAction) {
33 Button("Cancel") { dismiss() }
34 .disabled(isConnecting)
35 }
36 ToolbarItem(placement: .confirmationAction) {
37 Button("Connect") { connect() }
38 .disabled(token.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting)
39 }
40 }
41 .interactiveDismissDisabled(isConnecting)
42 }
43 }
44
45 private func connect() {
46 isConnecting = true
47 errorMessage = nil
48 let trimmed = token.trimmingCharacters(in: .whitespaces)
49 Task {
50 do {
51 try await appState.addAccount(token: trimmed)
52 dismiss()
53 } catch {
54 errorMessage = error.localizedDescription
55 }
56 isConnecting = false
57 }
58 }
59}
Hutch/Views/More/MoreView.swift +16
@@ -1,12 +1,16 @@
11import SwiftUI
22
33struct MoreView: View {
4 @Environment(AppState.self) private var appState
5
46 private let unsupportedLinks: [(title: String, url: URL)] = [
57 ("chat.sr.ht", URL(string: "https://chat.sr.ht")!),
68 ("man.sr.ht", URL(string: "https://man.sr.ht")!),
79 ("srht.site", URL(string: "https://srht.site")!)
810 ]
911
12 @State private var showAccountSwitcher = false
13
1014 var body: some View {
1115 List {
1216 Section {
@@ -36,5 +40,17 @@ struct MoreView: View {
3640 }
3741 }
3842 .navigationTitle("More")
43 .toolbar {
44 ToolbarItem(placement: .topBarTrailing) {
45 Button {
46 showAccountSwitcher = true
47 } label: {
48 Image(systemName: "person.crop.circle")
49 }
50 }
51 }
52 .sheet(isPresented: $showAccountSwitcher) {
53 AccountSwitcherView()
54 }
3955 }
4056}