summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 23:19:20 -0500
committerChristian Cleberg <[email protected]>2026-04-12 23:19:20 -0500
commitee9f2904aa319231b7047fca3cb069f0c07019cd (patch)
tree69a18234512c63f38f0d477e0023969698ec3c9e
parent72f860e0e171f6aac4e7d896e7a571147dbcb5c5 (diff)
downloadhutch-ee9f2904aa319231b7047fca3cb069f0c07019cd.tar.gz
hutch-ee9f2904aa319231b7047fca3cb069f0c07019cd.tar.bz2
hutch-ee9f2904aa319231b7047fca3cb069f0c07019cd.zip
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
-rw-r--r--Hutch/App/AppState.swift255
-rw-r--r--Hutch/App/HutchApp.swift4
-rw-r--r--Hutch/App/RootView.swift21
-rw-r--r--Hutch/Hutch/App/AccountSession.swift32
-rw-r--r--Hutch/Views/Builds/BuildListView.swift4
-rw-r--r--Hutch/Views/Home/HomeView.swift8
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift29
-rw-r--r--Hutch/Views/Inbox/InboxView.swift6
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift22
-rw-r--r--Hutch/Views/Lookup/LookupView.swift14
-rw-r--r--Hutch/Views/Lookup/UserProfileView.swift2
-rw-r--r--Hutch/Views/More/AccountSwitcherView.swift49
-rw-r--r--Hutch/Views/More/ProfileView.swift2
-rw-r--r--Hutch/Views/Pastes/PasteListView.swift2
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift4
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift35
-rw-r--r--Hutch/Views/Repositories/RepositoryListView.swift2
-rw-r--r--Hutch/Views/Settings/SettingsView.swift30
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift5
-rw-r--r--HutchTests/HutchIntentsTests.swift29
-rw-r--r--HutchTests/SystemStatusWidgetSnapshotTests.swift31
-rw-r--r--Shared/ContributionWidgetContext.swift27
-rw-r--r--Shared/NeedsAttentionSnapshot.swift56
-rw-r--r--Shared/SystemStatusWidgetSnapshot.swift27
24 files changed, 526 insertions, 170 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 0f0b036..2f14011 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -35,6 +35,7 @@ final class AppState {
// MARK: - Authentication
private(set) var authPhase: AuthPhase = .launching
+ private(set) var authStatusMessage = "Connecting…"
/// Convenience for views that need a simple bool.
var isAuthenticated: Bool {
@@ -57,9 +58,15 @@ final class AppState {
// MARK: - Networking
- let client: SRHTClient
+ private(set) var client: SRHTClient
let configuration: AppConfiguration
- let systemStatusRepository: SystemStatusRepository
+ private(set) var systemStatusRepository: SystemStatusRepository
+ private var activeSession: AccountSession?
+ private(set) var sessionIdentity = UUID()
+
+ var accountDefaults: UserDefaults {
+ activeSession?.defaults ?? .standard
+ }
// MARK: - Deep link pending navigation
@@ -72,8 +79,7 @@ final class AppState {
init() {
self.configuration = AppConfiguration()
- let token = KeychainHelper.loadToken()
- self.client = SRHTClient(token: token)
+ self.client = SRHTClient()
self.systemStatusRepository = SystemStatusRepository()
}
@@ -82,18 +88,18 @@ final class AppState {
/// Called once at app launch. If a token exists in Keychain, validates it
/// silently. On failure, clears the token and falls through to unauthenticated.
func validateOnLaunch() async {
+ authStatusMessage = "Connecting…"
var storedAccounts = KeychainHelper.loadAccounts()
if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() {
- client.setToken(legacyToken)
- if let user = try? await fetchMe() {
+ let legacyClient = SRHTClient(token: legacyToken)
+ if let user = try? await fetchMe(using: legacyClient) {
let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken)
storedAccounts = [entry]
try? KeychainHelper.saveAccounts(storedAccounts)
try? KeychainHelper.deleteToken()
} else {
try? KeychainHelper.deleteToken()
- client.setToken(nil)
authPhase = .unauthenticated
return
}
@@ -105,24 +111,29 @@ final class AppState {
}
let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
- let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0]
-
- client.setToken(target.token)
- do {
- let user = try await fetchMe()
- accounts = storedAccounts
- activeAccountID = target.id
- currentUser = user
- ContributionWidgetContextStore.saveActor(user.canonicalName)
- authPhase = .authenticated
- await refreshNeedsAttentionSnapshot()
- } catch {
- client.setToken(nil)
- currentUser = nil
- ContributionWidgetContextStore.clear()
- authPhase = .unauthenticated
- NeedsAttentionSnapshotStore.clear()
+ let orderedAccounts = prioritizedAccounts(storedAccounts, preferredID: savedID)
+ var invalidIDs = Set<String>()
+
+ for account in orderedAccounts {
+ do {
+ let session = try await makeSession(for: account)
+ let filteredAccounts = storedAccounts.filter { !invalidIDs.contains($0.id) }
+ accounts = filteredAccounts
+ try? KeychainHelper.saveAccounts(filteredAccounts)
+ activate(session)
+ authPhase = .authenticated
+ await refreshNeedsAttentionSnapshot()
+ return
+ } catch {
+ invalidIDs.insert(account.id)
+ clearAccountArtifacts(for: account.id)
+ }
}
+
+ accounts = storedAccounts.filter { !invalidIDs.contains($0.id) }
+ try? KeychainHelper.saveAccounts(accounts)
+ clearActiveSessionState()
+ authPhase = .unauthenticated
}
// MARK: - Token management
@@ -130,54 +141,40 @@ final class AppState {
/// Validate a new token by querying meta.sr.ht, then persist it.
/// Throws on network/GraphQL errors so the caller can display the message.
func connect(with token: String) async throws {
- client.setToken(token)
- do {
- let user = try await fetchMe()
- let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
- accounts.append(entry)
- activeAccountID = entry.id
- UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
- try KeychainHelper.saveAccounts(accounts)
- currentUser = user
- ContributionWidgetContextStore.saveActor(user.canonicalName)
- authPhase = .authenticated
- await refreshNeedsAttentionSnapshot()
- } catch {
- client.setToken(nil)
- throw error
- }
+ let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
+ try await addValidatedAccount(token: normalizedToken, activateNewAccount: true)
}
/// Validate a new token, add it as an account, and switch to it immediately.
func addAccount(token: String) async throws {
- let tempClient = SRHTClient(token: token)
- let user = try await fetchMe(using: tempClient)
- let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
- accounts.append(entry)
- try KeychainHelper.saveAccounts(accounts)
- try await switchAccount(to: entry.id)
+ let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
+ try await addValidatedAccount(token: normalizedToken, activateNewAccount: true)
}
/// Switch the active account and fully refresh the app.
func switchAccount(to id: String) async throws {
guard let entry = accounts.first(where: { $0.id == id }) else { return }
+ let previousSession = activeSession
- client.responseCache.clear()
- currentUser = nil
- pendingDeepLink = nil
- pendingTabNavigation = nil
- deepLinkError = nil
- selectedTab = .home
-
- authPhase = .unauthenticated
+ authStatusMessage = "Switching Accounts…"
+ authPhase = .launching
+ sessionIdentity = UUID()
- client.setToken(entry.token)
- activeAccountID = entry.id
- UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
+ do {
+ let session = try await makeSession(for: entry)
+ activate(session)
+ resetNavigationState()
+ } catch {
+ if let previousSession {
+ activate(previousSession)
+ authPhase = .authenticated
+ } else {
+ clearActiveSessionState()
+ authPhase = .unauthenticated
+ }
+ throw error
+ }
- let user = try await fetchMe()
- currentUser = user
- ContributionWidgetContextStore.saveActor(user.canonicalName)
authPhase = .authenticated
await refreshNeedsAttentionSnapshot()
}
@@ -185,42 +182,51 @@ final class AppState {
/// Remove a stored account. Switches to another account if the removed account
/// was active; signs out fully if it was the last account.
func removeAccount(id: String) async {
+ let removedWasActive = id == activeAccountID
accounts.removeAll { $0.id == id }
try? KeychainHelper.saveAccounts(accounts)
+ clearAccountArtifacts(for: id)
- guard id == activeAccountID else { return }
+ guard removedWasActive else { return }
if let next = accounts.first {
- try? await switchAccount(to: next.id)
+ do {
+ try await switchAccount(to: next.id)
+ } catch {
+ await removeAccount(id: next.id)
+ }
} else {
await signOut()
}
}
func signOut() async {
- clearSessionState()
+ clearActiveSessionState()
+ try? KeychainHelper.deleteAll()
URLCache.shared.removeAllCachedResponses()
HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
await clearWebData()
clearWebContentRenderCaches()
- NeedsAttentionSnapshotStore.clear()
- SystemStatusWidgetSnapshotStore.clear()
+ clearAllAccountArtifacts()
authPhase = .unauthenticated
selectedTab = .home
}
func resetAppData() async {
- clearSessionState()
+ clearActiveSessionState()
if let bundleIdentifier = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
}
+ for account in accounts {
+ AccountDefaultsStore.clear(accountID: account.id)
+ }
+ try? KeychainHelper.deleteAll()
URLCache.shared.removeAllCachedResponses()
HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
await clearWebData()
clearWebContentRenderCaches()
- NeedsAttentionSnapshotStore.clear()
- SystemStatusWidgetSnapshotStore.clear()
+ clearAllAccountArtifacts()
authPhase = .unauthenticated
selectedTab = .home
@@ -395,31 +401,117 @@ final class AppState {
let tracker: TrackerSummary
}
- private func clearSessionState() {
- try? KeychainHelper.deleteAll()
- client.setToken(nil)
- client.responseCache.clear()
- accounts = []
+ private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws {
+ let tempClient = SRHTClient(token: token)
+ let user = try await fetchMe(using: tempClient)
+
+ if let existing = accounts.first(where: {
+ $0.username.caseInsensitiveCompare(user.username) == .orderedSame || $0.token == token
+ }) {
+ _ = existing
+ throw AppStateError.duplicateAccount(username: user.username)
+ }
+
+ let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
+ accounts.append(entry)
+ try KeychainHelper.saveAccounts(accounts)
+
+ guard activateNewAccount else { return }
+ let session = try await makeSession(for: entry, knownUser: user)
+ authStatusMessage = "Switching Accounts…"
+ authPhase = .launching
+ sessionIdentity = UUID()
+ activate(session)
+ resetNavigationState()
+ authPhase = .authenticated
+ await refreshNeedsAttentionSnapshot()
+ }
+
+ private func makeSession(for account: AccountEntry, knownUser: User? = nil) async throws -> AccountSession {
+ let sessionClient = SRHTClient(token: account.token)
+ let user: User
+ if let knownUser {
+ user = knownUser
+ } else {
+ user = try await fetchMe(using: sessionClient)
+ }
+ let defaults = AccountDefaultsStore.userDefaults(for: account.id)
+ let repository = SystemStatusRepository(cacheStore: SystemStatusCacheStore(defaults: defaults))
+ return AccountSession(
+ account: account,
+ user: user,
+ client: sessionClient,
+ defaults: defaults,
+ systemStatusRepository: repository
+ )
+ }
+
+ private func activate(_ session: AccountSession) {
+ activeSession = session
+ client = session.client
+ systemStatusRepository = session.systemStatusRepository
+ currentUser = session.user
+ activeAccountID = session.account.id
+ UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID)
+ ActiveAccountContextStore.save(session.account.id)
+ ContributionWidgetContextStore.saveActor(session.user.canonicalName, accountID: session.account.id)
+ authStatusMessage = "Connecting…"
+ }
+
+ private func clearActiveSessionState() {
+ client = SRHTClient()
+ systemStatusRepository = SystemStatusRepository()
+ activeSession = nil
activeAccountID = ""
UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
+ ActiveAccountContextStore.clear()
currentUser = nil
- ContributionWidgetContextStore.clear()
+ sessionIdentity = UUID()
+ resetNavigationState()
+ }
+
+ private func resetNavigationState() {
pendingDeepLink = nil
pendingTabNavigation = nil
deepLinkError = nil
selectedTab = .home
}
+ private func clearAccountArtifacts(for accountID: String) {
+ AccountDefaultsStore.clear(accountID: accountID)
+ ContributionWidgetContextStore.clear(accountID: accountID)
+ NeedsAttentionSnapshotStore.clear(accountID: accountID)
+ SystemStatusWidgetSnapshotStore.clear(accountID: accountID)
+ }
+
+ private func clearAllAccountArtifacts() {
+ for account in accounts {
+ clearAccountArtifacts(for: account.id)
+ }
+ ContributionWidgetContextStore.clear(accountID: nil)
+ NeedsAttentionSnapshotStore.clear(accountID: nil)
+ SystemStatusWidgetSnapshotStore.clear(accountID: nil)
+ ActiveAccountContextStore.clear()
+ accounts = []
+ }
+
+ private func prioritizedAccounts(_ accounts: [AccountEntry], preferredID: String) -> [AccountEntry] {
+ guard let preferred = accounts.first(where: { $0.id == preferredID }) else { return accounts }
+ return [preferred] + accounts.filter { $0.id != preferredID }
+ }
+
private func refreshNeedsAttentionSnapshot() async {
guard let currentUser else {
- NeedsAttentionSnapshotStore.clear()
+ NeedsAttentionSnapshotStore.clear(accountID: activeAccountID)
return
}
let viewModel = HomeViewModel(
currentUser: currentUser,
client: client,
- systemStatusRepository: systemStatusRepository
+ systemStatusRepository: systemStatusRepository,
+ defaults: accountDefaults,
+ accountID: activeAccountID
)
await viewModel.loadDashboard()
}
@@ -434,3 +526,14 @@ final class AppState {
}
}
}
+
+enum AppStateError: LocalizedError {
+ case duplicateAccount(username: String)
+
+ var errorDescription: String? {
+ switch self {
+ case .duplicateAccount(let username):
+ "The account ~\(username) is already saved."
+ }
+ }
+}
diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift
index 016ddd0..853b1c2 100644
--- a/Hutch/App/HutchApp.swift
+++ b/Hutch/App/HutchApp.swift
@@ -4,8 +4,8 @@ import SwiftUI
struct HutchApp: App {
@State private var appState = AppState()
@State private var networkMonitor = NetworkMonitor()
- @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system
- @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard
+ @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
+ @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
var body: some Scene {
WindowGroup {
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 9e91a71..e3baa76 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -10,6 +10,7 @@ struct RootView: View {
@State private var buildsPath = NavigationPath()
@State private var ticketsPath = NavigationPath()
@State private var isResolvingDeepLink = false
+ @State private var hasValidatedLaunch = false
var body: some View {
@Bindable var appState = appState
@@ -17,8 +18,10 @@ struct RootView: View {
Group {
switch appState.authPhase {
case .launching:
- ProgressView("Connecting…")
+ ProgressView(appState.authStatusMessage)
.task {
+ guard !hasValidatedLaunch else { return }
+ hasValidatedLaunch = true
await appState.validateOnLaunch()
}
@@ -113,6 +116,8 @@ struct RootView: View {
Label("More", systemImage: "ellipsis.circle")
}
}
+ .id(appState.sessionIdentity)
+ .defaultAppStorage(appState.accountDefaults)
.modifier(SidebarAdaptableTabStyle())
.modifier(TabKeyboardShortcuts(selectedTab: Binding(
get: { appState.selectedTab },
@@ -315,6 +320,8 @@ enum MoreRoute: Hashable {
}
private struct MoreNavigationRoot: View {
+ @Environment(AppState.self) private var appState
+
var body: some View {
MoreView()
.navigationDestination(for: MoreRoute.self) { route in
@@ -339,16 +346,16 @@ private struct MoreNavigationRoot: View {
ThreadDetailView(
thread: thread,
onViewed: {
- InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
},
onMarkRead: {
- InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
},
onMarkUnread: {
- InboxReadStateStore.markUnread(for: thread.id)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
+ InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
}
)
case .manPageBrowser:
diff --git a/Hutch/Hutch/App/AccountSession.swift b/Hutch/Hutch/App/AccountSession.swift
new file mode 100644
index 0000000..1918b0a
--- /dev/null
+++ b/Hutch/Hutch/App/AccountSession.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+struct AccountSession: Sendable {
+ let account: AccountEntry
+ let user: User
+ let client: SRHTClient
+ let defaults: UserDefaults
+ let systemStatusRepository: SystemStatusRepository
+
+ var id: String {
+ account.id
+ }
+}
+
+enum AccountDefaultsStore {
+ private static let suitePrefix = "net.cleberg.Hutch.account"
+
+ static func userDefaults(for accountID: String) -> UserDefaults {
+ let suiteName = suiteName(for: accountID)
+ return UserDefaults(suiteName: suiteName) ?? .standard
+ }
+
+ static func clear(accountID: String) {
+ let suiteName = suiteName(for: accountID)
+ guard let defaults = UserDefaults(suiteName: suiteName) else { return }
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ private static func suiteName(for accountID: String) -> String {
+ "\(suitePrefix).\(accountID)"
+ }
+}
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index 3bc3d99..d99d53a 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -1,7 +1,7 @@
import SwiftUI
struct BuildListView: View {
- @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
+ @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
@AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0
@AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = ""
@Environment(AppState.self) private var appState
@@ -104,7 +104,7 @@ struct BuildListView: View {
}
.task {
if viewModel == nil {
- let vm = BuildListViewModel(client: appState.client)
+ let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults)
vm.repoFilter = savedRepoFilter
viewModel = vm
await vm.loadJobs()
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 426d351..abde827 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -1,7 +1,7 @@
import SwiftUI
struct HomeView: View {
- @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
+ @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
@AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true
@AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true
@AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true
@@ -39,7 +39,9 @@ struct HomeView: View {
let newViewModel = HomeViewModel(
currentUser: currentUser,
client: appState.client,
- systemStatusRepository: appState.systemStatusRepository
+ systemStatusRepository: appState.systemStatusRepository,
+ defaults: appState.accountDefaults,
+ accountID: appState.activeAccountID
)
viewModel = newViewModel
vm = newViewModel
@@ -834,7 +836,7 @@ private struct HomeAttentionLinkRow<Destination: View>: View {
private struct HomeAssignedTicketsListView: View {
let viewModel: HomeViewModel
- @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
+ @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
var body: some View {
List {
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 682c94d..a28f731 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -321,11 +321,22 @@ final class HomeViewModel {
}
"""
- init(currentUser: User, client: SRHTClient, systemStatusRepository: SystemStatusRepository) {
+ private let defaults: UserDefaults
+ private let accountID: String
+
+ init(
+ currentUser: User,
+ client: SRHTClient,
+ systemStatusRepository: SystemStatusRepository,
+ defaults: UserDefaults,
+ accountID: String
+ ) {
self.currentUser = currentUser
self.client = client
self.systemStatusRepository = systemStatusRepository
self.projectService = ProjectService(client: client)
+ self.defaults = defaults
+ self.accountID = accountID
}
func loadDashboard() async {
@@ -403,7 +414,7 @@ final class HomeViewModel {
}
var pinnedProjects: [Project] {
- let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey)
+ let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults)
guard !pinnedIDs.isEmpty else { return [] }
let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
@@ -411,7 +422,7 @@ final class HomeViewModel {
}
var hasPinnedProjects: Bool {
- !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey).isEmpty
+ !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty
}
var failedBuildCount: Int {
@@ -592,7 +603,7 @@ final class HomeViewModel {
}
func markInboxThreadRead(_ thread: InboxThreadSummary) {
- InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: defaults)
unreadInboxThreads.removeAll { $0.id == thread.id }
unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
@@ -600,7 +611,7 @@ final class HomeViewModel {
}
func markInboxThreadUnread(_ thread: InboxThreadSummary) {
- InboxReadStateStore.markUnread(for: thread.id)
+ InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
unreadInboxThreads.append(
InboxThreadSummary(
@@ -786,7 +797,8 @@ final class HomeViewModel {
containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
isUnread: InboxReadStateStore.isUnread(
threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
- lastActivityAt: thread.updated
+ lastActivityAt: thread.updated,
+ defaults: defaults
)
)
return summary.isUnread ? summary : nil
@@ -944,7 +956,8 @@ final class HomeViewModel {
}.count
: nil,
updatedAt: .now
- )
+ ),
+ accountID: accountID
)
}
@@ -966,7 +979,7 @@ final class HomeViewModel {
bannerSummary: snapshot.bannerSummary,
updatedAt: .now
)
- SystemStatusWidgetSnapshotStore.save(widgetSnapshot)
+ SystemStatusWidgetSnapshotStore.save(widgetSnapshot, accountID: accountID)
}
nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index f8753d7..c5299ec 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -22,7 +22,11 @@ struct InboxView: View {
if let viewModel {
vm = viewModel
} else {
- let newViewModel = InboxViewModel(client: appState.client)
+ let newViewModel = InboxViewModel(
+ client: appState.client,
+ defaults: appState.accountDefaults,
+ accountID: appState.activeAccountID
+ )
viewModel = newViewModel
vm = newViewModel
}
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index a01f836..8fff2dc 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -72,6 +72,8 @@ final class InboxViewModel {
var searchText = ""
private let client: SRHTClient
+ private let defaults: UserDefaults
+ private let accountID: String
private let listThreadFetchLimit = 10
private let listFetchConcurrencyLimit = 4
@@ -121,8 +123,10 @@ final class InboxViewModel {
}
"""
- init(client: SRHTClient) {
+ init(client: SRHTClient, defaults: UserDefaults, accountID: String) {
self.client = client
+ self.defaults = defaults
+ self.accountID = accountID
}
func loadThreads() async {
@@ -143,7 +147,7 @@ final class InboxViewModel {
}
return lhs.lastActivityAt > rhs.lastActivityAt
}
- NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
+ NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
} catch {
inboxListLogger.error("Inbox request failed")
self.error = "Failed to load inbox"
@@ -152,9 +156,9 @@ final class InboxViewModel {
func markThreadRead(_ thread: InboxThreadSummary) {
let viewedAt = max(Date(), thread.lastActivityAt)
- InboxReadStateStore.markViewed(viewedAt, for: thread.id)
+ InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
threads.removeAll { $0.id == thread.id }
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
}
func markAllThreadsRead() {
@@ -162,17 +166,17 @@ final class InboxViewModel {
let viewedAt = Date()
for thread in threads where thread.isUnread {
- InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id)
+ InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
}
threads.removeAll { $0.isUnread }
- NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
+ NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
}
func markThreadUnread(_ thread: InboxThreadSummary) {
- InboxReadStateStore.markUnread(for: thread.id)
+ InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
updateThread(thread, isUnread: true)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
}
func toggleThreadReadState(_ thread: InboxThreadSummary) {
@@ -291,7 +295,7 @@ final class InboxViewModel {
return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
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())"
- let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
+ let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated, defaults: defaults)
return InboxThreadSummary(
rootEmailID: thread.root.id,
rootMessageID: thread.root.messageID,
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index 6cefcf1..e53dd33 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -339,7 +339,7 @@ struct LookupView: View {
.navigationTitle("Look Up")
.task {
if viewModel == nil {
- viewModel = LookupViewModel(client: appState.client, appState: appState)
+ viewModel = LookupViewModel(client: appState.client, appState: appState, defaults: appState.accountDefaults)
}
}
}
@@ -440,16 +440,16 @@ struct LookupView: View {
ThreadDetailView(
thread: thread,
onViewed: {
- InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
},
onMarkRead: {
- InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
},
onMarkUnread: {
- InboxReadStateStore.markUnread(for: thread.id)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
+ InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
}
)
case .manPageBrowser:
diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift
index 068e05f..84375de 100644
--- a/Hutch/Views/Lookup/UserProfileView.swift
+++ b/Hutch/Views/Lookup/UserProfileView.swift
@@ -2,7 +2,7 @@ import SwiftUI
struct UserProfileView: View {
@Environment(AppState.self) private var appState
- @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
+ @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
let user: User
@State private var profileViewModel: UserProfileViewModel?
diff --git a/Hutch/Views/More/AccountSwitcherView.swift b/Hutch/Views/More/AccountSwitcherView.swift
index aefcc82..5fde9c5 100644
--- a/Hutch/Views/More/AccountSwitcherView.swift
+++ b/Hutch/Views/More/AccountSwitcherView.swift
@@ -7,21 +7,35 @@ struct AccountSwitcherView: View {
@State private var showAddAccount = false
@State private var isSwitching = false
@State private var switchError: String?
+ @State private var pendingRemoval: AccountEntry?
var body: some View {
NavigationStack {
List {
Section {
ForEach(appState.accounts) { account in
+ let isActive = account.id == appState.activeAccountID
Button {
- guard account.id != appState.activeAccountID else { return }
+ guard !isActive else { return }
switchTo(account)
} label: {
- HStack {
- Text("~\(account.username)")
- .foregroundStyle(.primary)
+ HStack(spacing: 12) {
+ if isActive {
+ Image(systemName: "person.crop.circle.fill")
+ .foregroundStyle(.tint)
+ } else {
+ Image(systemName: "person.crop.circle")
+ .foregroundStyle(.secondary)
+ }
+ VStack(alignment: .leading, spacing: 2) {
+ Text("~\(account.username)")
+ .foregroundStyle(.primary)
+ Text(isActive ? "Active Account" : "Tap to switch")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
Spacer()
- if account.id == appState.activeAccountID {
+ if isActive {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
@@ -31,8 +45,7 @@ struct AccountSwitcherView: View {
}
.onDelete { indexSet in
for index in indexSet {
- let account = appState.accounts[index]
- Task { await appState.removeAccount(id: account.id) }
+ pendingRemoval = appState.accounts[index]
}
}
}
@@ -71,6 +84,28 @@ struct AccountSwitcherView: View {
} message: {
Text(switchError ?? "")
}
+ .alert(
+ "Remove Account?",
+ isPresented: Binding(
+ get: { pendingRemoval != nil },
+ set: { isPresented in
+ if !isPresented {
+ pendingRemoval = nil
+ }
+ }
+ )
+ ) {
+ Button("Cancel", role: .cancel) {}
+ Button("Remove", role: .destructive) {
+ guard let pendingRemoval else { return }
+ Task { await appState.removeAccount(id: pendingRemoval.id) }
+ self.pendingRemoval = nil
+ }
+ } message: {
+ if let pendingRemoval {
+ Text("~\(pendingRemoval.username) and its isolated local cache will be removed from this device.")
+ }
+ }
.sheet(isPresented: $showAddAccount) {
AddAccountView()
}
diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift
index 673b298..db3cb53 100644
--- a/Hutch/Views/More/ProfileView.swift
+++ b/Hutch/Views/More/ProfileView.swift
@@ -7,7 +7,7 @@ private let profileBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
struct ProfileView: View {
@Environment(AppState.self) private var appState
- @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
+ @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
@State private var viewModel: SettingsViewModel?
@State private var contributionViewModel: UserProfileViewModel?
@State private var pendingDestructiveAction: ProfileDestructiveAction?
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
index ffd49ab..0fdddd7 100644
--- a/Hutch/Views/Pastes/PasteListView.swift
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -1,7 +1,7 @@
import SwiftUI
struct PasteListView: View {
- @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
+ @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
@Environment(AppState.self) private var appState
@State private var viewModel: PasteListViewModel?
@State private var showCreatePasteSheet = false
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift
index 11758ae..dc8b24d 100644
--- a/Hutch/Views/Projects/ProjectDetailView.swift
+++ b/Hutch/Views/Projects/ProjectDetailView.swift
@@ -22,7 +22,7 @@ struct ProjectDetailView: View {
private var isPinnedToHome: Bool {
_ = pinChangeCount
guard let currentUserKey else { return false }
- return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey)
+ return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
}
var body: some View {
@@ -243,7 +243,7 @@ struct ProjectDetailView: View {
private func togglePinnedState() {
guard let currentUserKey else { return }
- ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey)
+ ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
pinChangeCount += 1
}
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index 832c889..478e471 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -36,6 +36,8 @@ final class MailingListDetailViewModel {
private let mailingList: InboxMailingListReference
private let client: SRHTClient
+ private let defaults: UserDefaults
+ private let accountID: String
private static let listThreadsQuery = """
query projectMailingListThreads($rid: ID!) {
@@ -57,9 +59,11 @@ final class MailingListDetailViewModel {
}
"""
- init(mailingList: InboxMailingListReference, client: SRHTClient) {
+ init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) {
self.mailingList = mailingList
self.client = client
+ self.defaults = defaults
+ self.accountID = accountID
}
var filteredThreads: [InboxThreadSummary] {
@@ -90,15 +94,15 @@ final class MailingListDetailViewModel {
func markThreadRead(_ thread: InboxThreadSummary) {
let viewedAt = max(Date(), thread.lastActivityAt)
- InboxReadStateStore.markViewed(viewedAt, for: thread.id)
+ InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
updateThread(thread, isUnread: false)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
}
func markThreadUnread(_ thread: InboxThreadSummary) {
- InboxReadStateStore.markUnread(for: thread.id)
+ InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
updateThread(thread, isUnread: true)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
}
private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
@@ -124,7 +128,7 @@ final class MailingListDetailViewModel {
messageCount: thread.replies + 1,
repo: nil,
containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
- isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated, defaults: defaults)
)
}
@@ -238,7 +242,12 @@ struct MailingListDetailView: View {
.navigationBarTitleDisplayMode(.inline)
.task {
if viewModel == nil {
- let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client)
+ let viewModel = MailingListDetailViewModel(
+ mailingList: mailingList,
+ client: appState.client,
+ defaults: appState.accountDefaults,
+ accountID: appState.activeAccountID
+ )
self.viewModel = viewModel
await viewModel.loadThreads()
}
@@ -261,16 +270,16 @@ struct MailingListDetailView: View {
ThreadDetailView(
thread: thread,
onViewed: {
- InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
},
onMarkRead: {
- InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
},
onMarkUnread: {
- InboxReadStateStore.markUnread(for: thread.id)
- NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
+ InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
}
)
} label: {
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
index f5ca716..68dd0b0 100644
--- a/Hutch/Views/Repositories/RepositoryListView.swift
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -59,7 +59,7 @@ struct RepositoryListView: View {
}
.task {
if viewModel == nil {
- viewModel = RepositoryListViewModel(client: appState.client)
+ viewModel = RepositoryListViewModel(client: appState.client, defaults: appState.accountDefaults)
}
}
}
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 5abac90..b6ff8dd 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -2,10 +2,10 @@ import SwiftUI
struct SettingsView: View {
@Environment(AppState.self) private var appState
- @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system
- @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard
- @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
- @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
+ @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
+ @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
+ @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
+ @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
@State private var pendingDestructiveAction: SettingsDestructiveAction?
var body: some View {
@@ -89,7 +89,25 @@ struct SettingsView: View {
HStack {
Image(systemName: "key.fill")
.foregroundStyle(.secondary)
- Text("Personal access token in use")
+ VStack(alignment: .leading, spacing: 2) {
+ Text(appState.currentUser?.canonicalName ?? "No active account")
+ Text("\(appState.accounts.count) saved account\(appState.accounts.count == 1 ? "" : "s")")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
+
+ NavigationLink {
+ AccountSwitcherView()
+ } label: {
+ Label("Manage Accounts", systemImage: "person.2")
+ }
+
+ HStack {
+ Image(systemName: "lock.shield")
+ .foregroundStyle(.secondary)
+ Text("Tokens are stored separately per account in the iOS keychain")
.font(.subheadline)
.foregroundStyle(.secondary)
}
@@ -105,7 +123,7 @@ struct SettingsView: View {
} header: {
Text("Authentication")
} footer: {
- 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.")
+ 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.")
}
}
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index d2548d7..ccd06fe 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -4,7 +4,7 @@ struct TicketListView: View {
let onTrackerUpdated: (TrackerSummary) -> Void
let onTrackerDeleted: (TrackerSummary) -> Void
- @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
+ @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
@State private var tracker: TrackerSummary
@@ -232,7 +232,8 @@ struct TicketListView: View {
trackerName: tracker.name,
trackerId: tracker.id,
trackerRid: tracker.rid,
- client: appState.client
+ client: appState.client,
+ defaults: appState.accountDefaults
)
viewModel = vm
trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
diff --git a/HutchTests/HutchIntentsTests.swift b/HutchTests/HutchIntentsTests.swift
index 94ecbf0..3cb7ad6 100644
--- a/HutchTests/HutchIntentsTests.swift
+++ b/HutchTests/HutchIntentsTests.swift
@@ -72,4 +72,33 @@ struct HutchIntentsTests {
#expect(loaded?.unreadInboxThreads == 5)
#expect(loaded?.assignedOpenTickets == 3)
}
+
+ @Test
+ func needsAttentionSnapshotsAreIsolatedPerAccount() {
+ let defaultsName = "HutchIntentsTests-builds-isolation-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: defaultsName)!
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let firstSnapshot = NeedsAttentionSnapshot(
+ unreadInboxThreads: 1,
+ assignedOpenTickets: 2,
+ failedBuilds: 3,
+ updatedAt: Date(timeIntervalSince1970: 100)
+ )
+ let secondSnapshot = NeedsAttentionSnapshot(
+ unreadInboxThreads: 8,
+ assignedOpenTickets: 5,
+ failedBuilds: 1,
+ updatedAt: Date(timeIntervalSince1970: 200)
+ )
+
+ NeedsAttentionSnapshotStore.save(firstSnapshot, accountID: "account-a", defaults: defaults)
+ NeedsAttentionSnapshotStore.save(secondSnapshot, accountID: "account-b", defaults: defaults)
+
+ #expect(NeedsAttentionSnapshotStore.load(accountID: "account-a", defaults: defaults)?.unreadInboxThreads == 1)
+ #expect(NeedsAttentionSnapshotStore.load(accountID: "account-b", defaults: defaults)?.unreadInboxThreads == 8)
+
+ ActiveAccountContextStore.save("account-a", defaults: defaults)
+ #expect(NeedsAttentionSnapshotStore.load(defaults: defaults)?.failedBuilds == 3)
+ }
}
diff --git a/HutchTests/SystemStatusWidgetSnapshotTests.swift b/HutchTests/SystemStatusWidgetSnapshotTests.swift
index ddb74b1..7121804 100644
--- a/HutchTests/SystemStatusWidgetSnapshotTests.swift
+++ b/HutchTests/SystemStatusWidgetSnapshotTests.swift
@@ -65,6 +65,37 @@ struct SystemStatusWidgetSnapshotTests {
}
@Test
+ func snapshotsAreIsolatedPerAccount() {
+ let defaultsName = "SystemStatusWidgetSnapshotTests-isolation-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: defaultsName)!
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let firstSnapshot = SystemStatusWidgetSnapshot(
+ services: [.init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false)],
+ hasDisruption: false,
+ overallStatusText: "All monitored services operational",
+ bannerSummary: "",
+ updatedAt: Date(timeIntervalSince1970: 100)
+ )
+ let secondSnapshot = SystemStatusWidgetSnapshot(
+ services: [.init(id: "builds", name: "builds.sr.ht", status: "Degraded", requiresAttention: true)],
+ hasDisruption: true,
+ overallStatusText: "Experiencing disruptions",
+ bannerSummary: "builds.sr.ht disrupted",
+ updatedAt: Date(timeIntervalSince1970: 200)
+ )
+
+ SystemStatusWidgetSnapshotStore.save(firstSnapshot, accountID: "account-a", defaults: defaults)
+ SystemStatusWidgetSnapshotStore.save(secondSnapshot, accountID: "account-b", defaults: defaults)
+
+ #expect(SystemStatusWidgetSnapshotStore.load(accountID: "account-a", defaults: defaults)?.services.first?.name == "git.sr.ht")
+ #expect(SystemStatusWidgetSnapshotStore.load(accountID: "account-b", defaults: defaults)?.services.first?.name == "builds.sr.ht")
+
+ ActiveAccountContextStore.save("account-b", defaults: defaults)
+ #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults)?.bannerSummary == "builds.sr.ht disrupted")
+ }
+
+ @Test
func unavailableSnapshotHasEmptyServices() {
let snapshot = SystemStatusWidgetSnapshot.unavailable
#expect(snapshot.services.isEmpty)
diff --git a/Shared/ContributionWidgetContext.swift b/Shared/ContributionWidgetContext.swift
index 2404698..33fed77 100644
--- a/Shared/ContributionWidgetContext.swift
+++ b/Shared/ContributionWidgetContext.swift
@@ -11,8 +11,11 @@ enum ContributionWidgetContextStore {
private static let actorKey = "contributionWidget.actor"
private static let enabledKey = "contributionWidget.enabled"
- static func loadActor(defaults: UserDefaults? = sharedDefaults()) -> String? {
- defaults?.string(forKey: actorKey)
+ static func loadActor(
+ accountID: String? = ActiveAccountContextStore.load(),
+ defaults: UserDefaults? = sharedDefaults()
+ ) -> String? {
+ defaults?.string(forKey: scopedActorKey(for: accountID))
}
static func isEnabled(defaults: UserDefaults? = sharedDefaults()) -> Bool {
@@ -24,13 +27,20 @@ enum ContributionWidgetContextStore {
reloadWidgetTimelines()
}
- static func saveActor(_ actor: String, defaults: UserDefaults? = sharedDefaults()) {
- defaults?.set(actor, forKey: actorKey)
+ static func saveActor(
+ _ actor: String,
+ accountID: String? = ActiveAccountContextStore.load(),
+ defaults: UserDefaults? = sharedDefaults()
+ ) {
+ defaults?.set(actor, forKey: scopedActorKey(for: accountID))
reloadWidgetTimelines()
}
- static func clear(defaults: UserDefaults? = sharedDefaults()) {
- defaults?.removeObject(forKey: actorKey)
+ static func clear(
+ accountID: String? = ActiveAccountContextStore.load(),
+ defaults: UserDefaults? = sharedDefaults()
+ ) {
+ defaults?.removeObject(forKey: scopedActorKey(for: accountID))
reloadWidgetTimelines()
}
@@ -38,6 +48,11 @@ enum ContributionWidgetContextStore {
UserDefaults(suiteName: HutchAppGroup.identifier)
}
+ private static func scopedActorKey(for accountID: String?) -> String {
+ guard let accountID, !accountID.isEmpty else { return actorKey }
+ return "\(actorKey).\(accountID)"
+ }
+
private static func reloadWidgetTimelines() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadTimelines(ofKind: ContributionGraphWidgetConfiguration.kind)
diff --git a/Shared/NeedsAttentionSnapshot.swift b/Shared/NeedsAttentionSnapshot.swift
index f386af6..d4c581c 100644
--- a/Shared/NeedsAttentionSnapshot.swift
+++ b/Shared/NeedsAttentionSnapshot.swift
@@ -7,6 +7,26 @@ enum HutchAppGroup {
static let identifier = "group.net.cleberg.Hutch"
}
+enum ActiveAccountContextStore {
+ private static let activeAccountIDKey = "activeAccount.id"
+
+ static func load(defaults: UserDefaults? = sharedDefaults()) -> String? {
+ defaults?.string(forKey: activeAccountIDKey)
+ }
+
+ static func save(_ accountID: String, defaults: UserDefaults? = sharedDefaults()) {
+ defaults?.set(accountID, forKey: activeAccountIDKey)
+ }
+
+ static func clear(defaults: UserDefaults? = sharedDefaults()) {
+ defaults?.removeObject(forKey: activeAccountIDKey)
+ }
+
+ private static func sharedDefaults() -> UserDefaults? {
+ UserDefaults(suiteName: HutchAppGroup.identifier)
+ }
+}
+
enum NeedsAttentionWidgetConfiguration {
static let kind = "NeedsAttentionWidget"
}
@@ -37,22 +57,29 @@ struct NeedsAttentionSnapshot: Codable, Sendable {
enum NeedsAttentionSnapshotStore {
private static let snapshotKey = "needsAttention.snapshot"
- static func load(defaults: UserDefaults? = sharedDefaults()) -> NeedsAttentionSnapshot? {
+ static func load(
+ accountID: String? = ActiveAccountContextStore.load(),
+ defaults: UserDefaults? = sharedDefaults()
+ ) -> NeedsAttentionSnapshot? {
guard let defaults,
- let data = defaults.data(forKey: snapshotKey) else {
+ let data = defaults.data(forKey: scopedKey(for: accountID)) else {
return nil
}
return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data)
}
- static func save(_ snapshot: NeedsAttentionSnapshot, defaults: UserDefaults? = sharedDefaults()) {
+ static func save(
+ _ snapshot: NeedsAttentionSnapshot,
+ accountID: String? = ActiveAccountContextStore.load(),
+ defaults: UserDefaults? = sharedDefaults()
+ ) {
guard let defaults,
let data = try? JSONEncoder().encode(snapshot) else {
return
}
- defaults.set(data, forKey: snapshotKey)
+ defaults.set(data, forKey: scopedKey(for: accountID))
reloadWidgetTimelines()
}
@@ -60,23 +87,25 @@ enum NeedsAttentionSnapshotStore {
unreadInboxThreads: Int? = nil,
assignedOpenTickets: Int? = nil,
failedBuilds: Int? = nil,
+ accountID: String? = ActiveAccountContextStore.load(),
defaults: UserDefaults? = sharedDefaults()
) {
- let existing = load(defaults: defaults)
+ let existing = load(accountID: accountID, defaults: defaults)
let snapshot = NeedsAttentionSnapshot(
unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads,
assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets,
failedBuilds: failedBuilds ?? existing?.failedBuilds,
updatedAt: .now
)
- save(snapshot, defaults: defaults)
+ save(snapshot, accountID: accountID, defaults: defaults)
}
static func adjustUnreadInboxThreads(
by delta: Int,
+ accountID: String? = ActiveAccountContextStore.load(),
defaults: UserDefaults? = sharedDefaults()
) {
- guard let existing = load(defaults: defaults),
+ guard let existing = load(accountID: accountID, defaults: defaults),
let unreadInboxThreads = existing.unreadInboxThreads else {
return
}
@@ -88,12 +117,16 @@ enum NeedsAttentionSnapshotStore {
failedBuilds: existing.failedBuilds,
updatedAt: .now
),
+ accountID: accountID,
defaults: defaults
)
}
- static func clear(defaults: UserDefaults? = sharedDefaults()) {
- defaults?.removeObject(forKey: snapshotKey)
+ static func clear(
+ accountID: String? = ActiveAccountContextStore.load(),
+ defaults: UserDefaults? = sharedDefaults()
+ ) {
+ defaults?.removeObject(forKey: scopedKey(for: accountID))
reloadWidgetTimelines()
}
@@ -101,6 +134,11 @@ enum NeedsAttentionSnapshotStore {
UserDefaults(suiteName: HutchAppGroup.identifier)
}
+ private static func scopedKey(for accountID: String?) -> String {
+ guard let accountID, !accountID.isEmpty else { return snapshotKey }
+ return "\(snapshotKey).\(accountID)"
+ }
+
private static func reloadWidgetTimelines() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind)
diff --git a/Shared/SystemStatusWidgetSnapshot.swift b/Shared/SystemStatusWidgetSnapshot.swift
index 8489547..0b19365 100644
--- a/Shared/SystemStatusWidgetSnapshot.swift
+++ b/Shared/SystemStatusWidgetSnapshot.swift
@@ -33,25 +33,35 @@ struct SystemStatusWidgetSnapshot: Codable, Sendable {
enum SystemStatusWidgetSnapshotStore {
private static let snapshotKey = "systemStatus.widgetSnapshot"
- static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? {
+ static func load(
+ accountID: String? = ActiveAccountContextStore.load(),
+ defaults: UserDefaults? = sharedDefaults()
+ ) -> SystemStatusWidgetSnapshot? {
guard let defaults,
- let data = defaults.data(forKey: snapshotKey) else {
+ let data = defaults.data(forKey: scopedKey(for: accountID)) else {
return nil
}
return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data)
}
- static func save(_ snapshot: SystemStatusWidgetSnapshot, defaults: UserDefaults? = sharedDefaults()) {
+ static func save(
+ _ snapshot: SystemStatusWidgetSnapshot,
+ accountID: String? = ActiveAccountContextStore.load(),
+ defaults: UserDefaults? = sharedDefaults()
+ ) {
guard let defaults,
let data = try? JSONEncoder().encode(snapshot) else {
return
}
- defaults.set(data, forKey: snapshotKey)
+ defaults.set(data, forKey: scopedKey(for: accountID))
reloadWidgetTimelines()
}
- static func clear(defaults: UserDefaults? = sharedDefaults()) {
- defaults?.removeObject(forKey: snapshotKey)
+ static func clear(
+ accountID: String? = ActiveAccountContextStore.load(),
+ defaults: UserDefaults? = sharedDefaults()
+ ) {
+ defaults?.removeObject(forKey: scopedKey(for: accountID))
reloadWidgetTimelines()
}
@@ -59,6 +69,11 @@ enum SystemStatusWidgetSnapshotStore {
UserDefaults(suiteName: HutchAppGroup.identifier)
}
+ private static func scopedKey(for accountID: String?) -> String {
+ guard let accountID, !accountID.isEmpty else { return snapshotKey }
+ return "\(snapshotKey).\(accountID)"
+ }
+
private static func reloadWidgetTimelines() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind)