Commit 899abef569

899abef569721a4f1b931c2ffdd3caad204a649a

parent: d18aa66063

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 04:19 UTC

feat: add multi-account support

Implements: https://todo.sr.ht/~ccleberg/hutch/49
Implements: https://todo.sr.ht/~ccleberg/hutch/50
Implements: https://todo.sr.ht/~ccleberg/hutch/51

Layout: unified · split

Hutch/App/AppState.swift +179 −76
@@ -35,6 +35,7 @@ final class AppState {
3535 // MARK: - Authentication
3636
3737 private(set) var authPhase: AuthPhase = .launching
38 private(set) var authStatusMessage = "Connecting…"
3839
3940 /// Convenience for views that need a simple bool.
4041 var isAuthenticated: Bool {
@@ -57,9 +58,15 @@ final class AppState {
5758
5859 // MARK: - Networking
5960
60 let client: SRHTClient
61 private(set) var client: SRHTClient
6162 let configuration: AppConfiguration
62 let systemStatusRepository: SystemStatusRepository
63 private(set) var systemStatusRepository: SystemStatusRepository
64 private var activeSession: AccountSession?
65 private(set) var sessionIdentity = UUID()
66
67 var accountDefaults: UserDefaults {
68 activeSession?.defaults ?? .standard
69 }
6370
6471 // MARK: - Deep link pending navigation
6572
@@ -72,8 +79,7 @@ final class AppState {
7279
7380 init() {
7481 self.configuration = AppConfiguration()
75 let token = KeychainHelper.loadToken()
76 self.client = SRHTClient(token: token)
82 self.client = SRHTClient()
7783 self.systemStatusRepository = SystemStatusRepository()
7884 }
7985
@@ -82,18 +88,18 @@ final class AppState {
8288 /// Called once at app launch. If a token exists in Keychain, validates it
8389 /// silently. On failure, clears the token and falls through to unauthenticated.
8490 func validateOnLaunch() async {
91 authStatusMessage = "Connecting…"
8592 var storedAccounts = KeychainHelper.loadAccounts()
8693
8794 if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() {
88 client.setToken(legacyToken)
89 if let user = try? await fetchMe() {
95 let legacyClient = SRHTClient(token: legacyToken)
96 if let user = try? await fetchMe(using: legacyClient) {
9097 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken)
9198 storedAccounts = [entry]
9299 try? KeychainHelper.saveAccounts(storedAccounts)
93100 try? KeychainHelper.deleteToken()
94101 } else {
95102 try? KeychainHelper.deleteToken()
96 client.setToken(nil)
97103 authPhase = .unauthenticated
98104 return
99105 }
@@ -105,24 +111,29 @@ final class AppState {
105111 }
106112
107113 let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
108 let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0]
109
110 client.setToken(target.token)
111 do {
112 let user = try await fetchMe()
113 accounts = storedAccounts
114 activeAccountID = target.id
115 currentUser = user
116 ContributionWidgetContextStore.saveActor(user.canonicalName)
117 authPhase = .authenticated
118 await refreshNeedsAttentionSnapshot()
119 } catch {
120 client.setToken(nil)
121 currentUser = nil
122 ContributionWidgetContextStore.clear()
123 authPhase = .unauthenticated
124 NeedsAttentionSnapshotStore.clear()
114 let orderedAccounts = prioritizedAccounts(storedAccounts, preferredID: savedID)
115 var invalidIDs = Set<String>()
116
117 for account in orderedAccounts {
118 do {
119 let session = try await makeSession(for: account)
120 let filteredAccounts = storedAccounts.filter { !invalidIDs.contains($0.id) }
121 accounts = filteredAccounts
122 try? KeychainHelper.saveAccounts(filteredAccounts)
123 activate(session)
124 authPhase = .authenticated
125 await refreshNeedsAttentionSnapshot()
126 return
127 } catch {
128 invalidIDs.insert(account.id)
129 clearAccountArtifacts(for: account.id)
130 }
125131 }
132
133 accounts = storedAccounts.filter { !invalidIDs.contains($0.id) }
134 try? KeychainHelper.saveAccounts(accounts)
135 clearActiveSessionState()
136 authPhase = .unauthenticated
126137 }
127138
128139 // MARK: - Token management
@@ -130,54 +141,40 @@ final class AppState {
130141 /// Validate a new token by querying meta.sr.ht, then persist it.
131142 /// Throws on network/GraphQL errors so the caller can display the message.
132143 func connect(with token: String) async throws {
133 client.setToken(token)
134 do {
135 let user = try await fetchMe()
136 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
137 accounts.append(entry)
138 activeAccountID = entry.id
139 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
140 try KeychainHelper.saveAccounts(accounts)
141 currentUser = user
142 ContributionWidgetContextStore.saveActor(user.canonicalName)
143 authPhase = .authenticated
144 await refreshNeedsAttentionSnapshot()
145 } catch {
146 client.setToken(nil)
147 throw error
148 }
144 let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
145 try await addValidatedAccount(token: normalizedToken, activateNewAccount: true)
149146 }
150147
151148 /// Validate a new token, add it as an account, and switch to it immediately.
152149 func addAccount(token: String) async throws {
153 let tempClient = SRHTClient(token: token)
154 let user = try await fetchMe(using: tempClient)
155 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
156 accounts.append(entry)
157 try KeychainHelper.saveAccounts(accounts)
158 try await switchAccount(to: entry.id)
150 let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
151 try await addValidatedAccount(token: normalizedToken, activateNewAccount: true)
159152 }
160153
161154 /// Switch the active account and fully refresh the app.
162155 func switchAccount(to id: String) async throws {
163156 guard let entry = accounts.first(where: { $0.id == id }) else { return }
157 let previousSession = activeSession
164158
165 client.responseCache.clear()
166 currentUser = nil
167 pendingDeepLink = nil
168 pendingTabNavigation = nil
169 deepLinkError = nil
170 selectedTab = .home
171
172 authPhase = .unauthenticated
159 authStatusMessage = "Switching Accounts…"
160 authPhase = .launching
161 sessionIdentity = UUID()
173162
174 client.setToken(entry.token)
175 activeAccountID = entry.id
176 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
163 do {
164 let session = try await makeSession(for: entry)
165 activate(session)
166 resetNavigationState()
167 } catch {
168 if let previousSession {
169 activate(previousSession)
170 authPhase = .authenticated
171 } else {
172 clearActiveSessionState()
173 authPhase = .unauthenticated
174 }
175 throw error
176 }
177177
178 let user = try await fetchMe()
179 currentUser = user
180 ContributionWidgetContextStore.saveActor(user.canonicalName)
181178 authPhase = .authenticated
182179 await refreshNeedsAttentionSnapshot()
183180 }
@@ -185,42 +182,51 @@ final class AppState {
185182 /// Remove a stored account. Switches to another account if the removed account
186183 /// was active; signs out fully if it was the last account.
187184 func removeAccount(id: String) async {
185 let removedWasActive = id == activeAccountID
188186 accounts.removeAll { $0.id == id }
189187 try? KeychainHelper.saveAccounts(accounts)
188 clearAccountArtifacts(for: id)
190189
191 guard id == activeAccountID else { return }
190 guard removedWasActive else { return }
192191
193192 if let next = accounts.first {
194 try? await switchAccount(to: next.id)
193 do {
194 try await switchAccount(to: next.id)
195 } catch {
196 await removeAccount(id: next.id)
197 }
195198 } else {
196199 await signOut()
197200 }
198201 }
199202
200203 func signOut() async {
201 clearSessionState()
204 clearActiveSessionState()
205 try? KeychainHelper.deleteAll()
202206 URLCache.shared.removeAllCachedResponses()
203207 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
204208 await clearWebData()
205209 clearWebContentRenderCaches()
206 NeedsAttentionSnapshotStore.clear()
207 SystemStatusWidgetSnapshotStore.clear()
210 clearAllAccountArtifacts()
208211 authPhase = .unauthenticated
209212 selectedTab = .home
210213 }
211214
212215 func resetAppData() async {
213 clearSessionState()
216 clearActiveSessionState()
214217
215218 if let bundleIdentifier = Bundle.main.bundleIdentifier {
216219 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
217220 }
221 for account in accounts {
222 AccountDefaultsStore.clear(accountID: account.id)
223 }
224 try? KeychainHelper.deleteAll()
218225 URLCache.shared.removeAllCachedResponses()
219226 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
220227 await clearWebData()
221228 clearWebContentRenderCaches()
222 NeedsAttentionSnapshotStore.clear()
223 SystemStatusWidgetSnapshotStore.clear()
229 clearAllAccountArtifacts()
224230
225231 authPhase = .unauthenticated
226232 selectedTab = .home
@@ -395,31 +401,117 @@ final class AppState {
395401 let tracker: TrackerSummary
396402 }
397403
398 private func clearSessionState() {
399 try? KeychainHelper.deleteAll()
400 client.setToken(nil)
401 client.responseCache.clear()
402 accounts = []
404 private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws {
405 let tempClient = SRHTClient(token: token)
406 let user = try await fetchMe(using: tempClient)
407
408 if let existing = accounts.first(where: {
409 $0.username.caseInsensitiveCompare(user.username) == .orderedSame || $0.token == token
410 }) {
411 _ = existing
412 throw AppStateError.duplicateAccount(username: user.username)
413 }
414
415 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
416 accounts.append(entry)
417 try KeychainHelper.saveAccounts(accounts)
418
419 guard activateNewAccount else { return }
420 let session = try await makeSession(for: entry, knownUser: user)
421 authStatusMessage = "Switching Accounts…"
422 authPhase = .launching
423 sessionIdentity = UUID()
424 activate(session)
425 resetNavigationState()
426 authPhase = .authenticated
427 await refreshNeedsAttentionSnapshot()
428 }
429
430 private func makeSession(for account: AccountEntry, knownUser: User? = nil) async throws -> AccountSession {
431 let sessionClient = SRHTClient(token: account.token)
432 let user: User
433 if let knownUser {
434 user = knownUser
435 } else {
436 user = try await fetchMe(using: sessionClient)
437 }
438 let defaults = AccountDefaultsStore.userDefaults(for: account.id)
439 let repository = SystemStatusRepository(cacheStore: SystemStatusCacheStore(defaults: defaults))
440 return AccountSession(
441 account: account,
442 user: user,
443 client: sessionClient,
444 defaults: defaults,
445 systemStatusRepository: repository
446 )
447 }
448
449 private func activate(_ session: AccountSession) {
450 activeSession = session
451 client = session.client
452 systemStatusRepository = session.systemStatusRepository
453 currentUser = session.user
454 activeAccountID = session.account.id
455 UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID)
456 ActiveAccountContextStore.save(session.account.id)
457 ContributionWidgetContextStore.saveActor(session.user.canonicalName, accountID: session.account.id)
458 authStatusMessage = "Connecting…"
459 }
460
461 private func clearActiveSessionState() {
462 client = SRHTClient()
463 systemStatusRepository = SystemStatusRepository()
464 activeSession = nil
403465 activeAccountID = ""
404466 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
467 ActiveAccountContextStore.clear()
405468 currentUser = nil
406 ContributionWidgetContextStore.clear()
469 sessionIdentity = UUID()
470 resetNavigationState()
471 }
472
473 private func resetNavigationState() {
407474 pendingDeepLink = nil
408475 pendingTabNavigation = nil
409476 deepLinkError = nil
410477 selectedTab = .home
411478 }
412479
480 private func clearAccountArtifacts(for accountID: String) {
481 AccountDefaultsStore.clear(accountID: accountID)
482 ContributionWidgetContextStore.clear(accountID: accountID)
483 NeedsAttentionSnapshotStore.clear(accountID: accountID)
484 SystemStatusWidgetSnapshotStore.clear(accountID: accountID)
485 }
486
487 private func clearAllAccountArtifacts() {
488 for account in accounts {
489 clearAccountArtifacts(for: account.id)
490 }
491 ContributionWidgetContextStore.clear(accountID: nil)
492 NeedsAttentionSnapshotStore.clear(accountID: nil)
493 SystemStatusWidgetSnapshotStore.clear(accountID: nil)
494 ActiveAccountContextStore.clear()
495 accounts = []
496 }
497
498 private func prioritizedAccounts(_ accounts: [AccountEntry], preferredID: String) -> [AccountEntry] {
499 guard let preferred = accounts.first(where: { $0.id == preferredID }) else { return accounts }
500 return [preferred] + accounts.filter { $0.id != preferredID }
501 }
502
413503 private func refreshNeedsAttentionSnapshot() async {
414504 guard let currentUser else {
415 NeedsAttentionSnapshotStore.clear()
505 NeedsAttentionSnapshotStore.clear(accountID: activeAccountID)
416506 return
417507 }
418508
419509 let viewModel = HomeViewModel(
420510 currentUser: currentUser,
421511 client: client,
422 systemStatusRepository: systemStatusRepository
512 systemStatusRepository: systemStatusRepository,
513 defaults: accountDefaults,
514 accountID: activeAccountID
423515 )
424516 await viewModel.loadDashboard()
425517 }
@@ -434,3 +526,14 @@ final class AppState {
434526 }
435527 }
436528}
529
530enum AppStateError: LocalizedError {
531 case duplicateAccount(username: String)
532
533 var errorDescription: String? {
534 switch self {
535 case .duplicateAccount(let username):
536 "The account ~\(username) is already saved."
537 }
538 }
539}
Hutch/App/HutchApp.swift +2 −2
@@ -4,8 +4,8 @@ import SwiftUI
44struct HutchApp: App {
55 @State private var appState = AppState()
66 @State private var networkMonitor = NetworkMonitor()
7 @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system
8 @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard
7 @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
8 @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
99
1010 var body: some Scene {
1111 WindowGroup {
Hutch/App/RootView.swift +14 −7
@@ -10,6 +10,7 @@ struct RootView: View {
1010 @State private var buildsPath = NavigationPath()
1111 @State private var ticketsPath = NavigationPath()
1212 @State private var isResolvingDeepLink = false
13 @State private var hasValidatedLaunch = false
1314
1415 var body: some View {
1516 @Bindable var appState = appState
@@ -17,8 +18,10 @@ struct RootView: View {
1718 Group {
1819 switch appState.authPhase {
1920 case .launching:
20 ProgressView("Connecting…")
21 ProgressView(appState.authStatusMessage)
2122 .task {
23 guard !hasValidatedLaunch else { return }
24 hasValidatedLaunch = true
2225 await appState.validateOnLaunch()
2326 }
2427
@@ -113,6 +116,8 @@ struct RootView: View {
113116 Label("More", systemImage: "ellipsis.circle")
114117 }
115118 }
119 .id(appState.sessionIdentity)
120 .defaultAppStorage(appState.accountDefaults)
116121 .modifier(SidebarAdaptableTabStyle())
117122 .modifier(TabKeyboardShortcuts(selectedTab: Binding(
118123 get: { appState.selectedTab },
@@ -315,6 +320,8 @@ enum MoreRoute: Hashable {
315320}
316321
317322private struct MoreNavigationRoot: View {
323 @Environment(AppState.self) private var appState
324
318325 var body: some View {
319326 MoreView()
320327 .navigationDestination(for: MoreRoute.self) { route in
@@ -339,16 +346,16 @@ private struct MoreNavigationRoot: View {
339346 ThreadDetailView(
340347 thread: thread,
341348 onViewed: {
342 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
343 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
349 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
350 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
344351 },
345352 onMarkRead: {
346 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
347 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
353 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
354 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
348355 },
349356 onMarkUnread: {
350 InboxReadStateStore.markUnread(for: thread.id)
351 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
357 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
358 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
352359 }
353360 )
354361 case .manPageBrowser:
Hutch/Hutch/App/AccountSession.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2
3struct AccountSession: Sendable {
4 let account: AccountEntry
5 let user: User
6 let client: SRHTClient
7 let defaults: UserDefaults
8 let systemStatusRepository: SystemStatusRepository
9
10 var id: String {
11 account.id
12 }
13}
14
15enum AccountDefaultsStore {
16 private static let suitePrefix = "net.cleberg.Hutch.account"
17
18 static func userDefaults(for accountID: String) -> UserDefaults {
19 let suiteName = suiteName(for: accountID)
20 return UserDefaults(suiteName: suiteName) ?? .standard
21 }
22
23 static func clear(accountID: String) {
24 let suiteName = suiteName(for: accountID)
25 guard let defaults = UserDefaults(suiteName: suiteName) else { return }
26 defaults.removePersistentDomain(forName: suiteName)
27 }
28
29 private static func suiteName(for accountID: String) -> String {
30 "\(suitePrefix).\(accountID)"
31 }
32}
Hutch/Views/Builds/BuildListView.swift +2 −2
@@ -1,7 +1,7 @@
11import SwiftUI
22
33struct BuildListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
55 @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0
66 @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = ""
77 @Environment(AppState.self) private var appState
@@ -104,7 +104,7 @@ struct BuildListView: View {
104104 }
105105 .task {
106106 if viewModel == nil {
107 let vm = BuildListViewModel(client: appState.client)
107 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults)
108108 vm.repoFilter = savedRepoFilter
109109 viewModel = vm
110110 await vm.loadJobs()
Hutch/Views/Home/HomeView.swift +5 −3
@@ -1,7 +1,7 @@
11import SwiftUI
22
33struct HomeView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
55 @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true
66 @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true
77 @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true
@@ -39,7 +39,9 @@ struct HomeView: View {
3939 let newViewModel = HomeViewModel(
4040 currentUser: currentUser,
4141 client: appState.client,
42 systemStatusRepository: appState.systemStatusRepository
42 systemStatusRepository: appState.systemStatusRepository,
43 defaults: appState.accountDefaults,
44 accountID: appState.activeAccountID
4345 )
4446 viewModel = newViewModel
4547 vm = newViewModel
@@ -834,7 +836,7 @@ private struct HomeAttentionLinkRow<Destination: View>: View {
834836
835837private struct HomeAssignedTicketsListView: View {
836838 let viewModel: HomeViewModel
837 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
839 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
838840
839841 var body: some View {
840842 List {
Hutch/Views/Home/HomeViewModel.swift +21 −8
@@ -321,11 +321,22 @@ final class HomeViewModel {
321321 }
322322 """
323323
324 init(currentUser: User, client: SRHTClient, systemStatusRepository: SystemStatusRepository) {
324 private let defaults: UserDefaults
325 private let accountID: String
326
327 init(
328 currentUser: User,
329 client: SRHTClient,
330 systemStatusRepository: SystemStatusRepository,
331 defaults: UserDefaults,
332 accountID: String
333 ) {
325334 self.currentUser = currentUser
326335 self.client = client
327336 self.systemStatusRepository = systemStatusRepository
328337 self.projectService = ProjectService(client: client)
338 self.defaults = defaults
339 self.accountID = accountID
329340 }
330341
331342 func loadDashboard() async {
@@ -403,7 +414,7 @@ final class HomeViewModel {
403414 }
404415
405416 var pinnedProjects: [Project] {
406 let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey)
417 let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults)
407418 guard !pinnedIDs.isEmpty else { return [] }
408419
409420 let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
@@ -411,7 +422,7 @@ final class HomeViewModel {
411422 }
412423
413424 var hasPinnedProjects: Bool {
414 !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey).isEmpty
425 !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty
415426 }
416427
417428 var failedBuildCount: Int {
@@ -592,7 +603,7 @@ final class HomeViewModel {
592603 }
593604
594605 func markInboxThreadRead(_ thread: InboxThreadSummary) {
595 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
606 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: defaults)
596607 unreadInboxThreads.removeAll { $0.id == thread.id }
597608 unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
598609 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
@@ -600,7 +611,7 @@ final class HomeViewModel {
600611 }
601612
602613 func markInboxThreadUnread(_ thread: InboxThreadSummary) {
603 InboxReadStateStore.markUnread(for: thread.id)
614 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
604615 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
605616 unreadInboxThreads.append(
606617 InboxThreadSummary(
@@ -786,7 +797,8 @@ final class HomeViewModel {
786797 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
787798 isUnread: InboxReadStateStore.isUnread(
788799 threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
789 lastActivityAt: thread.updated
800 lastActivityAt: thread.updated,
801 defaults: defaults
790802 )
791803 )
792804 return summary.isUnread ? summary : nil
@@ -944,7 +956,8 @@ final class HomeViewModel {
944956 }.count
945957 : nil,
946958 updatedAt: .now
947 )
959 ),
960 accountID: accountID
948961 )
949962 }
950963
@@ -966,7 +979,7 @@ final class HomeViewModel {
966979 bannerSummary: snapshot.bannerSummary,
967980 updatedAt: .now
968981 )
969 SystemStatusWidgetSnapshotStore.save(widgetSnapshot)
982 SystemStatusWidgetSnapshotStore.save(widgetSnapshot, accountID: accountID)
970983 }
971984
972985 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
Hutch/Views/Inbox/InboxView.swift +5 −1
@@ -22,7 +22,11 @@ struct InboxView: View {
2222 if let viewModel {
2323 vm = viewModel
2424 } else {
25 let newViewModel = InboxViewModel(client: appState.client)
25 let newViewModel = InboxViewModel(
26 client: appState.client,
27 defaults: appState.accountDefaults,
28 accountID: appState.activeAccountID
29 )
2630 viewModel = newViewModel
2731 vm = newViewModel
2832 }
Hutch/Views/Inbox/InboxViewModel.swift +13 −9
@@ -72,6 +72,8 @@ final class InboxViewModel {
7272 var searchText = ""
7373
7474 private let client: SRHTClient
75 private let defaults: UserDefaults
76 private let accountID: String
7577 private let listThreadFetchLimit = 10
7678 private let listFetchConcurrencyLimit = 4
7779
@@ -121,8 +123,10 @@ final class InboxViewModel {
121123 }
122124 """
123125
124 init(client: SRHTClient) {
126 init(client: SRHTClient, defaults: UserDefaults, accountID: String) {
125127 self.client = client
128 self.defaults = defaults
129 self.accountID = accountID
126130 }
127131
128132 func loadThreads() async {
@@ -143,7 +147,7 @@ final class InboxViewModel {
143147 }
144148 return lhs.lastActivityAt > rhs.lastActivityAt
145149 }
146 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
150 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
147151 } catch {
148152 inboxListLogger.error("Inbox request failed")
149153 self.error = "Failed to load inbox"
@@ -152,9 +156,9 @@ final class InboxViewModel {
152156
153157 func markThreadRead(_ thread: InboxThreadSummary) {
154158 let viewedAt = max(Date(), thread.lastActivityAt)
155 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
159 InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
156160 threads.removeAll { $0.id == thread.id }
157 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
161 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
158162 }
159163
160164 func markAllThreadsRead() {
@@ -162,17 +166,17 @@ final class InboxViewModel {
162166
163167 let viewedAt = Date()
164168 for thread in threads where thread.isUnread {
165 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id)
169 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
166170 }
167171
168172 threads.removeAll { $0.isUnread }
169 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
173 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
170174 }
171175
172176 func markThreadUnread(_ thread: InboxThreadSummary) {
173 InboxReadStateStore.markUnread(for: thread.id)
177 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
174178 updateThread(thread, isUnread: true)
175 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
179 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
176180 }
177181
178182 func toggleThreadReadState(_ thread: InboxThreadSummary) {
@@ -291,7 +295,7 @@ final class InboxViewModel {
291295
292296 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
293297 let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
294 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
298 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated, defaults: defaults)
295299 return InboxThreadSummary(
296300 rootEmailID: thread.root.id,
297301 rootMessageID: thread.root.messageID,
Hutch/Views/Lookup/LookupView.swift +7 −7
@@ -339,7 +339,7 @@ struct LookupView: View {
339339 .navigationTitle("Look Up")
340340 .task {
341341 if viewModel == nil {
342 viewModel = LookupViewModel(client: appState.client, appState: appState)
342 viewModel = LookupViewModel(client: appState.client, appState: appState, defaults: appState.accountDefaults)
343343 }
344344 }
345345 }
@@ -440,16 +440,16 @@ struct LookupView: View {
440440 ThreadDetailView(
441441 thread: thread,
442442 onViewed: {
443 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
444 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
443 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
444 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
445445 },
446446 onMarkRead: {
447 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
448 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
447 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
448 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
449449 },
450450 onMarkUnread: {
451 InboxReadStateStore.markUnread(for: thread.id)
452 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
451 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
452 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
453453 }
454454 )
455455 case .manPageBrowser:
Hutch/Views/Lookup/UserProfileView.swift +1 −1
@@ -2,7 +2,7 @@ import SwiftUI
22
33struct UserProfileView: View {
44 @Environment(AppState.self) private var appState
5 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
5 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
66
77 let user: User
88 @State private var profileViewModel: UserProfileViewModel?
Hutch/Views/More/AccountSwitcherView.swift +42 −7
@@ -7,21 +7,35 @@ struct AccountSwitcherView: View {
77 @State private var showAddAccount = false
88 @State private var isSwitching = false
99 @State private var switchError: String?
10 @State private var pendingRemoval: AccountEntry?
1011
1112 var body: some View {
1213 NavigationStack {
1314 List {
1415 Section {
1516 ForEach(appState.accounts) { account in
17 let isActive = account.id == appState.activeAccountID
1618 Button {
17 guard account.id != appState.activeAccountID else { return }
19 guard !isActive else { return }
1820 switchTo(account)
1921 } label: {
20 HStack {
21 Text("~\(account.username)")
22 .foregroundStyle(.primary)
22 HStack(spacing: 12) {
23 if isActive {
24 Image(systemName: "person.crop.circle.fill")
25 .foregroundStyle(.tint)
26 } else {
27 Image(systemName: "person.crop.circle")
28 .foregroundStyle(.secondary)
29 }
30 VStack(alignment: .leading, spacing: 2) {
31 Text("~\(account.username)")
32 .foregroundStyle(.primary)
33 Text(isActive ? "Active Account" : "Tap to switch")
34 .font(.caption)
35 .foregroundStyle(.secondary)
36 }
2337 Spacer()
24 if account.id == appState.activeAccountID {
38 if isActive {
2539 Image(systemName: "checkmark")
2640 .foregroundStyle(.tint)
2741 }
@@ -31,8 +45,7 @@ struct AccountSwitcherView: View {
3145 }
3246 .onDelete { indexSet in
3347 for index in indexSet {
34 let account = appState.accounts[index]
35 Task { await appState.removeAccount(id: account.id) }
48 pendingRemoval = appState.accounts[index]
3649 }
3750 }
3851 }
@@ -71,6 +84,28 @@ struct AccountSwitcherView: View {
7184 } message: {
7285 Text(switchError ?? "")
7386 }
87 .alert(
88 "Remove Account?",
89 isPresented: Binding(
90 get: { pendingRemoval != nil },
91 set: { isPresented in
92 if !isPresented {
93 pendingRemoval = nil
94 }
95 }
96 )
97 ) {
98 Button("Cancel", role: .cancel) {}
99 Button("Remove", role: .destructive) {
100 guard let pendingRemoval else { return }
101 Task { await appState.removeAccount(id: pendingRemoval.id) }
102 self.pendingRemoval = nil
103 }
104 } message: {
105 if let pendingRemoval {
106 Text("~\(pendingRemoval.username) and its isolated local cache will be removed from this device.")
107 }
108 }
74109 .sheet(isPresented: $showAddAccount) {
75110 AddAccountView()
76111 }
Hutch/Views/More/ProfileView.swift +1 −1
@@ -7,7 +7,7 @@ private let profileBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
77
88struct ProfileView: View {
99 @Environment(AppState.self) private var appState
10 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
10 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
1111 @State private var viewModel: SettingsViewModel?
1212 @State private var contributionViewModel: UserProfileViewModel?
1313 @State private var pendingDestructiveAction: ProfileDestructiveAction?
Hutch/Views/Pastes/PasteListView.swift +1 −1
@@ -1,7 +1,7 @@
11import SwiftUI
22
33struct PasteListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
55 @Environment(AppState.self) private var appState
66 @State private var viewModel: PasteListViewModel?
77 @State private var showCreatePasteSheet = false
Hutch/Views/Projects/ProjectDetailView.swift +2 −2
@@ -22,7 +22,7 @@ struct ProjectDetailView: View {
2222 private var isPinnedToHome: Bool {
2323 _ = pinChangeCount
2424 guard let currentUserKey else { return false }
25 return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey)
25 return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
2626 }
2727
2828 var body: some View {
@@ -243,7 +243,7 @@ struct ProjectDetailView: View {
243243
244244 private func togglePinnedState() {
245245 guard let currentUserKey else { return }
246 ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey)
246 ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
247247 pinChangeCount += 1
248248 }
249249
Hutch/Views/Projects/ProjectMailingListView.swift +22 −13
@@ -36,6 +36,8 @@ final class MailingListDetailViewModel {
3636
3737 private let mailingList: InboxMailingListReference
3838 private let client: SRHTClient
39 private let defaults: UserDefaults
40 private let accountID: String
3941
4042 private static let listThreadsQuery = """
4143 query projectMailingListThreads($rid: ID!) {
@@ -57,9 +59,11 @@ final class MailingListDetailViewModel {
5759 }
5860 """
5961
60 init(mailingList: InboxMailingListReference, client: SRHTClient) {
62 init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) {
6163 self.mailingList = mailingList
6264 self.client = client
65 self.defaults = defaults
66 self.accountID = accountID
6367 }
6468
6569 var filteredThreads: [InboxThreadSummary] {
@@ -90,15 +94,15 @@ final class MailingListDetailViewModel {
9094
9195 func markThreadRead(_ thread: InboxThreadSummary) {
9296 let viewedAt = max(Date(), thread.lastActivityAt)
93 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
97 InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
9498 updateThread(thread, isUnread: false)
95 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
99 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
96100 }
97101
98102 func markThreadUnread(_ thread: InboxThreadSummary) {
99 InboxReadStateStore.markUnread(for: thread.id)
103 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
100104 updateThread(thread, isUnread: true)
101 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
105 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
102106 }
103107
104108 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
@@ -124,7 +128,7 @@ final class MailingListDetailViewModel {
124128 messageCount: thread.replies + 1,
125129 repo: nil,
126130 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
127 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
131 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated, defaults: defaults)
128132 )
129133 }
130134
@@ -238,7 +242,12 @@ struct MailingListDetailView: View {
238242 .navigationBarTitleDisplayMode(.inline)
239243 .task {
240244 if viewModel == nil {
241 let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client)
245 let viewModel = MailingListDetailViewModel(
246 mailingList: mailingList,
247 client: appState.client,
248 defaults: appState.accountDefaults,
249 accountID: appState.activeAccountID
250 )
242251 self.viewModel = viewModel
243252 await viewModel.loadThreads()
244253 }
@@ -261,16 +270,16 @@ struct MailingListDetailView: View {
261270 ThreadDetailView(
262271 thread: thread,
263272 onViewed: {
264 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
265 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
273 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
274 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
266275 },
267276 onMarkRead: {
268 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
269 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
277 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
278 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
270279 },
271280 onMarkUnread: {
272 InboxReadStateStore.markUnread(for: thread.id)
273 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
281 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
282 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
274283 }
275284 )
276285 } label: {
Hutch/Views/Repositories/RepositoryListView.swift +1 −1
@@ -59,7 +59,7 @@ struct RepositoryListView: View {
5959 }
6060 .task {
6161 if viewModel == nil {
62 viewModel = RepositoryListViewModel(client: appState.client)
62 viewModel = RepositoryListViewModel(client: appState.client, defaults: appState.accountDefaults)
6363 }
6464 }
6565 }
Hutch/Views/Settings/SettingsView.swift +24 −6
@@ -2,10 +2,10 @@ import SwiftUI
22
33struct SettingsView: View {
44 @Environment(AppState.self) private var appState
5 @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system
6 @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard
7 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
8 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
5 @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
6 @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
7 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
8 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
99 @State private var pendingDestructiveAction: SettingsDestructiveAction?
1010
1111 var body: some View {
@@ -89,7 +89,25 @@ struct SettingsView: View {
8989 HStack {
9090 Image(systemName: "key.fill")
9191 .foregroundStyle(.secondary)
92 Text("Personal access token in use")
92 VStack(alignment: .leading, spacing: 2) {
93 Text(appState.currentUser?.canonicalName ?? "No active account")
94 Text("\(appState.accounts.count) saved account\(appState.accounts.count == 1 ? "" : "s")")
95 .font(.caption)
96 .foregroundStyle(.secondary)
97 }
98 }
99 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
100
101 NavigationLink {
102 AccountSwitcherView()
103 } label: {
104 Label("Manage Accounts", systemImage: "person.2")
105 }
106
107 HStack {
108 Image(systemName: "lock.shield")
109 .foregroundStyle(.secondary)
110 Text("Tokens are stored separately per account in the iOS keychain")
93111 .font(.subheadline)
94112 .foregroundStyle(.secondary)
95113 }
@@ -105,7 +123,7 @@ struct SettingsView: View {
105123 } header: {
106124 Text("Authentication")
107125 } footer: {
108 Text("Hutch stores your SourceHut token in the iOS keychain. Reset App Data removes saved token data, local settings, cached responses, cookies, and embedded web data on this device.")
126 Text("Account switching keeps local caches and saved state isolated per account. Sign Out removes all saved accounts from this device. Reset App Data also clears local settings, cached responses, cookies, and embedded web data.")
109127 }
110128 }
111129
Hutch/Views/Tickets/TicketListView.swift +3 −2
@@ -4,7 +4,7 @@ struct TicketListView: View {
44 let onTrackerUpdated: (TrackerSummary) -> Void
55 let onTrackerDeleted: (TrackerSummary) -> Void
66
7 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
7 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
88 @Environment(AppState.self) private var appState
99 @Environment(\.dismiss) private var dismiss
1010 @State private var tracker: TrackerSummary
@@ -232,7 +232,8 @@ struct TicketListView: View {
232232 trackerName: tracker.name,
233233 trackerId: tracker.id,
234234 trackerRid: tracker.rid,
235 client: appState.client
235 client: appState.client,
236 defaults: appState.accountDefaults
236237 )
237238 viewModel = vm
238239 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
HutchTests/HutchIntentsTests.swift +29
@@ -72,4 +72,33 @@ struct HutchIntentsTests {
7272 #expect(loaded?.unreadInboxThreads == 5)
7373 #expect(loaded?.assignedOpenTickets == 3)
7474 }
75
76 @Test
77 func needsAttentionSnapshotsAreIsolatedPerAccount() {
78 let defaultsName = "HutchIntentsTests-builds-isolation-\(UUID().uuidString)"
79 let defaults = UserDefaults(suiteName: defaultsName)!
80 defer { defaults.removePersistentDomain(forName: defaultsName) }
81
82 let firstSnapshot = NeedsAttentionSnapshot(
83 unreadInboxThreads: 1,
84 assignedOpenTickets: 2,
85 failedBuilds: 3,
86 updatedAt: Date(timeIntervalSince1970: 100)
87 )
88 let secondSnapshot = NeedsAttentionSnapshot(
89 unreadInboxThreads: 8,
90 assignedOpenTickets: 5,
91 failedBuilds: 1,
92 updatedAt: Date(timeIntervalSince1970: 200)
93 )
94
95 NeedsAttentionSnapshotStore.save(firstSnapshot, accountID: "account-a", defaults: defaults)
96 NeedsAttentionSnapshotStore.save(secondSnapshot, accountID: "account-b", defaults: defaults)
97
98 #expect(NeedsAttentionSnapshotStore.load(accountID: "account-a", defaults: defaults)?.unreadInboxThreads == 1)
99 #expect(NeedsAttentionSnapshotStore.load(accountID: "account-b", defaults: defaults)?.unreadInboxThreads == 8)
100
101 ActiveAccountContextStore.save("account-a", defaults: defaults)
102 #expect(NeedsAttentionSnapshotStore.load(defaults: defaults)?.failedBuilds == 3)
103 }
75104}
HutchTests/SystemStatusWidgetSnapshotTests.swift +31
@@ -64,6 +64,37 @@ struct SystemStatusWidgetSnapshotTests {
6464 #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil)
6565 }
6666
67 @Test
68 func snapshotsAreIsolatedPerAccount() {
69 let defaultsName = "SystemStatusWidgetSnapshotTests-isolation-\(UUID().uuidString)"
70 let defaults = UserDefaults(suiteName: defaultsName)!
71 defer { defaults.removePersistentDomain(forName: defaultsName) }
72
73 let firstSnapshot = SystemStatusWidgetSnapshot(
74 services: [.init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false)],
75 hasDisruption: false,
76 overallStatusText: "All monitored services operational",
77 bannerSummary: "",
78 updatedAt: Date(timeIntervalSince1970: 100)
79 )
80 let secondSnapshot = SystemStatusWidgetSnapshot(
81 services: [.init(id: "builds", name: "builds.sr.ht", status: "Degraded", requiresAttention: true)],
82 hasDisruption: true,
83 overallStatusText: "Experiencing disruptions",
84 bannerSummary: "builds.sr.ht disrupted",
85 updatedAt: Date(timeIntervalSince1970: 200)
86 )
87
88 SystemStatusWidgetSnapshotStore.save(firstSnapshot, accountID: "account-a", defaults: defaults)
89 SystemStatusWidgetSnapshotStore.save(secondSnapshot, accountID: "account-b", defaults: defaults)
90
91 #expect(SystemStatusWidgetSnapshotStore.load(accountID: "account-a", defaults: defaults)?.services.first?.name == "git.sr.ht")
92 #expect(SystemStatusWidgetSnapshotStore.load(accountID: "account-b", defaults: defaults)?.services.first?.name == "builds.sr.ht")
93
94 ActiveAccountContextStore.save("account-b", defaults: defaults)
95 #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults)?.bannerSummary == "builds.sr.ht disrupted")
96 }
97
6798 @Test
6899 func unavailableSnapshotHasEmptyServices() {
69100 let snapshot = SystemStatusWidgetSnapshot.unavailable
Shared/ContributionWidgetContext.swift +21 −6
@@ -11,8 +11,11 @@ enum ContributionWidgetContextStore {
1111 private static let actorKey = "contributionWidget.actor"
1212 private static let enabledKey = "contributionWidget.enabled"
1313
14 static func loadActor(defaults: UserDefaults? = sharedDefaults()) -> String? {
15 defaults?.string(forKey: actorKey)
14 static func loadActor(
15 accountID: String? = ActiveAccountContextStore.load(),
16 defaults: UserDefaults? = sharedDefaults()
17 ) -> String? {
18 defaults?.string(forKey: scopedActorKey(for: accountID))
1619 }
1720
1821 static func isEnabled(defaults: UserDefaults? = sharedDefaults()) -> Bool {
@@ -24,13 +27,20 @@ enum ContributionWidgetContextStore {
2427 reloadWidgetTimelines()
2528 }
2629
27 static func saveActor(_ actor: String, defaults: UserDefaults? = sharedDefaults()) {
28 defaults?.set(actor, forKey: actorKey)
30 static func saveActor(
31 _ actor: String,
32 accountID: String? = ActiveAccountContextStore.load(),
33 defaults: UserDefaults? = sharedDefaults()
34 ) {
35 defaults?.set(actor, forKey: scopedActorKey(for: accountID))
2936 reloadWidgetTimelines()
3037 }
3138
32 static func clear(defaults: UserDefaults? = sharedDefaults()) {
33 defaults?.removeObject(forKey: actorKey)
39 static func clear(
40 accountID: String? = ActiveAccountContextStore.load(),
41 defaults: UserDefaults? = sharedDefaults()
42 ) {
43 defaults?.removeObject(forKey: scopedActorKey(for: accountID))
3444 reloadWidgetTimelines()
3545 }
3646
@@ -38,6 +48,11 @@ enum ContributionWidgetContextStore {
3848 UserDefaults(suiteName: HutchAppGroup.identifier)
3949 }
4050
51 private static func scopedActorKey(for accountID: String?) -> String {
52 guard let accountID, !accountID.isEmpty else { return actorKey }
53 return "\(actorKey).\(accountID)"
54 }
55
4156 private static func reloadWidgetTimelines() {
4257 #if canImport(WidgetKit)
4358 WidgetCenter.shared.reloadTimelines(ofKind: ContributionGraphWidgetConfiguration.kind)
Shared/NeedsAttentionSnapshot.swift +47 −9
@@ -7,6 +7,26 @@ enum HutchAppGroup {
77 static let identifier = "group.net.cleberg.Hutch"
88}
99
10enum ActiveAccountContextStore {
11 private static let activeAccountIDKey = "activeAccount.id"
12
13 static func load(defaults: UserDefaults? = sharedDefaults()) -> String? {
14 defaults?.string(forKey: activeAccountIDKey)
15 }
16
17 static func save(_ accountID: String, defaults: UserDefaults? = sharedDefaults()) {
18 defaults?.set(accountID, forKey: activeAccountIDKey)
19 }
20
21 static func clear(defaults: UserDefaults? = sharedDefaults()) {
22 defaults?.removeObject(forKey: activeAccountIDKey)
23 }
24
25 private static func sharedDefaults() -> UserDefaults? {
26 UserDefaults(suiteName: HutchAppGroup.identifier)
27 }
28}
29
1030enum NeedsAttentionWidgetConfiguration {
1131 static let kind = "NeedsAttentionWidget"
1232}
@@ -37,22 +57,29 @@ struct NeedsAttentionSnapshot: Codable, Sendable {
3757enum NeedsAttentionSnapshotStore {
3858 private static let snapshotKey = "needsAttention.snapshot"
3959
40 static func load(defaults: UserDefaults? = sharedDefaults()) -> NeedsAttentionSnapshot? {
60 static func load(
61 accountID: String? = ActiveAccountContextStore.load(),
62 defaults: UserDefaults? = sharedDefaults()
63 ) -> NeedsAttentionSnapshot? {
4164 guard let defaults,
42 let data = defaults.data(forKey: snapshotKey) else {
65 let data = defaults.data(forKey: scopedKey(for: accountID)) else {
4366 return nil
4467 }
4568
4669 return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data)
4770 }
4871
49 static func save(_ snapshot: NeedsAttentionSnapshot, defaults: UserDefaults? = sharedDefaults()) {
72 static func save(
73 _ snapshot: NeedsAttentionSnapshot,
74 accountID: String? = ActiveAccountContextStore.load(),
75 defaults: UserDefaults? = sharedDefaults()
76 ) {
5077 guard let defaults,
5178 let data = try? JSONEncoder().encode(snapshot) else {
5279 return
5380 }
5481
55 defaults.set(data, forKey: snapshotKey)
82 defaults.set(data, forKey: scopedKey(for: accountID))
5683 reloadWidgetTimelines()
5784 }
5885
@@ -60,23 +87,25 @@ enum NeedsAttentionSnapshotStore {
6087 unreadInboxThreads: Int? = nil,
6188 assignedOpenTickets: Int? = nil,
6289 failedBuilds: Int? = nil,
90 accountID: String? = ActiveAccountContextStore.load(),
6391 defaults: UserDefaults? = sharedDefaults()
6492 ) {
65 let existing = load(defaults: defaults)
93 let existing = load(accountID: accountID, defaults: defaults)
6694 let snapshot = NeedsAttentionSnapshot(
6795 unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads,
6896 assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets,
6997 failedBuilds: failedBuilds ?? existing?.failedBuilds,
7098 updatedAt: .now
7199 )
72 save(snapshot, defaults: defaults)
100 save(snapshot, accountID: accountID, defaults: defaults)
73101 }
74102
75103 static func adjustUnreadInboxThreads(
76104 by delta: Int,
105 accountID: String? = ActiveAccountContextStore.load(),
77106 defaults: UserDefaults? = sharedDefaults()
78107 ) {
79 guard let existing = load(defaults: defaults),
108 guard let existing = load(accountID: accountID, defaults: defaults),
80109 let unreadInboxThreads = existing.unreadInboxThreads else {
81110 return
82111 }
@@ -88,12 +117,16 @@ enum NeedsAttentionSnapshotStore {
88117 failedBuilds: existing.failedBuilds,
89118 updatedAt: .now
90119 ),
120 accountID: accountID,
91121 defaults: defaults
92122 )
93123 }
94124
95 static func clear(defaults: UserDefaults? = sharedDefaults()) {
96 defaults?.removeObject(forKey: snapshotKey)
125 static func clear(
126 accountID: String? = ActiveAccountContextStore.load(),
127 defaults: UserDefaults? = sharedDefaults()
128 ) {
129 defaults?.removeObject(forKey: scopedKey(for: accountID))
97130 reloadWidgetTimelines()
98131 }
99132
@@ -101,6 +134,11 @@ enum NeedsAttentionSnapshotStore {
101134 UserDefaults(suiteName: HutchAppGroup.identifier)
102135 }
103136
137 private static func scopedKey(for accountID: String?) -> String {
138 guard let accountID, !accountID.isEmpty else { return snapshotKey }
139 return "\(snapshotKey).\(accountID)"
140 }
141
104142 private static func reloadWidgetTimelines() {
105143 #if canImport(WidgetKit)
106144 WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind)
Shared/SystemStatusWidgetSnapshot.swift +21 −6
@@ -33,25 +33,35 @@ struct SystemStatusWidgetSnapshot: Codable, Sendable {
3333enum SystemStatusWidgetSnapshotStore {
3434 private static let snapshotKey = "systemStatus.widgetSnapshot"
3535
36 static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? {
36 static func load(
37 accountID: String? = ActiveAccountContextStore.load(),
38 defaults: UserDefaults? = sharedDefaults()
39 ) -> SystemStatusWidgetSnapshot? {
3740 guard let defaults,
38 let data = defaults.data(forKey: snapshotKey) else {
41 let data = defaults.data(forKey: scopedKey(for: accountID)) else {
3942 return nil
4043 }
4144 return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data)
4245 }
4346
44 static func save(_ snapshot: SystemStatusWidgetSnapshot, defaults: UserDefaults? = sharedDefaults()) {
47 static func save(
48 _ snapshot: SystemStatusWidgetSnapshot,
49 accountID: String? = ActiveAccountContextStore.load(),
50 defaults: UserDefaults? = sharedDefaults()
51 ) {
4552 guard let defaults,
4653 let data = try? JSONEncoder().encode(snapshot) else {
4754 return
4855 }
49 defaults.set(data, forKey: snapshotKey)
56 defaults.set(data, forKey: scopedKey(for: accountID))
5057 reloadWidgetTimelines()
5158 }
5259
53 static func clear(defaults: UserDefaults? = sharedDefaults()) {
54 defaults?.removeObject(forKey: snapshotKey)
60 static func clear(
61 accountID: String? = ActiveAccountContextStore.load(),
62 defaults: UserDefaults? = sharedDefaults()
63 ) {
64 defaults?.removeObject(forKey: scopedKey(for: accountID))
5565 reloadWidgetTimelines()
5666 }
5767
@@ -59,6 +69,11 @@ enum SystemStatusWidgetSnapshotStore {
5969 UserDefaults(suiteName: HutchAppGroup.identifier)
6070 }
6171
72 private static func scopedKey(for accountID: String?) -> String {
73 guard let accountID, !accountID.isEmpty else { return snapshotKey }
74 return "\(snapshotKey).\(accountID)"
75 }
76
6277 private static func reloadWidgetTimelines() {
6378 #if canImport(WidgetKit)
6479 WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind)