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 @@
491 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 491 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
492 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 492 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
493 CODE_SIGN_STYLE = Automatic; 493 CODE_SIGN_STYLE = Automatic;
494 CURRENT_PROJECT_VERSION = 14; 494 CURRENT_PROJECT_VERSION = 15;
495 DEVELOPMENT_TEAM = ZCNAX3VL9D; 495 DEVELOPMENT_TEAM = ZCNAX3VL9D;
496 ENABLE_PREVIEWS = YES; 496 ENABLE_PREVIEWS = YES;
497 GENERATE_INFOPLIST_FILE = YES; 497 GENERATE_INFOPLIST_FILE = YES;
@@ -508,7 +508,7 @@
508 "$(inherited)", 508 "$(inherited)",
509 "@executable_path/Frameworks", 509 "@executable_path/Frameworks",
510 ); 510 );
511 MARKETING_VERSION = 2.5.2; 511 MARKETING_VERSION = 2.6.0;
512 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 512 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
513 PRODUCT_NAME = "$(TARGET_NAME)"; 513 PRODUCT_NAME = "$(TARGET_NAME)";
514 STRING_CATALOG_GENERATE_SYMBOLS = YES; 514 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -528,7 +528,7 @@
528 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 528 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
529 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 529 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
530 CODE_SIGN_STYLE = Automatic; 530 CODE_SIGN_STYLE = Automatic;
531 CURRENT_PROJECT_VERSION = 14; 531 CURRENT_PROJECT_VERSION = 15;
532 DEVELOPMENT_TEAM = ZCNAX3VL9D; 532 DEVELOPMENT_TEAM = ZCNAX3VL9D;
533 ENABLE_PREVIEWS = YES; 533 ENABLE_PREVIEWS = YES;
534 GENERATE_INFOPLIST_FILE = YES; 534 GENERATE_INFOPLIST_FILE = YES;
@@ -545,7 +545,7 @@
545 "$(inherited)", 545 "$(inherited)",
546 "@executable_path/Frameworks", 546 "@executable_path/Frameworks",
547 ); 547 );
548 MARKETING_VERSION = 2.5.2; 548 MARKETING_VERSION = 2.6.0;
549 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 549 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
550 PRODUCT_NAME = "$(TARGET_NAME)"; 550 PRODUCT_NAME = "$(TARGET_NAME)";
551 STRING_CATALOG_GENERATE_SYMBOLS = YES; 551 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -608,7 +608,7 @@
608 APPLICATION_EXTENSION_API_ONLY = YES; 608 APPLICATION_EXTENSION_API_ONLY = YES;
609 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 609 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
610 CODE_SIGN_STYLE = Automatic; 610 CODE_SIGN_STYLE = Automatic;
611 CURRENT_PROJECT_VERSION = 14; 611 CURRENT_PROJECT_VERSION = 15;
612 DEVELOPMENT_TEAM = ZCNAX3VL9D; 612 DEVELOPMENT_TEAM = ZCNAX3VL9D;
613 GENERATE_INFOPLIST_FILE = NO; 613 GENERATE_INFOPLIST_FILE = NO;
614 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 614 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -618,7 +618,7 @@
618 "@executable_path/Frameworks", 618 "@executable_path/Frameworks",
619 "@executable_path/../../Frameworks", 619 "@executable_path/../../Frameworks",
620 ); 620 );
621 MARKETING_VERSION = 2.5.2; 621 MARKETING_VERSION = 2.6.0;
622 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 622 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
623 PRODUCT_NAME = "$(TARGET_NAME)"; 623 PRODUCT_NAME = "$(TARGET_NAME)";
624 SKIP_INSTALL = YES; 624 SKIP_INSTALL = YES;
@@ -637,7 +637,7 @@
637 APPLICATION_EXTENSION_API_ONLY = YES; 637 APPLICATION_EXTENSION_API_ONLY = YES;
638 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 638 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
639 CODE_SIGN_STYLE = Automatic; 639 CODE_SIGN_STYLE = Automatic;
640 CURRENT_PROJECT_VERSION = 14; 640 CURRENT_PROJECT_VERSION = 15;
641 DEVELOPMENT_TEAM = ZCNAX3VL9D; 641 DEVELOPMENT_TEAM = ZCNAX3VL9D;
642 GENERATE_INFOPLIST_FILE = NO; 642 GENERATE_INFOPLIST_FILE = NO;
643 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 643 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -647,7 +647,7 @@
647 "@executable_path/Frameworks", 647 "@executable_path/Frameworks",
648 "@executable_path/../../Frameworks", 648 "@executable_path/../../Frameworks",
649 ); 649 );
650 MARKETING_VERSION = 2.5.2; 650 MARKETING_VERSION = 2.6.0;
651 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 651 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
652 PRODUCT_NAME = "$(TARGET_NAME)"; 652 PRODUCT_NAME = "$(TARGET_NAME)";
653 SKIP_INSTALL = YES; 653 SKIP_INSTALL = YES;
Hutch/App/AppState.swift +93 −7
@@ -39,6 +39,14 @@ final class AppState {
39 authPhase == .authenticated && currentUser != nil 39 authPhase == .authenticated && currentUser != nil
40 } 40 }
41 41
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
42 var selectedTab: Tab = .home 50 var selectedTab: Tab = .home
43 51
44 // MARK: - Current user (populated after successful validation) 52 // MARK: - Current user (populated after successful validation)
@@ -68,18 +76,40 @@ final class AppState {
68 /// Called once at app launch. If a token exists in Keychain, validates it 76 /// Called once at app launch. If a token exists in Keychain, validates it
69 /// silently. On failure, clears the token and falls through to unauthenticated. 77 /// silently. On failure, clears the token and falls through to unauthenticated.
70 func validateOnLaunch() async { 78 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 {
72 authPhase = .unauthenticated 97 authPhase = .unauthenticated
73 return 98 return
74 } 99 }
75 100
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)
76 do { 105 do {
77 let user = try await fetchMe() 106 let user = try await fetchMe()
107 accounts = storedAccounts
108 activeAccountID = target.id
78 currentUser = user 109 currentUser = user
79 authPhase = .authenticated 110 authPhase = .authenticated
80 await refreshNeedsAttentionSnapshot() 111 await refreshNeedsAttentionSnapshot()
81 } catch { 112 } catch {
82 try? KeychainHelper.deleteToken()
83 client.setToken(nil) 113 client.setToken(nil)
84 currentUser = nil 114 currentUser = nil
85 authPhase = .unauthenticated 115 authPhase = .unauthenticated
@@ -92,22 +122,71 @@ final class AppState {
92 /// Validate a new token by querying meta.sr.ht, then persist it. 122 /// Validate a new token by querying meta.sr.ht, then persist it.
93 /// Throws on network/GraphQL errors so the caller can display the message. 123 /// Throws on network/GraphQL errors so the caller can display the message.
94 func connect(with token: String) async throws { 124 func connect(with token: String) async throws {
95 // Temporarily set the token so the client can use it for the request.
96 client.setToken(token) 125 client.setToken(token)
97
98 do { 126 do {
99 let user = try await fetchMe() 127 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)
101 currentUser = user 133 currentUser = user
102 authPhase = .authenticated 134 authPhase = .authenticated
103 await refreshNeedsAttentionSnapshot() 135 await refreshNeedsAttentionSnapshot()
104 } catch { 136 } catch {
105 // Roll back — don't leave an invalid token in the client.
106 client.setToken(nil) 137 client.setToken(nil)
107 throw error 138 throw error
108 } 139 }
109 } 140 }
110 141
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
111 func signOut() async { 190 func signOut() async {
112 clearSessionState() 191 clearSessionState()
113 URLCache.shared.removeAllCachedResponses() 192 URLCache.shared.removeAllCachedResponses()
@@ -215,7 +294,11 @@ final class AppState {
215 } 294 }
216 295
217 private func fetchMe() async throws -> User { 296 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(
219 service: .meta, 302 service: .meta,
220 query: Self.meQuery, 303 query: Self.meQuery,
221 responseType: MeResponse.self 304 responseType: MeResponse.self
@@ -268,6 +351,9 @@ final class AppState {
268 try? KeychainHelper.deleteAll() 351 try? KeychainHelper.deleteAll()
269 client.setToken(nil) 352 client.setToken(nil)
270 client.responseCache.clear() 353 client.responseCache.clear()
354 accounts = []
355 activeAccountID = ""
356 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
271 currentUser = nil 357 currentUser = nil
272 pendingDeepLink = nil 358 pendingDeepLink = nil
273 pendingTabNavigation = nil 359 pendingTabNavigation = nil
Hutch/App/AppStorageKeys.swift +1
@@ -1,4 +1,5 @@
1// Shared UserDefaults key constants 1// Shared UserDefaults key constants
2enum AppStorageKeys { 2enum AppStorageKeys {
3 static let swipeActionsEnabled = "swipeActionsEnabled" 3 static let swipeActionsEnabled = "swipeActionsEnabled"
4 static let activeAccountID = "activeAccountID"
4} 5}
Hutch/Extensions/KeychainHelper.swift +40
@@ -5,6 +5,7 @@ enum KeychainHelper: Sendable {
5 5
6 private static let service = "net.cleberg.Hutch" 6 private static let service = "net.cleberg.Hutch"
7 private static let tokenAccount = "srht-access-token" 7 private static let tokenAccount = "srht-access-token"
8 private static let accountsAccount = "srht-accounts"
8 9
9 // MARK: - Save 10 // MARK: - Save
10 11
@@ -57,6 +58,45 @@ enum KeychainHelper: Sendable {
57 return token 58 return token
58 } 59 }
59 60
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
60 // MARK: - Delete 100 // MARK: - Delete
61 101
62 static func deleteToken() throws { 102 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 @@
1import SwiftUI 1import SwiftUI
2 2
3struct MoreView: View { 3struct MoreView: View {
4 @Environment(AppState.self) private var appState
5
4 private let unsupportedLinks: [(title: String, url: URL)] = [ 6 private let unsupportedLinks: [(title: String, url: URL)] = [
5 ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), 7 ("chat.sr.ht", URL(string: "https://chat.sr.ht")!),
6 ("man.sr.ht", URL(string: "https://man.sr.ht")!), 8 ("man.sr.ht", URL(string: "https://man.sr.ht")!),
7 ("srht.site", URL(string: "https://srht.site")!) 9 ("srht.site", URL(string: "https://srht.site")!)
8 ] 10 ]
9 11
12 @State private var showAccountSwitcher = false
13
10 var body: some View { 14 var body: some View {
11 List { 15 List {
12 Section { 16 Section {
@@ -36,5 +40,17 @@ struct MoreView: View {
36 } 40 }
37 } 41 }
38 .navigationTitle("More") 42 .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 }
39 } 55 }
40} 56}