Commit 899abef569
Unsigned
Layout: unified · split
Hutch/App/AppState.swift +179 −76
| @@ -35,6 +35,7 @@ final class AppState { | |||
| 35 | // MARK: - Authentication | 35 | // MARK: - Authentication |
| 36 | 36 | ||
| 37 | private(set) var authPhase: AuthPhase = .launching | 37 | private(set) var authPhase: AuthPhase = .launching |
| 38 | private(set) var authStatusMessage = "Connecting…" | ||
| 38 | 39 | ||
| 39 | /// Convenience for views that need a simple bool. | 40 | /// Convenience for views that need a simple bool. |
| 40 | var isAuthenticated: Bool { | 41 | var isAuthenticated: Bool { |
| @@ -57,9 +58,15 @@ final class AppState { | |||
| 57 | 58 | ||
| 58 | // MARK: - Networking | 59 | // MARK: - Networking |
| 59 | 60 | ||
| 60 | let client: SRHTClient | 61 | private(set) var client: SRHTClient |
| 61 | let configuration: AppConfiguration | 62 | 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 | } | ||
| 63 | 70 | ||
| 64 | // MARK: - Deep link pending navigation | 71 | // MARK: - Deep link pending navigation |
| 65 | 72 | ||
| @@ -72,8 +79,7 @@ final class AppState { | |||
| 72 | 79 | ||
| 73 | init() { | 80 | init() { |
| 74 | self.configuration = AppConfiguration() | 81 | self.configuration = AppConfiguration() |
| 75 | let token = KeychainHelper.loadToken() | 82 | self.client = SRHTClient() |
| 76 | self.client = SRHTClient(token: token) | ||
| 77 | self.systemStatusRepository = SystemStatusRepository() | 83 | self.systemStatusRepository = SystemStatusRepository() |
| 78 | } | 84 | } |
| 79 | 85 | ||
| @@ -82,18 +88,18 @@ final class AppState { | |||
| 82 | /// Called once at app launch. If a token exists in Keychain, validates it | 88 | /// Called once at app launch. If a token exists in Keychain, validates it |
| 83 | /// silently. On failure, clears the token and falls through to unauthenticated. | 89 | /// silently. On failure, clears the token and falls through to unauthenticated. |
| 84 | func validateOnLaunch() async { | 90 | func validateOnLaunch() async { |
| 91 | authStatusMessage = "Connecting…" | ||
| 85 | var storedAccounts = KeychainHelper.loadAccounts() | 92 | var storedAccounts = KeychainHelper.loadAccounts() |
| 86 | 93 | ||
| 87 | if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() { | 94 | if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() { |
| 88 | client.setToken(legacyToken) | 95 | let legacyClient = SRHTClient(token: legacyToken) |
| 89 | if let user = try? await fetchMe() { | 96 | if let user = try? await fetchMe(using: legacyClient) { |
| 90 | let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken) | 97 | let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken) |
| 91 | storedAccounts = [entry] | 98 | storedAccounts = [entry] |
| 92 | try? KeychainHelper.saveAccounts(storedAccounts) | 99 | try? KeychainHelper.saveAccounts(storedAccounts) |
| 93 | try? KeychainHelper.deleteToken() | 100 | try? KeychainHelper.deleteToken() |
| 94 | } else { | 101 | } else { |
| 95 | try? KeychainHelper.deleteToken() | 102 | try? KeychainHelper.deleteToken() |
| 96 | client.setToken(nil) | ||
| 97 | authPhase = .unauthenticated | 103 | authPhase = .unauthenticated |
| 98 | return | 104 | return |
| 99 | } | 105 | } |
| @@ -105,24 +111,29 @@ final class AppState { | |||
| 105 | } | 111 | } |
| 106 | 112 | ||
| 107 | let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" | 113 | let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" |
| 108 | let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0] | 114 | let orderedAccounts = prioritizedAccounts(storedAccounts, preferredID: savedID) |
| 109 | 115 | var invalidIDs = Set<String>() | |
| 110 | client.setToken(target.token) | 116 | |
| 111 | do { | 117 | for account in orderedAccounts { |
| 112 | let user = try await fetchMe() | 118 | do { |
| 113 | accounts = storedAccounts | 119 | let session = try await makeSession(for: account) |
| 114 | activeAccountID = target.id | 120 | let filteredAccounts = storedAccounts.filter { !invalidIDs.contains($0.id) } |
| 115 | currentUser = user | 121 | accounts = filteredAccounts |
| 116 | ContributionWidgetContextStore.saveActor(user.canonicalName) | 122 | try? KeychainHelper.saveAccounts(filteredAccounts) |
| 117 | authPhase = .authenticated | 123 | activate(session) |
| 118 | await refreshNeedsAttentionSnapshot() | 124 | authPhase = .authenticated |
| 119 | } catch { | 125 | await refreshNeedsAttentionSnapshot() |
| 120 | client.setToken(nil) | 126 | return |
| 121 | currentUser = nil | 127 | } catch { |
| 122 | ContributionWidgetContextStore.clear() | 128 | invalidIDs.insert(account.id) |
| 123 | authPhase = .unauthenticated | 129 | clearAccountArtifacts(for: account.id) |
| 124 | NeedsAttentionSnapshotStore.clear() | 130 | } |
| 125 | } | 131 | } |
| 132 | |||
| 133 | accounts = storedAccounts.filter { !invalidIDs.contains($0.id) } | ||
| 134 | try? KeychainHelper.saveAccounts(accounts) | ||
| 135 | clearActiveSessionState() | ||
| 136 | authPhase = .unauthenticated | ||
| 126 | } | 137 | } |
| 127 | 138 | ||
| 128 | // MARK: - Token management | 139 | // MARK: - Token management |
| @@ -130,54 +141,40 @@ final class AppState { | |||
| 130 | /// Validate a new token by querying meta.sr.ht, then persist it. | 141 | /// Validate a new token by querying meta.sr.ht, then persist it. |
| 131 | /// Throws on network/GraphQL errors so the caller can display the message. | 142 | /// Throws on network/GraphQL errors so the caller can display the message. |
| 132 | func connect(with token: String) async throws { | 143 | func connect(with token: String) async throws { |
| 133 | client.setToken(token) | 144 | let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) |
| 134 | do { | 145 | try await addValidatedAccount(token: normalizedToken, activateNewAccount: true) |
| 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 | } | ||
| 149 | } | 146 | } |
| 150 | 147 | ||
| 151 | /// Validate a new token, add it as an account, and switch to it immediately. | 148 | /// Validate a new token, add it as an account, and switch to it immediately. |
| 152 | func addAccount(token: String) async throws { | 149 | func addAccount(token: String) async throws { |
| 153 | let tempClient = SRHTClient(token: token) | 150 | let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) |
| 154 | let user = try await fetchMe(using: tempClient) | 151 | try await addValidatedAccount(token: normalizedToken, activateNewAccount: true) |
| 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) | ||
| 159 | } | 152 | } |
| 160 | 153 | ||
| 161 | /// Switch the active account and fully refresh the app. | 154 | /// Switch the active account and fully refresh the app. |
| 162 | func switchAccount(to id: String) async throws { | 155 | func switchAccount(to id: String) async throws { |
| 163 | guard let entry = accounts.first(where: { $0.id == id }) else { return } | 156 | guard let entry = accounts.first(where: { $0.id == id }) else { return } |
| 157 | let previousSession = activeSession | ||
| 164 | 158 | ||
| 165 | client.responseCache.clear() | 159 | authStatusMessage = "Switching Accounts…" |
| 166 | currentUser = nil | 160 | authPhase = .launching |
| 167 | pendingDeepLink = nil | 161 | sessionIdentity = UUID() |
| 168 | pendingTabNavigation = nil | ||
| 169 | deepLinkError = nil | ||
| 170 | selectedTab = .home | ||
| 171 | |||
| 172 | authPhase = .unauthenticated | ||
| 173 | 162 | ||
| 174 | client.setToken(entry.token) | 163 | do { |
| 175 | activeAccountID = entry.id | 164 | let session = try await makeSession(for: entry) |
| 176 | UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) | 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 | } | ||
| 177 | 177 | ||
| 178 | let user = try await fetchMe() | ||
| 179 | currentUser = user | ||
| 180 | ContributionWidgetContextStore.saveActor(user.canonicalName) | ||
| 181 | authPhase = .authenticated | 178 | authPhase = .authenticated |
| 182 | await refreshNeedsAttentionSnapshot() | 179 | await refreshNeedsAttentionSnapshot() |
| 183 | } | 180 | } |
| @@ -185,42 +182,51 @@ final class AppState { | |||
| 185 | /// Remove a stored account. Switches to another account if the removed account | 182 | /// Remove a stored account. Switches to another account if the removed account |
| 186 | /// was active; signs out fully if it was the last account. | 183 | /// was active; signs out fully if it was the last account. |
| 187 | func removeAccount(id: String) async { | 184 | func removeAccount(id: String) async { |
| 185 | let removedWasActive = id == activeAccountID | ||
| 188 | accounts.removeAll { $0.id == id } | 186 | accounts.removeAll { $0.id == id } |
| 189 | try? KeychainHelper.saveAccounts(accounts) | 187 | try? KeychainHelper.saveAccounts(accounts) |
| 188 | clearAccountArtifacts(for: id) | ||
| 190 | 189 | ||
| 191 | guard id == activeAccountID else { return } | 190 | guard removedWasActive else { return } |
| 192 | 191 | ||
| 193 | if let next = accounts.first { | 192 | 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 | } | ||
| 195 | } else { | 198 | } else { |
| 196 | await signOut() | 199 | await signOut() |
| 197 | } | 200 | } |
| 198 | } | 201 | } |
| 199 | 202 | ||
| 200 | func signOut() async { | 203 | func signOut() async { |
| 201 | clearSessionState() | 204 | clearActiveSessionState() |
| 205 | try? KeychainHelper.deleteAll() | ||
| 202 | URLCache.shared.removeAllCachedResponses() | 206 | URLCache.shared.removeAllCachedResponses() |
| 203 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } | 207 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } |
| 204 | await clearWebData() | 208 | await clearWebData() |
| 205 | clearWebContentRenderCaches() | 209 | clearWebContentRenderCaches() |
| 206 | NeedsAttentionSnapshotStore.clear() | 210 | clearAllAccountArtifacts() |
| 207 | SystemStatusWidgetSnapshotStore.clear() | ||
| 208 | authPhase = .unauthenticated | 211 | authPhase = .unauthenticated |
| 209 | selectedTab = .home | 212 | selectedTab = .home |
| 210 | } | 213 | } |
| 211 | 214 | ||
| 212 | func resetAppData() async { | 215 | func resetAppData() async { |
| 213 | clearSessionState() | 216 | clearActiveSessionState() |
| 214 | 217 | ||
| 215 | if let bundleIdentifier = Bundle.main.bundleIdentifier { | 218 | if let bundleIdentifier = Bundle.main.bundleIdentifier { |
| 216 | UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier) | 219 | UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier) |
| 217 | } | 220 | } |
| 221 | for account in accounts { | ||
| 222 | AccountDefaultsStore.clear(accountID: account.id) | ||
| 223 | } | ||
| 224 | try? KeychainHelper.deleteAll() | ||
| 218 | URLCache.shared.removeAllCachedResponses() | 225 | URLCache.shared.removeAllCachedResponses() |
| 219 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } | 226 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } |
| 220 | await clearWebData() | 227 | await clearWebData() |
| 221 | clearWebContentRenderCaches() | 228 | clearWebContentRenderCaches() |
| 222 | NeedsAttentionSnapshotStore.clear() | 229 | clearAllAccountArtifacts() |
| 223 | SystemStatusWidgetSnapshotStore.clear() | ||
| 224 | 230 | ||
| 225 | authPhase = .unauthenticated | 231 | authPhase = .unauthenticated |
| 226 | selectedTab = .home | 232 | selectedTab = .home |
| @@ -395,31 +401,117 @@ final class AppState { | |||
| 395 | let tracker: TrackerSummary | 401 | let tracker: TrackerSummary |
| 396 | } | 402 | } |
| 397 | 403 | ||
| 398 | private func clearSessionState() { | 404 | private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws { |
| 399 | try? KeychainHelper.deleteAll() | 405 | let tempClient = SRHTClient(token: token) |
| 400 | client.setToken(nil) | 406 | let user = try await fetchMe(using: tempClient) |
| 401 | client.responseCache.clear() | 407 | |
| 402 | accounts = [] | 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 | ||
| 403 | activeAccountID = "" | 465 | activeAccountID = "" |
| 404 | UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID) | 466 | UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID) |
| 467 | ActiveAccountContextStore.clear() | ||
| 405 | currentUser = nil | 468 | currentUser = nil |
| 406 | ContributionWidgetContextStore.clear() | 469 | sessionIdentity = UUID() |
| 470 | resetNavigationState() | ||
| 471 | } | ||
| 472 | |||
| 473 | private func resetNavigationState() { | ||
| 407 | pendingDeepLink = nil | 474 | pendingDeepLink = nil |
| 408 | pendingTabNavigation = nil | 475 | pendingTabNavigation = nil |
| 409 | deepLinkError = nil | 476 | deepLinkError = nil |
| 410 | selectedTab = .home | 477 | selectedTab = .home |
| 411 | } | 478 | } |
| 412 | 479 | ||
| 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 | |||
| 413 | private func refreshNeedsAttentionSnapshot() async { | 503 | private func refreshNeedsAttentionSnapshot() async { |
| 414 | guard let currentUser else { | 504 | guard let currentUser else { |
| 415 | NeedsAttentionSnapshotStore.clear() | 505 | NeedsAttentionSnapshotStore.clear(accountID: activeAccountID) |
| 416 | return | 506 | return |
| 417 | } | 507 | } |
| 418 | 508 | ||
| 419 | let viewModel = HomeViewModel( | 509 | let viewModel = HomeViewModel( |
| 420 | currentUser: currentUser, | 510 | currentUser: currentUser, |
| 421 | client: client, | 511 | client: client, |
| 422 | systemStatusRepository: systemStatusRepository | 512 | systemStatusRepository: systemStatusRepository, |
| 513 | defaults: accountDefaults, | ||
| 514 | accountID: activeAccountID | ||
| 423 | ) | 515 | ) |
| 424 | await viewModel.loadDashboard() | 516 | await viewModel.loadDashboard() |
| 425 | } | 517 | } |
| @@ -434,3 +526,14 @@ final class AppState { | |||
| 434 | } | 526 | } |
| 435 | } | 527 | } |
| 436 | } | 528 | } |
| 529 | |||
| 530 | enum 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 | |||
| 4 | struct HutchApp: App { | 4 | struct HutchApp: App { |
| 5 | @State private var appState = AppState() | 5 | @State private var appState = AppState() |
| 6 | @State private var networkMonitor = NetworkMonitor() | 6 | @State private var networkMonitor = NetworkMonitor() |
| 7 | @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system | 7 | @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system |
| 8 | @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard | 8 | @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard |
| 9 | 9 | ||
| 10 | var body: some Scene { | 10 | var body: some Scene { |
| 11 | WindowGroup { | 11 | WindowGroup { |
Hutch/App/RootView.swift +14 −7
| @@ -10,6 +10,7 @@ struct RootView: View { | |||
| 10 | @State private var buildsPath = NavigationPath() | 10 | @State private var buildsPath = NavigationPath() |
| 11 | @State private var ticketsPath = NavigationPath() | 11 | @State private var ticketsPath = NavigationPath() |
| 12 | @State private var isResolvingDeepLink = false | 12 | @State private var isResolvingDeepLink = false |
| 13 | @State private var hasValidatedLaunch = false | ||
| 13 | 14 | ||
| 14 | var body: some View { | 15 | var body: some View { |
| 15 | @Bindable var appState = appState | 16 | @Bindable var appState = appState |
| @@ -17,8 +18,10 @@ struct RootView: View { | |||
| 17 | Group { | 18 | Group { |
| 18 | switch appState.authPhase { | 19 | switch appState.authPhase { |
| 19 | case .launching: | 20 | case .launching: |
| 20 | ProgressView("Connecting…") | 21 | ProgressView(appState.authStatusMessage) |
| 21 | .task { | 22 | .task { |
| 23 | guard !hasValidatedLaunch else { return } | ||
| 24 | hasValidatedLaunch = true | ||
| 22 | await appState.validateOnLaunch() | 25 | await appState.validateOnLaunch() |
| 23 | } | 26 | } |
| 24 | 27 | ||
| @@ -113,6 +116,8 @@ struct RootView: View { | |||
| 113 | Label("More", systemImage: "ellipsis.circle") | 116 | Label("More", systemImage: "ellipsis.circle") |
| 114 | } | 117 | } |
| 115 | } | 118 | } |
| 119 | .id(appState.sessionIdentity) | ||
| 120 | .defaultAppStorage(appState.accountDefaults) | ||
| 116 | .modifier(SidebarAdaptableTabStyle()) | 121 | .modifier(SidebarAdaptableTabStyle()) |
| 117 | .modifier(TabKeyboardShortcuts(selectedTab: Binding( | 122 | .modifier(TabKeyboardShortcuts(selectedTab: Binding( |
| 118 | get: { appState.selectedTab }, | 123 | get: { appState.selectedTab }, |
| @@ -315,6 +320,8 @@ enum MoreRoute: Hashable { | |||
| 315 | } | 320 | } |
| 316 | 321 | ||
| 317 | private struct MoreNavigationRoot: View { | 322 | private struct MoreNavigationRoot: View { |
| 323 | @Environment(AppState.self) private var appState | ||
| 324 | |||
| 318 | var body: some View { | 325 | var body: some View { |
| 319 | MoreView() | 326 | MoreView() |
| 320 | .navigationDestination(for: MoreRoute.self) { route in | 327 | .navigationDestination(for: MoreRoute.self) { route in |
| @@ -339,16 +346,16 @@ private struct MoreNavigationRoot: View { | |||
| 339 | ThreadDetailView( | 346 | ThreadDetailView( |
| 340 | thread: thread, | 347 | thread: thread, |
| 341 | onViewed: { | 348 | onViewed: { |
| 342 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | 349 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) |
| 343 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | 350 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) |
| 344 | }, | 351 | }, |
| 345 | onMarkRead: { | 352 | onMarkRead: { |
| 346 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | 353 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) |
| 347 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | 354 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) |
| 348 | }, | 355 | }, |
| 349 | onMarkUnread: { | 356 | onMarkUnread: { |
| 350 | InboxReadStateStore.markUnread(for: thread.id) | 357 | InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults) |
| 351 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | 358 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID) |
| 352 | } | 359 | } |
| 353 | ) | 360 | ) |
| 354 | case .manPageBrowser: | 361 | case .manPageBrowser: |
Hutch/Hutch/App/AccountSession.swift added +32
| @@ -0,0 +1,32 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct 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 | |||
| 15 | enum 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 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct BuildListView: View { | 3 | struct BuildListView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 5 | @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 | 5 | @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 |
| 6 | @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" | 6 | @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" |
| 7 | @Environment(AppState.self) private var appState | 7 | @Environment(AppState.self) private var appState |
| @@ -104,7 +104,7 @@ struct BuildListView: View { | |||
| 104 | } | 104 | } |
| 105 | .task { | 105 | .task { |
| 106 | if viewModel == nil { | 106 | if viewModel == nil { |
| 107 | let vm = BuildListViewModel(client: appState.client) | 107 | let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) |
| 108 | vm.repoFilter = savedRepoFilter | 108 | vm.repoFilter = savedRepoFilter |
| 109 | viewModel = vm | 109 | viewModel = vm |
| 110 | await vm.loadJobs() | 110 | await vm.loadJobs() |
Hutch/Views/Home/HomeView.swift +5 −3
| @@ -1,7 +1,7 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct HomeView: View { | 3 | struct HomeView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 5 | @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true | 5 | @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true |
| 6 | @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true | 6 | @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true |
| 7 | @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true | 7 | @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true |
| @@ -39,7 +39,9 @@ struct HomeView: View { | |||
| 39 | let newViewModel = HomeViewModel( | 39 | let newViewModel = HomeViewModel( |
| 40 | currentUser: currentUser, | 40 | currentUser: currentUser, |
| 41 | client: appState.client, | 41 | client: appState.client, |
| 42 | systemStatusRepository: appState.systemStatusRepository | 42 | systemStatusRepository: appState.systemStatusRepository, |
| 43 | defaults: appState.accountDefaults, | ||
| 44 | accountID: appState.activeAccountID | ||
| 43 | ) | 45 | ) |
| 44 | viewModel = newViewModel | 46 | viewModel = newViewModel |
| 45 | vm = newViewModel | 47 | vm = newViewModel |
| @@ -834,7 +836,7 @@ private struct HomeAttentionLinkRow<Destination: View>: View { | |||
| 834 | 836 | ||
| 835 | private struct HomeAssignedTicketsListView: View { | 837 | private struct HomeAssignedTicketsListView: View { |
| 836 | let viewModel: HomeViewModel | 838 | let viewModel: HomeViewModel |
| 837 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 839 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 838 | 840 | ||
| 839 | var body: some View { | 841 | var body: some View { |
| 840 | List { | 842 | List { |
Hutch/Views/Home/HomeViewModel.swift +21 −8
| @@ -321,11 +321,22 @@ final class HomeViewModel { | |||
| 321 | } | 321 | } |
| 322 | """ | 322 | """ |
| 323 | 323 | ||
| 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 | ) { | ||
| 325 | self.currentUser = currentUser | 334 | self.currentUser = currentUser |
| 326 | self.client = client | 335 | self.client = client |
| 327 | self.systemStatusRepository = systemStatusRepository | 336 | self.systemStatusRepository = systemStatusRepository |
| 328 | self.projectService = ProjectService(client: client) | 337 | self.projectService = ProjectService(client: client) |
| 338 | self.defaults = defaults | ||
| 339 | self.accountID = accountID | ||
| 329 | } | 340 | } |
| 330 | 341 | ||
| 331 | func loadDashboard() async { | 342 | func loadDashboard() async { |
| @@ -403,7 +414,7 @@ final class HomeViewModel { | |||
| 403 | } | 414 | } |
| 404 | 415 | ||
| 405 | var pinnedProjects: [Project] { | 416 | var pinnedProjects: [Project] { |
| 406 | let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey) | 417 | let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults) |
| 407 | guard !pinnedIDs.isEmpty else { return [] } | 418 | guard !pinnedIDs.isEmpty else { return [] } |
| 408 | 419 | ||
| 409 | let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) | 420 | let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) |
| @@ -411,7 +422,7 @@ final class HomeViewModel { | |||
| 411 | } | 422 | } |
| 412 | 423 | ||
| 413 | var hasPinnedProjects: Bool { | 424 | var hasPinnedProjects: Bool { |
| 414 | !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey).isEmpty | 425 | !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty |
| 415 | } | 426 | } |
| 416 | 427 | ||
| 417 | var failedBuildCount: Int { | 428 | var failedBuildCount: Int { |
| @@ -592,7 +603,7 @@ final class HomeViewModel { | |||
| 592 | } | 603 | } |
| 593 | 604 | ||
| 594 | func markInboxThreadRead(_ thread: InboxThreadSummary) { | 605 | 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) |
| 596 | unreadInboxThreads.removeAll { $0.id == thread.id } | 607 | unreadInboxThreads.removeAll { $0.id == thread.id } |
| 597 | unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0) | 608 | unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0) |
| 598 | hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 | 609 | hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 |
| @@ -600,7 +611,7 @@ final class HomeViewModel { | |||
| 600 | } | 611 | } |
| 601 | 612 | ||
| 602 | func markInboxThreadUnread(_ thread: InboxThreadSummary) { | 613 | func markInboxThreadUnread(_ thread: InboxThreadSummary) { |
| 603 | InboxReadStateStore.markUnread(for: thread.id) | 614 | InboxReadStateStore.markUnread(for: thread.id, defaults: defaults) |
| 604 | if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false { | 615 | if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false { |
| 605 | unreadInboxThreads.append( | 616 | unreadInboxThreads.append( |
| 606 | InboxThreadSummary( | 617 | InboxThreadSummary( |
| @@ -786,7 +797,8 @@ final class HomeViewModel { | |||
| 786 | containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), | 797 | containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), |
| 787 | isUnread: InboxReadStateStore.isUnread( | 798 | isUnread: InboxReadStateStore.isUnread( |
| 788 | threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))", | 799 | threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))", |
| 789 | lastActivityAt: thread.updated | 800 | lastActivityAt: thread.updated, |
| 801 | defaults: defaults | ||
| 790 | ) | 802 | ) |
| 791 | ) | 803 | ) |
| 792 | return summary.isUnread ? summary : nil | 804 | return summary.isUnread ? summary : nil |
| @@ -944,7 +956,8 @@ final class HomeViewModel { | |||
| 944 | }.count | 956 | }.count |
| 945 | : nil, | 957 | : nil, |
| 946 | updatedAt: .now | 958 | updatedAt: .now |
| 947 | ) | 959 | ), |
| 960 | accountID: accountID | ||
| 948 | ) | 961 | ) |
| 949 | } | 962 | } |
| 950 | 963 | ||
| @@ -966,7 +979,7 @@ final class HomeViewModel { | |||
| 966 | bannerSummary: snapshot.bannerSummary, | 979 | bannerSummary: snapshot.bannerSummary, |
| 967 | updatedAt: .now | 980 | updatedAt: .now |
| 968 | ) | 981 | ) |
| 969 | SystemStatusWidgetSnapshotStore.save(widgetSnapshot) | 982 | SystemStatusWidgetSnapshotStore.save(widgetSnapshot, accountID: accountID) |
| 970 | } | 983 | } |
| 971 | 984 | ||
| 972 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { | 985 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { |
Hutch/Views/Inbox/InboxView.swift +5 −1
| @@ -22,7 +22,11 @@ struct InboxView: View { | |||
| 22 | if let viewModel { | 22 | if let viewModel { |
| 23 | vm = viewModel | 23 | vm = viewModel |
| 24 | } else { | 24 | } 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 | ) | ||
| 26 | viewModel = newViewModel | 30 | viewModel = newViewModel |
| 27 | vm = newViewModel | 31 | vm = newViewModel |
| 28 | } | 32 | } |
Hutch/Views/Inbox/InboxViewModel.swift +13 −9
| @@ -72,6 +72,8 @@ final class InboxViewModel { | |||
| 72 | var searchText = "" | 72 | var searchText = "" |
| 73 | 73 | ||
| 74 | private let client: SRHTClient | 74 | private let client: SRHTClient |
| 75 | private let defaults: UserDefaults | ||
| 76 | private let accountID: String | ||
| 75 | private let listThreadFetchLimit = 10 | 77 | private let listThreadFetchLimit = 10 |
| 76 | private let listFetchConcurrencyLimit = 4 | 78 | private let listFetchConcurrencyLimit = 4 |
| 77 | 79 | ||
| @@ -121,8 +123,10 @@ final class InboxViewModel { | |||
| 121 | } | 123 | } |
| 122 | """ | 124 | """ |
| 123 | 125 | ||
| 124 | init(client: SRHTClient) { | 126 | init(client: SRHTClient, defaults: UserDefaults, accountID: String) { |
| 125 | self.client = client | 127 | self.client = client |
| 128 | self.defaults = defaults | ||
| 129 | self.accountID = accountID | ||
| 126 | } | 130 | } |
| 127 | 131 | ||
| 128 | func loadThreads() async { | 132 | func loadThreads() async { |
| @@ -143,7 +147,7 @@ final class InboxViewModel { | |||
| 143 | } | 147 | } |
| 144 | return lhs.lastActivityAt > rhs.lastActivityAt | 148 | return lhs.lastActivityAt > rhs.lastActivityAt |
| 145 | } | 149 | } |
| 146 | NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count) | 150 | NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID) |
| 147 | } catch { | 151 | } catch { |
| 148 | inboxListLogger.error("Inbox request failed") | 152 | inboxListLogger.error("Inbox request failed") |
| 149 | self.error = "Failed to load inbox" | 153 | self.error = "Failed to load inbox" |
| @@ -152,9 +156,9 @@ final class InboxViewModel { | |||
| 152 | 156 | ||
| 153 | func markThreadRead(_ thread: InboxThreadSummary) { | 157 | func markThreadRead(_ thread: InboxThreadSummary) { |
| 154 | let viewedAt = max(Date(), thread.lastActivityAt) | 158 | let viewedAt = max(Date(), thread.lastActivityAt) |
| 155 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) | 159 | InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults) |
| 156 | threads.removeAll { $0.id == thread.id } | 160 | threads.removeAll { $0.id == thread.id } |
| 157 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | 161 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID) |
| 158 | } | 162 | } |
| 159 | 163 | ||
| 160 | func markAllThreadsRead() { | 164 | func markAllThreadsRead() { |
| @@ -162,17 +166,17 @@ final class InboxViewModel { | |||
| 162 | 166 | ||
| 163 | let viewedAt = Date() | 167 | let viewedAt = Date() |
| 164 | for thread in threads where thread.isUnread { | 168 | 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) |
| 166 | } | 170 | } |
| 167 | 171 | ||
| 168 | threads.removeAll { $0.isUnread } | 172 | threads.removeAll { $0.isUnread } |
| 169 | NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count) | 173 | NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID) |
| 170 | } | 174 | } |
| 171 | 175 | ||
| 172 | func markThreadUnread(_ thread: InboxThreadSummary) { | 176 | func markThreadUnread(_ thread: InboxThreadSummary) { |
| 173 | InboxReadStateStore.markUnread(for: thread.id) | 177 | InboxReadStateStore.markUnread(for: thread.id, defaults: defaults) |
| 174 | updateThread(thread, isUnread: true) | 178 | updateThread(thread, isUnread: true) |
| 175 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | 179 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID) |
| 176 | } | 180 | } |
| 177 | 181 | ||
| 178 | func toggleThreadReadState(_ thread: InboxThreadSummary) { | 182 | func toggleThreadReadState(_ thread: InboxThreadSummary) { |
| @@ -291,7 +295,7 @@ final class InboxViewModel { | |||
| 291 | 295 | ||
| 292 | return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in | 296 | return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in |
| 293 | 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())" | 297 | 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) |
| 295 | return InboxThreadSummary( | 299 | return InboxThreadSummary( |
| 296 | rootEmailID: thread.root.id, | 300 | rootEmailID: thread.root.id, |
| 297 | rootMessageID: thread.root.messageID, | 301 | rootMessageID: thread.root.messageID, |
Hutch/Views/Lookup/LookupView.swift +7 −7
| @@ -339,7 +339,7 @@ struct LookupView: View { | |||
| 339 | .navigationTitle("Look Up") | 339 | .navigationTitle("Look Up") |
| 340 | .task { | 340 | .task { |
| 341 | if viewModel == nil { | 341 | if viewModel == nil { |
| 342 | viewModel = LookupViewModel(client: appState.client, appState: appState) | 342 | viewModel = LookupViewModel(client: appState.client, appState: appState, defaults: appState.accountDefaults) |
| 343 | } | 343 | } |
| 344 | } | 344 | } |
| 345 | } | 345 | } |
| @@ -440,16 +440,16 @@ struct LookupView: View { | |||
| 440 | ThreadDetailView( | 440 | ThreadDetailView( |
| 441 | thread: thread, | 441 | thread: thread, |
| 442 | onViewed: { | 442 | onViewed: { |
| 443 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | 443 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) |
| 444 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | 444 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) |
| 445 | }, | 445 | }, |
| 446 | onMarkRead: { | 446 | onMarkRead: { |
| 447 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | 447 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) |
| 448 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | 448 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) |
| 449 | }, | 449 | }, |
| 450 | onMarkUnread: { | 450 | onMarkUnread: { |
| 451 | InboxReadStateStore.markUnread(for: thread.id) | 451 | InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults) |
| 452 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | 452 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID) |
| 453 | } | 453 | } |
| 454 | ) | 454 | ) |
| 455 | case .manPageBrowser: | 455 | case .manPageBrowser: |
Hutch/Views/Lookup/UserProfileView.swift +1 −1
| @@ -2,7 +2,7 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct UserProfileView: View { | 3 | struct UserProfileView: View { |
| 4 | @Environment(AppState.self) private var appState | 4 | @Environment(AppState.self) private var appState |
| 5 | @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true | 5 | @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true |
| 6 | 6 | ||
| 7 | let user: User | 7 | let user: User |
| 8 | @State private var profileViewModel: UserProfileViewModel? | 8 | @State private var profileViewModel: UserProfileViewModel? |
Hutch/Views/More/AccountSwitcherView.swift +42 −7
| @@ -7,21 +7,35 @@ struct AccountSwitcherView: View { | |||
| 7 | @State private var showAddAccount = false | 7 | @State private var showAddAccount = false |
| 8 | @State private var isSwitching = false | 8 | @State private var isSwitching = false |
| 9 | @State private var switchError: String? | 9 | @State private var switchError: String? |
| 10 | @State private var pendingRemoval: AccountEntry? | ||
| 10 | 11 | ||
| 11 | var body: some View { | 12 | var body: some View { |
| 12 | NavigationStack { | 13 | NavigationStack { |
| 13 | List { | 14 | List { |
| 14 | Section { | 15 | Section { |
| 15 | ForEach(appState.accounts) { account in | 16 | ForEach(appState.accounts) { account in |
| 17 | let isActive = account.id == appState.activeAccountID | ||
| 16 | Button { | 18 | Button { |
| 17 | guard account.id != appState.activeAccountID else { return } | 19 | guard !isActive else { return } |
| 18 | switchTo(account) | 20 | switchTo(account) |
| 19 | } label: { | 21 | } label: { |
| 20 | HStack { | 22 | HStack(spacing: 12) { |
| 21 | Text("~\(account.username)") | 23 | if isActive { |
| 22 | .foregroundStyle(.primary) | 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 | } | ||
| 23 | Spacer() | 37 | Spacer() |
| 24 | if account.id == appState.activeAccountID { | 38 | if isActive { |
| 25 | Image(systemName: "checkmark") | 39 | Image(systemName: "checkmark") |
| 26 | .foregroundStyle(.tint) | 40 | .foregroundStyle(.tint) |
| 27 | } | 41 | } |
| @@ -31,8 +45,7 @@ struct AccountSwitcherView: View { | |||
| 31 | } | 45 | } |
| 32 | .onDelete { indexSet in | 46 | .onDelete { indexSet in |
| 33 | for index in indexSet { | 47 | for index in indexSet { |
| 34 | let account = appState.accounts[index] | 48 | pendingRemoval = appState.accounts[index] |
| 35 | Task { await appState.removeAccount(id: account.id) } | ||
| 36 | } | 49 | } |
| 37 | } | 50 | } |
| 38 | } | 51 | } |
| @@ -71,6 +84,28 @@ struct AccountSwitcherView: View { | |||
| 71 | } message: { | 84 | } message: { |
| 72 | Text(switchError ?? "") | 85 | Text(switchError ?? "") |
| 73 | } | 86 | } |
| 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 | } | ||
| 74 | .sheet(isPresented: $showAddAccount) { | 109 | .sheet(isPresented: $showAddAccount) { |
| 75 | AddAccountView() | 110 | AddAccountView() |
| 76 | } | 111 | } |
Hutch/Views/More/ProfileView.swift +1 −1
| @@ -7,7 +7,7 @@ private let profileBioMarkdownOptions = AttributedString.MarkdownParsingOptions( | |||
| 7 | 7 | ||
| 8 | struct ProfileView: View { | 8 | struct ProfileView: View { |
| 9 | @Environment(AppState.self) private var appState | 9 | @Environment(AppState.self) private var appState |
| 10 | @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true | 10 | @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true |
| 11 | @State private var viewModel: SettingsViewModel? | 11 | @State private var viewModel: SettingsViewModel? |
| 12 | @State private var contributionViewModel: UserProfileViewModel? | 12 | @State private var contributionViewModel: UserProfileViewModel? |
| 13 | @State private var pendingDestructiveAction: ProfileDestructiveAction? | 13 | @State private var pendingDestructiveAction: ProfileDestructiveAction? |
Hutch/Views/Pastes/PasteListView.swift +1 −1
| @@ -1,7 +1,7 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct PasteListView: View { | 3 | struct PasteListView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 5 | @Environment(AppState.self) private var appState | 5 | @Environment(AppState.self) private var appState |
| 6 | @State private var viewModel: PasteListViewModel? | 6 | @State private var viewModel: PasteListViewModel? |
| 7 | @State private var showCreatePasteSheet = false | 7 | @State private var showCreatePasteSheet = false |
Hutch/Views/Projects/ProjectDetailView.swift +2 −2
| @@ -22,7 +22,7 @@ struct ProjectDetailView: View { | |||
| 22 | private var isPinnedToHome: Bool { | 22 | private var isPinnedToHome: Bool { |
| 23 | _ = pinChangeCount | 23 | _ = pinChangeCount |
| 24 | guard let currentUserKey else { return false } | 24 | 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) |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | var body: some View { | 28 | var body: some View { |
| @@ -243,7 +243,7 @@ struct ProjectDetailView: View { | |||
| 243 | 243 | ||
| 244 | private func togglePinnedState() { | 244 | private func togglePinnedState() { |
| 245 | guard let currentUserKey else { return } | 245 | guard let currentUserKey else { return } |
| 246 | ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey) | 246 | ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults) |
| 247 | pinChangeCount += 1 | 247 | pinChangeCount += 1 |
| 248 | } | 248 | } |
| 249 | 249 | ||
Hutch/Views/Projects/ProjectMailingListView.swift +22 −13
| @@ -36,6 +36,8 @@ final class MailingListDetailViewModel { | |||
| 36 | 36 | ||
| 37 | private let mailingList: InboxMailingListReference | 37 | private let mailingList: InboxMailingListReference |
| 38 | private let client: SRHTClient | 38 | private let client: SRHTClient |
| 39 | private let defaults: UserDefaults | ||
| 40 | private let accountID: String | ||
| 39 | 41 | ||
| 40 | private static let listThreadsQuery = """ | 42 | private static let listThreadsQuery = """ |
| 41 | query projectMailingListThreads($rid: ID!) { | 43 | query projectMailingListThreads($rid: ID!) { |
| @@ -57,9 +59,11 @@ final class MailingListDetailViewModel { | |||
| 57 | } | 59 | } |
| 58 | """ | 60 | """ |
| 59 | 61 | ||
| 60 | init(mailingList: InboxMailingListReference, client: SRHTClient) { | 62 | init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) { |
| 61 | self.mailingList = mailingList | 63 | self.mailingList = mailingList |
| 62 | self.client = client | 64 | self.client = client |
| 65 | self.defaults = defaults | ||
| 66 | self.accountID = accountID | ||
| 63 | } | 67 | } |
| 64 | 68 | ||
| 65 | var filteredThreads: [InboxThreadSummary] { | 69 | var filteredThreads: [InboxThreadSummary] { |
| @@ -90,15 +94,15 @@ final class MailingListDetailViewModel { | |||
| 90 | 94 | ||
| 91 | func markThreadRead(_ thread: InboxThreadSummary) { | 95 | func markThreadRead(_ thread: InboxThreadSummary) { |
| 92 | let viewedAt = max(Date(), thread.lastActivityAt) | 96 | let viewedAt = max(Date(), thread.lastActivityAt) |
| 93 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) | 97 | InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults) |
| 94 | updateThread(thread, isUnread: false) | 98 | updateThread(thread, isUnread: false) |
| 95 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | 99 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID) |
| 96 | } | 100 | } |
| 97 | 101 | ||
| 98 | func markThreadUnread(_ thread: InboxThreadSummary) { | 102 | func markThreadUnread(_ thread: InboxThreadSummary) { |
| 99 | InboxReadStateStore.markUnread(for: thread.id) | 103 | InboxReadStateStore.markUnread(for: thread.id, defaults: defaults) |
| 100 | updateThread(thread, isUnread: true) | 104 | updateThread(thread, isUnread: true) |
| 101 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | 105 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID) |
| 102 | } | 106 | } |
| 103 | 107 | ||
| 104 | private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { | 108 | private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { |
| @@ -124,7 +128,7 @@ final class MailingListDetailViewModel { | |||
| 124 | messageCount: thread.replies + 1, | 128 | messageCount: thread.replies + 1, |
| 125 | repo: nil, | 129 | repo: nil, |
| 126 | containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), | 130 | 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) |
| 128 | ) | 132 | ) |
| 129 | } | 133 | } |
| 130 | 134 | ||
| @@ -238,7 +242,12 @@ struct MailingListDetailView: View { | |||
| 238 | .navigationBarTitleDisplayMode(.inline) | 242 | .navigationBarTitleDisplayMode(.inline) |
| 239 | .task { | 243 | .task { |
| 240 | if viewModel == nil { | 244 | 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 | ) | ||
| 242 | self.viewModel = viewModel | 251 | self.viewModel = viewModel |
| 243 | await viewModel.loadThreads() | 252 | await viewModel.loadThreads() |
| 244 | } | 253 | } |
| @@ -261,16 +270,16 @@ struct MailingListDetailView: View { | |||
| 261 | ThreadDetailView( | 270 | ThreadDetailView( |
| 262 | thread: thread, | 271 | thread: thread, |
| 263 | onViewed: { | 272 | onViewed: { |
| 264 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | 273 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) |
| 265 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | 274 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) |
| 266 | }, | 275 | }, |
| 267 | onMarkRead: { | 276 | onMarkRead: { |
| 268 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | 277 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults) |
| 269 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | 278 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) |
| 270 | }, | 279 | }, |
| 271 | onMarkUnread: { | 280 | onMarkUnread: { |
| 272 | InboxReadStateStore.markUnread(for: thread.id) | 281 | InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults) |
| 273 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | 282 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID) |
| 274 | } | 283 | } |
| 275 | ) | 284 | ) |
| 276 | } label: { | 285 | } label: { |
Hutch/Views/Repositories/RepositoryListView.swift +1 −1
| @@ -59,7 +59,7 @@ struct RepositoryListView: View { | |||
| 59 | } | 59 | } |
| 60 | .task { | 60 | .task { |
| 61 | if viewModel == nil { | 61 | if viewModel == nil { |
| 62 | viewModel = RepositoryListViewModel(client: appState.client) | 62 | viewModel = RepositoryListViewModel(client: appState.client, defaults: appState.accountDefaults) |
| 63 | } | 63 | } |
| 64 | } | 64 | } |
| 65 | } | 65 | } |
Hutch/Views/Settings/SettingsView.swift +24 −6
| @@ -2,10 +2,10 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct SettingsView: View { | 3 | struct SettingsView: View { |
| 4 | @Environment(AppState.self) private var appState | 4 | @Environment(AppState.self) private var appState |
| 5 | @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system | 5 | @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system |
| 6 | @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard | 6 | @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard |
| 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 8 | @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true | 8 | @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true |
| 9 | @State private var pendingDestructiveAction: SettingsDestructiveAction? | 9 | @State private var pendingDestructiveAction: SettingsDestructiveAction? |
| 10 | 10 | ||
| 11 | var body: some View { | 11 | var body: some View { |
| @@ -89,7 +89,25 @@ struct SettingsView: View { | |||
| 89 | HStack { | 89 | HStack { |
| 90 | Image(systemName: "key.fill") | 90 | Image(systemName: "key.fill") |
| 91 | .foregroundStyle(.secondary) | 91 | .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") | ||
| 93 | .font(.subheadline) | 111 | .font(.subheadline) |
| 94 | .foregroundStyle(.secondary) | 112 | .foregroundStyle(.secondary) |
| 95 | } | 113 | } |
| @@ -105,7 +123,7 @@ struct SettingsView: View { | |||
| 105 | } header: { | 123 | } header: { |
| 106 | Text("Authentication") | 124 | Text("Authentication") |
| 107 | } footer: { | 125 | } 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.") |
| 109 | } | 127 | } |
| 110 | } | 128 | } |
| 111 | 129 | ||
Hutch/Views/Tickets/TicketListView.swift +3 −2
| @@ -4,7 +4,7 @@ struct TicketListView: View { | |||
| 4 | let onTrackerUpdated: (TrackerSummary) -> Void | 4 | let onTrackerUpdated: (TrackerSummary) -> Void |
| 5 | let onTrackerDeleted: (TrackerSummary) -> Void | 5 | let onTrackerDeleted: (TrackerSummary) -> Void |
| 6 | 6 | ||
| 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 8 | @Environment(AppState.self) private var appState | 8 | @Environment(AppState.self) private var appState |
| 9 | @Environment(\.dismiss) private var dismiss | 9 | @Environment(\.dismiss) private var dismiss |
| 10 | @State private var tracker: TrackerSummary | 10 | @State private var tracker: TrackerSummary |
| @@ -232,7 +232,8 @@ struct TicketListView: View { | |||
| 232 | trackerName: tracker.name, | 232 | trackerName: tracker.name, |
| 233 | trackerId: tracker.id, | 233 | trackerId: tracker.id, |
| 234 | trackerRid: tracker.rid, | 234 | trackerRid: tracker.rid, |
| 235 | client: appState.client | 235 | client: appState.client, |
| 236 | defaults: appState.accountDefaults | ||
| 236 | ) | 237 | ) |
| 237 | viewModel = vm | 238 | viewModel = vm |
| 238 | trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) | 239 | trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) |
HutchTests/HutchIntentsTests.swift +29
| @@ -72,4 +72,33 @@ struct HutchIntentsTests { | |||
| 72 | #expect(loaded?.unreadInboxThreads == 5) | 72 | #expect(loaded?.unreadInboxThreads == 5) |
| 73 | #expect(loaded?.assignedOpenTickets == 3) | 73 | #expect(loaded?.assignedOpenTickets == 3) |
| 74 | } | 74 | } |
| 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 | } | ||
| 75 | } | 104 | } |
HutchTests/SystemStatusWidgetSnapshotTests.swift +31
| @@ -64,6 +64,37 @@ struct SystemStatusWidgetSnapshotTests { | |||
| 64 | #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil) | 64 | #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil) |
| 65 | } | 65 | } |
| 66 | 66 | ||
| 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 | |||
| 67 | @Test | 98 | @Test |
| 68 | func unavailableSnapshotHasEmptyServices() { | 99 | func unavailableSnapshotHasEmptyServices() { |
| 69 | let snapshot = SystemStatusWidgetSnapshot.unavailable | 100 | let snapshot = SystemStatusWidgetSnapshot.unavailable |
Shared/ContributionWidgetContext.swift +21 −6
| @@ -11,8 +11,11 @@ enum ContributionWidgetContextStore { | |||
| 11 | private static let actorKey = "contributionWidget.actor" | 11 | private static let actorKey = "contributionWidget.actor" |
| 12 | private static let enabledKey = "contributionWidget.enabled" | 12 | private static let enabledKey = "contributionWidget.enabled" |
| 13 | 13 | ||
| 14 | static func loadActor(defaults: UserDefaults? = sharedDefaults()) -> String? { | 14 | static func loadActor( |
| 15 | defaults?.string(forKey: actorKey) | 15 | accountID: String? = ActiveAccountContextStore.load(), |
| 16 | defaults: UserDefaults? = sharedDefaults() | ||
| 17 | ) -> String? { | ||
| 18 | defaults?.string(forKey: scopedActorKey(for: accountID)) | ||
| 16 | } | 19 | } |
| 17 | 20 | ||
| 18 | static func isEnabled(defaults: UserDefaults? = sharedDefaults()) -> Bool { | 21 | static func isEnabled(defaults: UserDefaults? = sharedDefaults()) -> Bool { |
| @@ -24,13 +27,20 @@ enum ContributionWidgetContextStore { | |||
| 24 | reloadWidgetTimelines() | 27 | reloadWidgetTimelines() |
| 25 | } | 28 | } |
| 26 | 29 | ||
| 27 | static func saveActor(_ actor: String, defaults: UserDefaults? = sharedDefaults()) { | 30 | static func saveActor( |
| 28 | defaults?.set(actor, forKey: actorKey) | 31 | _ actor: String, |
| 32 | accountID: String? = ActiveAccountContextStore.load(), | ||
| 33 | defaults: UserDefaults? = sharedDefaults() | ||
| 34 | ) { | ||
| 35 | defaults?.set(actor, forKey: scopedActorKey(for: accountID)) | ||
| 29 | reloadWidgetTimelines() | 36 | reloadWidgetTimelines() |
| 30 | } | 37 | } |
| 31 | 38 | ||
| 32 | static func clear(defaults: UserDefaults? = sharedDefaults()) { | 39 | static func clear( |
| 33 | defaults?.removeObject(forKey: actorKey) | 40 | accountID: String? = ActiveAccountContextStore.load(), |
| 41 | defaults: UserDefaults? = sharedDefaults() | ||
| 42 | ) { | ||
| 43 | defaults?.removeObject(forKey: scopedActorKey(for: accountID)) | ||
| 34 | reloadWidgetTimelines() | 44 | reloadWidgetTimelines() |
| 35 | } | 45 | } |
| 36 | 46 | ||
| @@ -38,6 +48,11 @@ enum ContributionWidgetContextStore { | |||
| 38 | UserDefaults(suiteName: HutchAppGroup.identifier) | 48 | UserDefaults(suiteName: HutchAppGroup.identifier) |
| 39 | } | 49 | } |
| 40 | 50 | ||
| 51 | private static func scopedActorKey(for accountID: String?) -> String { | ||
| 52 | guard let accountID, !accountID.isEmpty else { return actorKey } | ||
| 53 | return "\(actorKey).\(accountID)" | ||
| 54 | } | ||
| 55 | |||
| 41 | private static func reloadWidgetTimelines() { | 56 | private static func reloadWidgetTimelines() { |
| 42 | #if canImport(WidgetKit) | 57 | #if canImport(WidgetKit) |
| 43 | WidgetCenter.shared.reloadTimelines(ofKind: ContributionGraphWidgetConfiguration.kind) | 58 | WidgetCenter.shared.reloadTimelines(ofKind: ContributionGraphWidgetConfiguration.kind) |
Shared/NeedsAttentionSnapshot.swift +47 −9
| @@ -7,6 +7,26 @@ enum HutchAppGroup { | |||
| 7 | static let identifier = "group.net.cleberg.Hutch" | 7 | static let identifier = "group.net.cleberg.Hutch" |
| 8 | } | 8 | } |
| 9 | 9 | ||
| 10 | enum 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 | |||
| 10 | enum NeedsAttentionWidgetConfiguration { | 30 | enum NeedsAttentionWidgetConfiguration { |
| 11 | static let kind = "NeedsAttentionWidget" | 31 | static let kind = "NeedsAttentionWidget" |
| 12 | } | 32 | } |
| @@ -37,22 +57,29 @@ struct NeedsAttentionSnapshot: Codable, Sendable { | |||
| 37 | enum NeedsAttentionSnapshotStore { | 57 | enum NeedsAttentionSnapshotStore { |
| 38 | private static let snapshotKey = "needsAttention.snapshot" | 58 | private static let snapshotKey = "needsAttention.snapshot" |
| 39 | 59 | ||
| 40 | static func load(defaults: UserDefaults? = sharedDefaults()) -> NeedsAttentionSnapshot? { | 60 | static func load( |
| 61 | accountID: String? = ActiveAccountContextStore.load(), | ||
| 62 | defaults: UserDefaults? = sharedDefaults() | ||
| 63 | ) -> NeedsAttentionSnapshot? { | ||
| 41 | guard let defaults, | 64 | guard let defaults, |
| 42 | let data = defaults.data(forKey: snapshotKey) else { | 65 | let data = defaults.data(forKey: scopedKey(for: accountID)) else { |
| 43 | return nil | 66 | return nil |
| 44 | } | 67 | } |
| 45 | 68 | ||
| 46 | return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data) | 69 | return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data) |
| 47 | } | 70 | } |
| 48 | 71 | ||
| 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 | ) { | ||
| 50 | guard let defaults, | 77 | guard let defaults, |
| 51 | let data = try? JSONEncoder().encode(snapshot) else { | 78 | let data = try? JSONEncoder().encode(snapshot) else { |
| 52 | return | 79 | return |
| 53 | } | 80 | } |
| 54 | 81 | ||
| 55 | defaults.set(data, forKey: snapshotKey) | 82 | defaults.set(data, forKey: scopedKey(for: accountID)) |
| 56 | reloadWidgetTimelines() | 83 | reloadWidgetTimelines() |
| 57 | } | 84 | } |
| 58 | 85 | ||
| @@ -60,23 +87,25 @@ enum NeedsAttentionSnapshotStore { | |||
| 60 | unreadInboxThreads: Int? = nil, | 87 | unreadInboxThreads: Int? = nil, |
| 61 | assignedOpenTickets: Int? = nil, | 88 | assignedOpenTickets: Int? = nil, |
| 62 | failedBuilds: Int? = nil, | 89 | failedBuilds: Int? = nil, |
| 90 | accountID: String? = ActiveAccountContextStore.load(), | ||
| 63 | defaults: UserDefaults? = sharedDefaults() | 91 | defaults: UserDefaults? = sharedDefaults() |
| 64 | ) { | 92 | ) { |
| 65 | let existing = load(defaults: defaults) | 93 | let existing = load(accountID: accountID, defaults: defaults) |
| 66 | let snapshot = NeedsAttentionSnapshot( | 94 | let snapshot = NeedsAttentionSnapshot( |
| 67 | unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads, | 95 | unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads, |
| 68 | assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets, | 96 | assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets, |
| 69 | failedBuilds: failedBuilds ?? existing?.failedBuilds, | 97 | failedBuilds: failedBuilds ?? existing?.failedBuilds, |
| 70 | updatedAt: .now | 98 | updatedAt: .now |
| 71 | ) | 99 | ) |
| 72 | save(snapshot, defaults: defaults) | 100 | save(snapshot, accountID: accountID, defaults: defaults) |
| 73 | } | 101 | } |
| 74 | 102 | ||
| 75 | static func adjustUnreadInboxThreads( | 103 | static func adjustUnreadInboxThreads( |
| 76 | by delta: Int, | 104 | by delta: Int, |
| 105 | accountID: String? = ActiveAccountContextStore.load(), | ||
| 77 | defaults: UserDefaults? = sharedDefaults() | 106 | defaults: UserDefaults? = sharedDefaults() |
| 78 | ) { | 107 | ) { |
| 79 | guard let existing = load(defaults: defaults), | 108 | guard let existing = load(accountID: accountID, defaults: defaults), |
| 80 | let unreadInboxThreads = existing.unreadInboxThreads else { | 109 | let unreadInboxThreads = existing.unreadInboxThreads else { |
| 81 | return | 110 | return |
| 82 | } | 111 | } |
| @@ -88,12 +117,16 @@ enum NeedsAttentionSnapshotStore { | |||
| 88 | failedBuilds: existing.failedBuilds, | 117 | failedBuilds: existing.failedBuilds, |
| 89 | updatedAt: .now | 118 | updatedAt: .now |
| 90 | ), | 119 | ), |
| 120 | accountID: accountID, | ||
| 91 | defaults: defaults | 121 | defaults: defaults |
| 92 | ) | 122 | ) |
| 93 | } | 123 | } |
| 94 | 124 | ||
| 95 | static func clear(defaults: UserDefaults? = sharedDefaults()) { | 125 | static func clear( |
| 96 | defaults?.removeObject(forKey: snapshotKey) | 126 | accountID: String? = ActiveAccountContextStore.load(), |
| 127 | defaults: UserDefaults? = sharedDefaults() | ||
| 128 | ) { | ||
| 129 | defaults?.removeObject(forKey: scopedKey(for: accountID)) | ||
| 97 | reloadWidgetTimelines() | 130 | reloadWidgetTimelines() |
| 98 | } | 131 | } |
| 99 | 132 | ||
| @@ -101,6 +134,11 @@ enum NeedsAttentionSnapshotStore { | |||
| 101 | UserDefaults(suiteName: HutchAppGroup.identifier) | 134 | UserDefaults(suiteName: HutchAppGroup.identifier) |
| 102 | } | 135 | } |
| 103 | 136 | ||
| 137 | private static func scopedKey(for accountID: String?) -> String { | ||
| 138 | guard let accountID, !accountID.isEmpty else { return snapshotKey } | ||
| 139 | return "\(snapshotKey).\(accountID)" | ||
| 140 | } | ||
| 141 | |||
| 104 | private static func reloadWidgetTimelines() { | 142 | private static func reloadWidgetTimelines() { |
| 105 | #if canImport(WidgetKit) | 143 | #if canImport(WidgetKit) |
| 106 | WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) | 144 | WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) |
Shared/SystemStatusWidgetSnapshot.swift +21 −6
| @@ -33,25 +33,35 @@ struct SystemStatusWidgetSnapshot: Codable, Sendable { | |||
| 33 | enum SystemStatusWidgetSnapshotStore { | 33 | enum SystemStatusWidgetSnapshotStore { |
| 34 | private static let snapshotKey = "systemStatus.widgetSnapshot" | 34 | private static let snapshotKey = "systemStatus.widgetSnapshot" |
| 35 | 35 | ||
| 36 | static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? { | 36 | static func load( |
| 37 | accountID: String? = ActiveAccountContextStore.load(), | ||
| 38 | defaults: UserDefaults? = sharedDefaults() | ||
| 39 | ) -> SystemStatusWidgetSnapshot? { | ||
| 37 | guard let defaults, | 40 | guard let defaults, |
| 38 | let data = defaults.data(forKey: snapshotKey) else { | 41 | let data = defaults.data(forKey: scopedKey(for: accountID)) else { |
| 39 | return nil | 42 | return nil |
| 40 | } | 43 | } |
| 41 | return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) | 44 | return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) |
| 42 | } | 45 | } |
| 43 | 46 | ||
| 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 | ) { | ||
| 45 | guard let defaults, | 52 | guard let defaults, |
| 46 | let data = try? JSONEncoder().encode(snapshot) else { | 53 | let data = try? JSONEncoder().encode(snapshot) else { |
| 47 | return | 54 | return |
| 48 | } | 55 | } |
| 49 | defaults.set(data, forKey: snapshotKey) | 56 | defaults.set(data, forKey: scopedKey(for: accountID)) |
| 50 | reloadWidgetTimelines() | 57 | reloadWidgetTimelines() |
| 51 | } | 58 | } |
| 52 | 59 | ||
| 53 | static func clear(defaults: UserDefaults? = sharedDefaults()) { | 60 | static func clear( |
| 54 | defaults?.removeObject(forKey: snapshotKey) | 61 | accountID: String? = ActiveAccountContextStore.load(), |
| 62 | defaults: UserDefaults? = sharedDefaults() | ||
| 63 | ) { | ||
| 64 | defaults?.removeObject(forKey: scopedKey(for: accountID)) | ||
| 55 | reloadWidgetTimelines() | 65 | reloadWidgetTimelines() |
| 56 | } | 66 | } |
| 57 | 67 | ||
| @@ -59,6 +69,11 @@ enum SystemStatusWidgetSnapshotStore { | |||
| 59 | UserDefaults(suiteName: HutchAppGroup.identifier) | 69 | UserDefaults(suiteName: HutchAppGroup.identifier) |
| 60 | } | 70 | } |
| 61 | 71 | ||
| 72 | private static func scopedKey(for accountID: String?) -> String { | ||
| 73 | guard let accountID, !accountID.isEmpty else { return snapshotKey } | ||
| 74 | return "\(snapshotKey).\(accountID)" | ||
| 75 | } | ||
| 76 | |||
| 62 | private static func reloadWidgetTimelines() { | 77 | private static func reloadWidgetTimelines() { |
| 63 | #if canImport(WidgetKit) | 78 | #if canImport(WidgetKit) |
| 64 | WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) | 79 | WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) |