From f29326703cc714088e647446e56054a27b9b8a54 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:55:55 -0400 Subject: [PATCH 1/4] cache home status across navigation --- .../Sources/MainWindow/DesktopHomeView.swift | 7 +- .../Sources/MainWindow/Pages/AppsPage.swift | 16 +- .../MainWindow/Pages/DashboardPage.swift | 117 ++----- .../MainWindow/Pages/HomeStatusStore.swift | 202 ++++++++++++ .../Desktop/Sources/ViewModelContainer.swift | 2 + .../Tests/DashboardCaptureStateTests.swift | 46 --- .../Desktop/Tests/HomeStatusStoreTests.swift | 299 ++++++++++++++++++ ...09-home-status-navigation-performance.json | 3 + 8 files changed, 544 insertions(+), 148 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStatusStore.swift create mode 100644 desktop/macos/Desktop/Tests/HomeStatusStoreTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260709-home-status-navigation-performance.json diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 4af117d6c58..580d17ebb17 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -1107,6 +1107,7 @@ private struct PageContentView: View { case 0: DashboardPage( viewModel: viewModelContainer.dashboardViewModel, + homeStatusStore: viewModelContainer.homeStatusStore, appState: appState, appProvider: viewModelContainer.appProvider, chatProvider: viewModelContainer.chatProvider, @@ -1132,7 +1133,10 @@ private struct PageContentView: View { case 7: RewindPage(appState: appState) case 8: - AppsPage(appProvider: viewModelContainer.appProvider, appState: appState) + AppsPage( + appProvider: viewModelContainer.appProvider, + appState: appState, + connectorStatusStore: viewModelContainer.homeStatusStore.connectorStatusStore) case 9: SettingsPage( appState: appState, @@ -1147,6 +1151,7 @@ private struct PageContentView: View { default: DashboardPage( viewModel: viewModelContainer.dashboardViewModel, + homeStatusStore: viewModelContainer.homeStatusStore, appState: appState, appProvider: viewModelContainer.appProvider, chatProvider: viewModelContainer.chatProvider, diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift index 7c9e26b1f00..f032c40b2e5 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import GRDB import SwiftUI import OmiTheme @@ -116,12 +117,12 @@ enum AppsCatalogInitialSection { struct AppsPage: View { @ObservedObject var appProvider: AppProvider var appState: AppState? = nil + @ObservedObject var connectorStatusStore: ImportConnectorStatusStore = ImportConnectorStatusStore() var initialSection: AppsCatalogInitialSection = .imports var onDismiss: (() -> Void)? = nil var onSelectApp: ((OmiApp) -> Void)? = nil var onSelectConnector: ((ImportConnector) -> Void)? = nil var onSelectDestination: ((MemoryExportDestination) -> Void)? = nil - @StateObject private var connectorStatusStore = ImportConnectorStatusStore() @State private var searchText = "" @State private var selectedApp: OmiApp? @State private var selectedConnector: ImportConnector? @@ -739,6 +740,7 @@ final class ImportConnectorStatusStore: ObservableObject { } @Published private var metricsByID: [String: ConnectorMetrics] = [:] + let connectorDidSync = PassthroughSubject() private let defaults: UserDefaults private let sourceCountKeyPrefix = "appsImportConnectorSourceCount." @@ -805,6 +807,7 @@ final class ImportConnectorStatusStore: ObservableObject { defaults.set(availabilityText, forKey: availabilityTextKeyPrefix + connectorID) } metricsByID[connectorID] = metrics + connectorDidSync.send(connectorID) } private func clearStoredMetrics(for connectorID: String) { @@ -818,10 +821,15 @@ final class ImportConnectorStatusStore: ObservableObject { } func refresh() async { + refreshPersistedManualImportMetrics() await refreshLocalFilesMetrics() await refreshAppleNotesMetrics() } + func refreshPersistedManualImportMetrics() { + hydrateLegacyManualImports() + } + private func load() { for connector in ImportConnector.all { var metrics = ConnectorMetrics() @@ -854,7 +862,8 @@ final class ImportConnectorStatusStore: ObservableObject { private func hydrateLegacyManualImports() { let legacyChatGPTCount = defaults.integer(forKey: onboardingChatGPTImportedMemoriesKey) - if legacyChatGPTCount > 0, metricsByID["chatgpt"]?.memoryCount == nil { + if legacyChatGPTCount > 0, + defaults.object(forKey: memoryCountKeyPrefix + "chatgpt") == nil { var metrics = metricsByID["chatgpt"] ?? ConnectorMetrics() metrics.memoryCount = legacyChatGPTCount metrics.availabilityText = "Imported during onboarding" @@ -862,7 +871,8 @@ final class ImportConnectorStatusStore: ObservableObject { } let legacyClaudeCount = defaults.integer(forKey: onboardingClaudeImportedMemoriesKey) - if legacyClaudeCount > 0, metricsByID["claude"]?.memoryCount == nil { + if legacyClaudeCount > 0, + defaults.object(forKey: memoryCountKeyPrefix + "claude") == nil { var metrics = metricsByID["claude"] ?? ConnectorMetrics() metrics.memoryCount = legacyClaudeCount metrics.availabilityText = "Imported during onboarding" diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 14b23c4d57e..661ca9696e4 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -214,12 +214,12 @@ class DashboardViewModel: ObservableObject { struct DashboardPage: View { @ObservedObject var viewModel: DashboardViewModel + @ObservedObject var homeStatusStore: HomeStatusStore = HomeStatusStore() @ObservedObject var appState: AppState @ObservedObject var appProvider: AppProvider @ObservedObject var chatProvider: ChatProvider @ObservedObject var memoriesViewModel: MemoriesViewModel @ObservedObject private var deviceProvider = DeviceProvider.shared - @StateObject private var importConnectorStatusStore = ImportConnectorStatusStore() @Binding var selectedIndex: Int @State private var citedConversation: ServerConversation? = nil @State private var selectedCatalogApp: OmiApp? @@ -231,18 +231,6 @@ struct DashboardPage: View { @State private var appsPopupInitialSection: AppsCatalogInitialSection = .imports @State private var appsPopupPresentationID = UUID() @State private var isLoadingCitation = false - @State private var screenshotCount: Int? - // True totals for the "What omi knows" tiles. Without these the tiles showed - // only the loaded page (~50 conversations, ~100 memories), badly undercounting. - @State private var conversationCount: Int? - @State private var memoryCount: Int? - @State private var taskCount: Int? - // Wearable used on this account (any friend/omi-sourced conversation). - // Seeded from UserDefaults so the badge is instant on later launches. - @State private var accountHasOmiDeviceConversations = UserDefaults.standard.bool( - forKey: DashboardPage.omiDeviceHistoryDefaultsKey) - @State private var memoryExportStatuses: [MemoryExportDestination: MemoryExportStatus] = [:] - @State private var lastHomeStatusRefreshAt = Date.distantPast @State private var isCaptureMonitoring = false @State private var isTogglingCapture = false @State private var isTogglingListening = false @@ -291,7 +279,6 @@ struct DashboardPage: View { } } - private static let omiDeviceHistoryDefaultsKey = "home-omi-device-account-history" private static let homeStageMaxWidth: CGFloat = 1360 private static let homeStageMinSideInset: CGFloat = 30 private static let homeStageMaxSideInset: CGFloat = 96 @@ -348,23 +335,23 @@ struct DashboardPage: View { private var hasOmiDeviceHistory: Bool { deviceProvider.connectedDevice != nil || deviceProvider.pairedDevice != nil - || accountHasOmiDeviceConversations + || homeStatusStore.accountHasOmiDeviceConversations } /// Real persisted import-connector state (UserDefaults-backed via ImportConnectorStatusStore). private func isImportConnectorConnected(_ connectorID: String) -> Bool { guard let connector = ImportConnector.all.first(where: { $0.id == connectorID }) else { return false } - return importConnectorStatusStore.snapshot(for: connector).isConnected + return homeStatusStore.connectorStatusStore.snapshot(for: connector).isConnected } private func isMCPDestinationConnected(_ destination: MemoryExportDestination) -> Bool { switch destination { case .claude, .claudeCode: - return [.claude, .claudeCode].contains { memoryExportStatuses[$0]?.hasConnection == true } + return [.claude, .claudeCode].contains { homeStatusStore.memoryExportStatuses[$0]?.hasConnection == true } case .chatgpt, .codex: - return [.chatgpt, .codex].contains { memoryExportStatuses[$0]?.hasConnection == true } + return [.chatgpt, .codex].contains { homeStatusStore.memoryExportStatuses[$0]?.hasConnection == true } default: - return memoryExportStatuses[destination]?.hasConnection == true + return homeStatusStore.memoryExportStatuses[destination]?.hasConnection == true } } @@ -398,7 +385,7 @@ struct DashboardPage: View { ImportConnectorSheet( connector: connector, appState: appState, - statusStore: importConnectorStatusStore, + statusStore: homeStatusStore.connectorStatusStore, onDismiss: { selectedImportConnector = nil } @@ -408,7 +395,7 @@ struct DashboardPage: View { .dismissableSheet(item: legacySelectedExportDestination) { destination in ConnectDestinationSheet( destination: destination, - statuses: $memoryExportStatuses, + statuses: $homeStatusStore.memoryExportStatuses, onDismiss: { selectedExportDestination = nil } @@ -437,13 +424,13 @@ struct DashboardPage: View { } syncCaptureState() reportHomeAutomationMode() - Task { await refreshHomeStatusData(force: true) } + Task { await homeStatusStore.refreshIfNeeded() } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in viewModel.refreshGoals() appState.checkAllPermissions() syncCaptureState() - Task { await refreshHomeStatusData(force: false) } + Task { await homeStatusStore.refreshIfNeeded() } } .onReceive(NotificationCenter.default.publisher(for: .assistantMonitoringStateDidChange)) { _ in syncCaptureState() @@ -1100,6 +1087,7 @@ struct DashboardPage: View { AppsPage( appProvider: appProvider, appState: appState, + connectorStatusStore: homeStatusStore.connectorStatusStore, initialSection: appsPopupInitialSection, onDismiss: { dismissAppsPopup() @@ -1223,7 +1211,7 @@ struct DashboardPage: View { ImportConnectorSheet( connector: connector, appState: appState, - statusStore: importConnectorStatusStore, + statusStore: homeStatusStore.connectorStatusStore, onDismiss: { dismissHomeConnectSheet() } @@ -1231,7 +1219,7 @@ struct DashboardPage: View { } else if let destination = selectedExportDestination { ConnectDestinationSheet( destination: destination, - statuses: $memoryExportStatuses, + statuses: $homeStatusStore.memoryExportStatuses, onDismiss: { dismissHomeConnectSheet() } @@ -1356,22 +1344,24 @@ struct DashboardPage: View { } private var conversationMetricValue: String { - formattedCount(conversationCount ?? appState.totalConversationsCount ?? appState.conversations.count) + formattedCount( + homeStatusStore.conversationCount ?? appState.totalConversationsCount ?? appState.conversations.count + ) } private var taskMetricValue: String { - formattedCount(taskCount ?? incompleteTaskCount) + formattedCount(homeStatusStore.taskCount ?? incompleteTaskCount) } private var memoryMetricValue: String { - let count = memoryCount ?? (memoriesViewModel.totalMemoriesCount > 0 + let count = homeStatusStore.memoryCount ?? (memoriesViewModel.totalMemoriesCount > 0 ? memoriesViewModel.totalMemoriesCount : memoriesViewModel.memories.count) return formattedCount(count) } private var screenshotMetricValue: String { - screenshotCount.map(formattedCount) ?? "—" + homeStatusStore.screenshotCount.map(formattedCount) ?? "—" } private func navigate(to item: SidebarNavItem) { @@ -1546,75 +1536,6 @@ struct DashboardPage: View { isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring } - private func loadScreenshotCount() async { - let stats = await RewindIndexer.shared.getStats() - await MainActor.run { - screenshotCount = stats?.total - } - } - - /// Refreshes Home-only status tiles and connector rows. Forced loads run - /// when the Home view is recreated; activation-triggered loads share the - /// app-wide cooldown so Cmd-Tab bursts do not rescan configs or hit count - /// endpoints repeatedly while Home is still mounted. - private func refreshHomeStatusData(force: Bool) async { - let shouldRefresh = await MainActor.run { - let now = Date() - if !force, - !PollingConfig.shouldAllowActivationRefresh( - now: now, - lastRefresh: lastHomeStatusRefreshAt - ) { - return false - } - lastHomeStatusRefreshAt = now - return true - } - guard shouldRefresh else { return } - - async let importConnectorStatuses: Void = importConnectorStatusStore.refresh() - async let screenshots: Void = loadScreenshotCount() - async let knowledgeCounts: Void = loadKnowledgeCounts() - async let exportStatuses: Void = loadMemoryExportStatuses() - _ = await (importConnectorStatuses, screenshots, knowledgeCounts, exportStatuses) - } - - private func loadMemoryExportStatuses() async { - let statuses = await MemoryExportService.shared.allStatuses() - await MainActor.run { - memoryExportStatuses = statuses - } - } - - /// Load the true totals behind the "What omi knows" tiles. Conversations come - /// from the server count endpoint (not stored locally); memories and tasks are - /// counted from the synced local DB — the same totals the detail pages show. - private func loadKnowledgeCounts() async { - async let convos = try? APIClient.shared.getConversationsCount(includeDiscarded: false) - async let mems = try? MemoryStorage.shared.getLocalMemoriesCount() - // Open tasks only (matches the "Tasks" label and the old tile's intent — - // the old value just under-counted, capping each bucket at a 7-day window). - async let tasks = try? ActionItemStorage.shared.getLocalActionItemsCount(completed: false) - let shouldLoadDeviceHistory = await MainActor.run { !accountHasOmiDeviceConversations } - async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil - let (c, m, t, d) = await (convos, mems, tasks, deviceHistory) - await MainActor.run { - if let c { conversationCount = c } - if let m { memoryCount = m } - if let t { taskCount = t } - // Sticky: device history never un-happens; keep the badge across - // launches and network failures once observed. - if d == true { - accountHasOmiDeviceConversations = true - UserDefaults.standard.set(true, forKey: Self.omiDeviceHistoryDefaultsKey) - } - } - } - - private func loadOmiDeviceHistory() async -> Bool? { - try? await APIClient.shared.hasOmiDeviceConversations() - } - private func formattedCount(_ count: Int) -> String { count.formatted() } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStatusStore.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStatusStore.swift new file mode 100644 index 00000000000..51d06c4ba1f --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStatusStore.swift @@ -0,0 +1,202 @@ +import Combine +import Foundation + +struct HomeKnowledgeCounts: Equatable, Sendable { + let conversations: Int? + let memories: Int? + let tasks: Int? + let hasOmiDeviceConversations: Bool? +} + +@MainActor +struct HomeStatusLoader { + let refreshConnectorStatuses: () async -> Void + let loadScreenshotCount: () async -> Int? + let loadKnowledgeCounts: (_ includeOmiDeviceHistory: Bool) async -> HomeKnowledgeCounts + let loadMemoryExportStatuses: () async -> [MemoryExportDestination: MemoryExportStatus] + + static func live(connectorStatusStore: ImportConnectorStatusStore) -> HomeStatusLoader { + HomeStatusLoader( + refreshConnectorStatuses: { + await connectorStatusStore.refresh() + }, + loadScreenshotCount: { + let stats = await RewindIndexer.shared.getStats() + return stats?.total + }, + loadKnowledgeCounts: { includeOmiDeviceHistory in + async let conversations = try? APIClient.shared.getConversationsCount(includeDiscarded: false) + async let memories = try? MemoryStorage.shared.getLocalMemoriesCount() + async let tasks = try? ActionItemStorage.shared.getLocalActionItemsCount(completed: false) + async let deviceHistory = includeOmiDeviceHistory + ? (try? APIClient.shared.hasOmiDeviceConversations()) + : nil + + return await HomeKnowledgeCounts( + conversations: conversations, + memories: memories, + tasks: tasks, + hasOmiDeviceConversations: deviceHistory + ) + }, + loadMemoryExportStatuses: { + await MemoryExportService.shared.allStatuses() + } + ) + } +} + +/// Long-lived cache for status data shown on Home. The view is intentionally +/// recreated during navigation; this store is owned by ViewModelContainer so +/// returning to Home can render cached values without repeating provider scans. +@MainActor +final class HomeStatusStore: ObservableObject { + static let omiDeviceHistoryDefaultsKey = "home-omi-device-account-history" + + let connectorStatusStore: ImportConnectorStatusStore + + @Published private(set) var screenshotCount: Int? + @Published private(set) var conversationCount: Int? + @Published private(set) var memoryCount: Int? + @Published private(set) var taskCount: Int? + @Published private(set) var accountHasOmiDeviceConversations: Bool + @Published var memoryExportStatuses: [MemoryExportDestination: MemoryExportStatus] = [:] + + private let defaults: UserDefaults + private let loader: HomeStatusLoader + private var lastRefreshAt = Date.distantPast + private var refreshTask: Task? + private var refreshID: UUID? + private var latestKnowledgeRefreshID: UUID? + private var refreshGeneration = 0 + private var cancellables = Set() + + init( + connectorStatusStore: ImportConnectorStatusStore? = nil, + defaults: UserDefaults = .standard, + loader: HomeStatusLoader? = nil + ) { + let connectorStatusStore = connectorStatusStore ?? ImportConnectorStatusStore(defaults: defaults) + self.connectorStatusStore = connectorStatusStore + self.defaults = defaults + self.loader = loader ?? .live(connectorStatusStore: connectorStatusStore) + accountHasOmiDeviceConversations = defaults.bool(forKey: Self.omiDeviceHistoryDefaultsKey) + + connectorStatusStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + connectorStatusStore.connectorDidSync + .sink { [weak self] _ in + self?.refreshKnowledgeCountsAfterImport() + } + .store(in: &cancellables) + } + + func refreshIfNeeded(force: Bool = false, now: Date = Date()) async { + if let refreshTask { + await refreshTask.value + return + } + + guard force || PollingConfig.shouldAllowActivationRefresh(now: now, lastRefresh: lastRefreshAt) else { + return + } + + lastRefreshAt = now + let generation = refreshGeneration + let refreshID = UUID() + let task = Task { [weak self] in + guard let self else { return } + await self.performRefresh(generation: generation) + } + self.refreshID = refreshID + refreshTask = task + await task.value + if self.refreshID == refreshID { + self.refreshID = nil + refreshTask = nil + } + } + + func resetSessionState() { + refreshGeneration += 1 + refreshTask?.cancel() + refreshTask = nil + refreshID = nil + latestKnowledgeRefreshID = nil + lastRefreshAt = .distantPast + screenshotCount = nil + conversationCount = nil + memoryCount = nil + taskCount = nil + memoryExportStatuses = [:] + accountHasOmiDeviceConversations = defaults.bool(forKey: Self.omiDeviceHistoryDefaultsKey) + } + + private func performRefresh(generation: Int) async { + let includeDeviceHistory = !accountHasOmiDeviceConversations + let knowledgeRefreshID = beginKnowledgeRefresh() + async let connectorStatuses: Void = loader.refreshConnectorStatuses() + async let screenshots = loader.loadScreenshotCount() + async let knowledgeCounts = loader.loadKnowledgeCounts(includeDeviceHistory) + async let exportStatuses = loader.loadMemoryExportStatuses() + let (_, loadedScreenshots, loadedKnowledgeCounts, loadedExportStatuses) = await ( + connectorStatuses, + screenshots, + knowledgeCounts, + exportStatuses + ) + + guard !Task.isCancelled, generation == refreshGeneration else { return } + apply(screenshotCount: loadedScreenshots) + if knowledgeRefreshID == latestKnowledgeRefreshID { + apply(knowledgeCounts: loadedKnowledgeCounts) + } + memoryExportStatuses = loadedExportStatuses + } + + private func refreshKnowledgeCountsAfterImport() { + let generation = refreshGeneration + let knowledgeRefreshID = beginKnowledgeRefresh() + Task { [weak self] in + guard let self else { return } + let counts = await self.loader.loadKnowledgeCounts(!self.accountHasOmiDeviceConversations) + guard !Task.isCancelled, + generation == self.refreshGeneration, + knowledgeRefreshID == self.latestKnowledgeRefreshID + else { return } + self.apply(knowledgeCounts: counts) + } + } + + private func beginKnowledgeRefresh() -> UUID { + let refreshID = UUID() + latestKnowledgeRefreshID = refreshID + return refreshID + } + + private func apply(screenshotCount: Int?) { + if let screenshotCount { + self.screenshotCount = screenshotCount + } + } + + private func apply(knowledgeCounts: HomeKnowledgeCounts) { + if let conversations = knowledgeCounts.conversations { + conversationCount = conversations + } + if let memories = knowledgeCounts.memories { + memoryCount = memories + } + if let tasks = knowledgeCounts.tasks { + taskCount = tasks + } + if knowledgeCounts.hasOmiDeviceConversations == true { + accountHasOmiDeviceConversations = true + defaults.set(true, forKey: Self.omiDeviceHistoryDefaultsKey) + } + } +} diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index b7777e7899f..1101427f865 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -9,6 +9,7 @@ class ViewModelContainer: ObservableObject { // ViewModels for each page let dashboardViewModel = DashboardViewModel() + let homeStatusStore = HomeStatusStore() let tasksViewModel = TasksViewModel() let appProvider = AppProvider() let memoriesViewModel = MemoriesViewModel() @@ -131,6 +132,7 @@ class ViewModelContainer: ObservableObject { warmupCoordinator.reset() tasksStore.resetSessionState() dashboardViewModel.resetSessionState() + homeStatusStore.resetSessionState() memoriesViewModel.resetSessionState() appProvider.resetSessionState() isInitialLoadComplete = false diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index c7633099a0a..fdec5bd9b4a 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -184,52 +184,6 @@ final class DashboardCaptureStateTests: XCTestCase { XCTAssertTrue(connectDismissMethod.contains("selectedExportDestination = nil")) } - func testHomeStatusRefreshUsesSharedActivationThrottle() throws { - let source = try dashboardSource() - let normalizedSource = normalizedWhitespace(source) - let method = try methodBody(named: "refreshHomeStatusData", in: source) - - XCTAssertTrue(source.contains("@State private var lastHomeStatusRefreshAt = Date.distantPast")) - XCTAssertTrue(normalizedSource.contains("syncCaptureState() reportHomeAutomationMode() Task { await refreshHomeStatusData(force: true) }")) - XCTAssertTrue( - normalizedSource.contains( - "viewModel.refreshGoals() appState.checkAllPermissions() syncCaptureState() Task { await refreshHomeStatusData(force: false) }" - ) - ) - XCTAssertTrue(method.contains("PollingConfig.shouldAllowActivationRefresh")) - XCTAssertTrue(method.contains("lastRefresh: lastHomeStatusRefreshAt")) - XCTAssertTrue(method.contains("lastHomeStatusRefreshAt = now")) - XCTAssertTrue(method.contains("async let importConnectorStatuses: Void = importConnectorStatusStore.refresh()")) - XCTAssertTrue(method.contains("async let screenshots: Void = loadScreenshotCount()")) - XCTAssertTrue(method.contains("async let knowledgeCounts: Void = loadKnowledgeCounts()")) - XCTAssertTrue(method.contains("async let exportStatuses: Void = loadMemoryExportStatuses()")) - XCTAssertFalse(source.contains("memoryExportStatusActiveRefreshThrottle")) - XCTAssertFalse(source.contains("lastMemoryExportStatusRefreshAt")) - XCTAssertFalse(source.contains("loadMemoryExportStatuses(force:")) - } - - func testMemoryExportStatusesRefreshInsideHomeStatusGate() throws { - let source = try dashboardSource() - let method = try methodBody(named: "loadMemoryExportStatuses", in: source) - - XCTAssertTrue(method.contains("let statuses = await MemoryExportService.shared.allStatuses()")) - XCTAssertTrue(method.contains("memoryExportStatuses = statuses")) - XCTAssertFalse(method.contains("PollingConfig.shouldAllowActivationRefresh")) - XCTAssertFalse(method.contains("lastHomeStatusRefreshAt")) - XCTAssertFalse(method.contains("memoryExportStatusActiveRefreshThrottle")) - } - - func testOmiDeviceHistorySkipsNetworkAfterStickyFlag() throws { - let source = try dashboardSource() - let method = try methodBody(named: "loadKnowledgeCounts", in: source) - let helper = try methodBody(named: "loadOmiDeviceHistory", in: source) - - XCTAssertTrue(method.contains("let shouldLoadDeviceHistory = await MainActor.run { !accountHasOmiDeviceConversations }")) - XCTAssertTrue(method.contains("async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil")) - XCTAssertTrue(helper.contains("APIClient.shared.hasOmiDeviceConversations()")) - XCTAssertTrue(method.contains("UserDefaults.standard.set(true, forKey: Self.omiDeviceHistoryDefaultsKey)")) - } - func testConnectorRowsUseStatusConnectionForConnectedState() throws { let destinationSheet = try source(named: "MemoryExportDestinationSheet.swift") let groupedSheet = try source(named: "AgentConnectPickerSheet.swift") diff --git a/desktop/macos/Desktop/Tests/HomeStatusStoreTests.swift b/desktop/macos/Desktop/Tests/HomeStatusStoreTests.swift new file mode 100644 index 00000000000..bb88c3a6c2a --- /dev/null +++ b/desktop/macos/Desktop/Tests/HomeStatusStoreTests.swift @@ -0,0 +1,299 @@ +import XCTest + +@testable import Omi_Computer + +@MainActor +final class HomeStatusStoreTests: XCTestCase { + func testRefreshUsesCachedValuesUntilCooldownExpires() async { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + + var connectorLoads = 0 + var screenshotLoads = 0 + var knowledgeLoads = 0 + var exportLoads = 0 + let connectorStore = ImportConnectorStatusStore(defaults: defaults) + let store = HomeStatusStore( + connectorStatusStore: connectorStore, + defaults: defaults, + loader: HomeStatusLoader( + refreshConnectorStatuses: { connectorLoads += 1 }, + loadScreenshotCount: { + screenshotLoads += 1 + return screenshotLoads + }, + loadKnowledgeCounts: { _ in + knowledgeLoads += 1 + return HomeKnowledgeCounts( + conversations: knowledgeLoads, + memories: knowledgeLoads, + tasks: knowledgeLoads, + hasOmiDeviceConversations: false + ) + }, + loadMemoryExportStatuses: { + exportLoads += 1 + return [:] + } + ) + ) + let start = Date(timeIntervalSince1970: 10_000) + + await store.refreshIfNeeded(now: start) + await store.refreshIfNeeded(now: start.addingTimeInterval(1)) + + XCTAssertEqual(connectorLoads, 1) + XCTAssertEqual(screenshotLoads, 1) + XCTAssertEqual(knowledgeLoads, 1) + XCTAssertEqual(exportLoads, 1) + XCTAssertEqual(store.screenshotCount, 1) + XCTAssertEqual(store.conversationCount, 1) + + await store.refreshIfNeeded(now: start.addingTimeInterval(PollingConfig.activationCooldown)) + + XCTAssertEqual(connectorLoads, 2) + XCTAssertEqual(screenshotLoads, 2) + XCTAssertEqual(knowledgeLoads, 2) + XCTAssertEqual(exportLoads, 2) + XCTAssertEqual(store.screenshotCount, 2) + XCTAssertEqual(store.conversationCount, 2) + } + + func testConcurrentRefreshesShareOneLoad() async { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + + let gate = RefreshGate() + var connectorLoads = 0 + let connectorStore = ImportConnectorStatusStore(defaults: defaults) + let store = HomeStatusStore( + connectorStatusStore: connectorStore, + defaults: defaults, + loader: HomeStatusLoader( + refreshConnectorStatuses: { + connectorLoads += 1 + await gate.wait() + }, + loadScreenshotCount: { nil }, + loadKnowledgeCounts: { _ in + HomeKnowledgeCounts( + conversations: nil, + memories: nil, + tasks: nil, + hasOmiDeviceConversations: nil + ) + }, + loadMemoryExportStatuses: { [:] } + ) + ) + let now = Date(timeIntervalSince1970: 20_000) + + let first = Task { await store.refreshIfNeeded(now: now) } + for _ in 0..<100 where connectorLoads == 0 { + await Task.yield() + } + XCTAssertEqual(connectorLoads, 1) + let second = Task { await store.refreshIfNeeded(now: now) } + await Task.yield() + + XCTAssertEqual(connectorLoads, 1) + + gate.open() + await first.value + await second.value + XCTAssertEqual(connectorLoads, 1) + } + + func testCompletedImportRefreshesOnlyKnowledgeCounts() async { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + + var connectorLoads = 0 + var screenshotLoads = 0 + var knowledgeLoads = 0 + var exportLoads = 0 + let connectorStore = ImportConnectorStatusStore(defaults: defaults) + let store = HomeStatusStore( + connectorStatusStore: connectorStore, + defaults: defaults, + loader: HomeStatusLoader( + refreshConnectorStatuses: { connectorLoads += 1 }, + loadScreenshotCount: { + screenshotLoads += 1 + return 4 + }, + loadKnowledgeCounts: { _ in + knowledgeLoads += 1 + return HomeKnowledgeCounts( + conversations: 10 + knowledgeLoads, + memories: 20 + knowledgeLoads, + tasks: 30 + knowledgeLoads, + hasOmiDeviceConversations: false + ) + }, + loadMemoryExportStatuses: { + exportLoads += 1 + return [:] + } + ) + ) + + await store.refreshIfNeeded(now: Date(timeIntervalSince1970: 30_000)) + connectorStore.markSynced(connectorID: "email", sourceCount: 12) + for _ in 0..<100 where knowledgeLoads < 2 { + await Task.yield() + } + + XCTAssertEqual(knowledgeLoads, 2) + XCTAssertEqual(store.conversationCount, 12) + XCTAssertEqual(store.memoryCount, 22) + XCTAssertEqual(store.taskCount, 32) + XCTAssertEqual(connectorLoads, 1) + XCTAssertEqual(screenshotLoads, 1) + XCTAssertEqual(exportLoads, 1) + } + + func testCompletedImportCountsAreNotOverwrittenByOlderHomeRefresh() async { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + + let connectorGate = RefreshGate() + var knowledgeLoads = 0 + let connectorStore = ImportConnectorStatusStore(defaults: defaults) + let store = HomeStatusStore( + connectorStatusStore: connectorStore, + defaults: defaults, + loader: HomeStatusLoader( + refreshConnectorStatuses: { await connectorGate.wait() }, + loadScreenshotCount: { nil }, + loadKnowledgeCounts: { _ in + knowledgeLoads += 1 + let count = knowledgeLoads == 1 ? 10 : 20 + return HomeKnowledgeCounts( + conversations: count, + memories: count, + tasks: count, + hasOmiDeviceConversations: false + ) + }, + loadMemoryExportStatuses: { [:] } + ) + ) + + let homeRefresh = Task { + await store.refreshIfNeeded(now: Date(timeIntervalSince1970: 35_000)) + } + for _ in 0..<100 where knowledgeLoads < 1 { + await Task.yield() + } + XCTAssertEqual(knowledgeLoads, 1) + + connectorStore.markSynced(connectorID: "email", sourceCount: 12) + for _ in 0..<100 where store.conversationCount != 20 { + await Task.yield() + } + XCTAssertEqual(store.conversationCount, 20) + + connectorGate.open() + await homeRefresh.value + + XCTAssertEqual(store.conversationCount, 20) + XCTAssertEqual(store.memoryCount, 20) + XCTAssertEqual(store.taskCount, 20) + } + + func testRefreshFindsManualImportCompletedAfterStoreInitialization() async throws { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + + let connectorStore = ImportConnectorStatusStore(defaults: defaults) + let store = HomeStatusStore( + connectorStatusStore: connectorStore, + defaults: defaults, + loader: HomeStatusLoader( + refreshConnectorStatuses: { connectorStore.refreshPersistedManualImportMetrics() }, + loadScreenshotCount: { nil }, + loadKnowledgeCounts: { _ in + HomeKnowledgeCounts( + conversations: nil, + memories: nil, + tasks: nil, + hasOmiDeviceConversations: nil + ) + }, + loadMemoryExportStatuses: { [:] } + ) + ) + defaults.set(4, forKey: "onboardingChatGPTImportedMemoriesCount") + + await store.refreshIfNeeded(now: Date(timeIntervalSince1970: 37_500)) + + let connector = try XCTUnwrap(ImportConnector.all.first { $0.id == "chatgpt" }) + let snapshot = connectorStore.snapshot(for: connector) + XCTAssertTrue(snapshot.isConnected) + XCTAssertEqual(snapshot.primaryText, "4 memories imported") + } + + func testPersistedDeviceHistorySkipsDeviceLookup() async { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + defaults.set(true, forKey: HomeStatusStore.omiDeviceHistoryDefaultsKey) + + var deviceLookupRequests: [Bool] = [] + let connectorStore = ImportConnectorStatusStore(defaults: defaults) + let store = HomeStatusStore( + connectorStatusStore: connectorStore, + defaults: defaults, + loader: HomeStatusLoader( + refreshConnectorStatuses: {}, + loadScreenshotCount: { nil }, + loadKnowledgeCounts: { includeOmiDeviceHistory in + deviceLookupRequests.append(includeOmiDeviceHistory) + return HomeKnowledgeCounts( + conversations: nil, + memories: nil, + tasks: nil, + hasOmiDeviceConversations: nil + ) + }, + loadMemoryExportStatuses: { [:] } + ) + ) + + await store.refreshIfNeeded(now: Date(timeIntervalSince1970: 40_000)) + + XCTAssertTrue(store.accountHasOmiDeviceConversations) + XCTAssertEqual(deviceLookupRequests, [false]) + } + + private func makeDefaults() -> (defaults: UserDefaults, suiteName: String) { + let suiteName = "HomeStatusStoreTests.\(UUID().uuidString)" + return (UserDefaults(suiteName: suiteName)!, suiteName) + } +} + +@MainActor +private final class RefreshGate { + private var isOpen = false + private var continuations: [CheckedContinuation] = [] + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func open() { + isOpen = true + let pending = continuations + continuations.removeAll() + pending.forEach { $0.resume() } + } +} diff --git a/desktop/macos/changelog/unreleased/20260709-home-status-navigation-performance.json b/desktop/macos/changelog/unreleased/20260709-home-status-navigation-performance.json new file mode 100644 index 00000000000..2ef88376884 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260709-home-status-navigation-performance.json @@ -0,0 +1,3 @@ +{ + "change": "Made Home load instantly when returning from another page by reusing recently refreshed status data" +} From 6aabbe804fd48d0169dfb2791d16fb0553593b10 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:35:05 -0400 Subject: [PATCH 2/4] cover home status navigation flow --- desktop/macos/e2e/flows/navigation.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/desktop/macos/e2e/flows/navigation.yaml b/desktop/macos/e2e/flows/navigation.yaml index 5199a29a278..e034abfc36b 100644 --- a/desktop/macos/e2e/flows/navigation.yaml +++ b/desktop/macos/e2e/flows/navigation.yaml @@ -8,6 +8,7 @@ covers: - desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift - desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift + - desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStatusStore.swift preconditions: - automation_bridge_ready @@ -61,6 +62,14 @@ steps: state.selectedTabIndex: 9 - id: S7 + name: Return to Dashboard + bridge.navigate: + target: dashboard + activateApp: false + wait: + state.selectedTabIndex: 0 + + - id: S8 name: Logs clean log.expect: absent: From 23cad87ccfcca0f3f7e481c4cfc65464f1a83922 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Fri, 10 Jul 2026 01:24:55 -0400 Subject: [PATCH 3/4] scope home status cache by account Persist connector metrics and device-history status under the signed-in user, migrate legacy cache keys once, and clear in-memory status on session reset. Track the owner of legacy onboarding import summaries to prevent cross-account reuse. Verification: xcrun swift test --package-path Desktop --scratch-path /tmp/omi-pr9372-account-cache-build --filter 'HomeStatusStoreTests|ImportConnectorStatusStoreTests|OnboardingFlowTests|OnboardingDataSourceRowStatusTests' (24 passed). Built and launched the omi-home-cache-scope named bundle, navigated Apps to Home through omi-ctl, and found no related app-log errors. --- .../macos/Desktop/Sources/DefaultsKey.swift | 3 + .../Sources/MainWindow/Pages/AppsPage.swift | 173 ++++++++++++++---- .../MainWindow/Pages/HomeStatusStore.swift | 70 ++++++- .../OnboardingPagedIntroCoordinator.swift | 38 +++- .../Desktop/Tests/HomeStatusStoreTests.swift | 49 +++++ .../ImportConnectorStatusStoreTests.swift | 94 ++++++---- ...09-home-status-navigation-performance.json | 2 +- 7 files changed, 347 insertions(+), 82 deletions(-) diff --git a/desktop/macos/Desktop/Sources/DefaultsKey.swift b/desktop/macos/Desktop/Sources/DefaultsKey.swift index a42c25c146f..ba8f13895b6 100644 --- a/desktop/macos/Desktop/Sources/DefaultsKey.swift +++ b/desktop/macos/Desktop/Sources/DefaultsKey.swift @@ -36,6 +36,8 @@ enum DefaultsKey: String { case multiChatEnabled = "multiChatEnabled" case aiChatWorkingDirectory = "aiChatWorkingDirectory" case onboardingStep = "onboardingStep" + case onboardingMemoryImportOwnerUserId = "onboardingMemoryImportOwnerUserID" + case homeOmiDeviceAccountHistory = "home-omi-device-account-history" case chatScreenshotSharingEnabled = "chatScreenshotSharingEnabled" } @@ -50,6 +52,7 @@ extension UserDefaults { func bool(forKey key: DefaultsKey) -> Bool { bool(forKey: key.rawValue) } func integer(forKey key: DefaultsKey) -> Int { integer(forKey: key.rawValue) } func double(forKey key: DefaultsKey) -> Double { double(forKey: key.rawValue) } + func object(forKey key: DefaultsKey) -> Any? { object(forKey: key.rawValue) } func set(_ value: Any?, forKey key: DefaultsKey) { set(value, forKey: key.rawValue) } func removeObject(forKey key: DefaultsKey) { removeObject(forKey: key.rawValue) } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift index f032c40b2e5..cf1d8d3eee4 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift @@ -752,9 +752,20 @@ final class ImportConnectorStatusStore: ObservableObject { private let manualConnectorIDs: Set = ["chatgpt", "claude"] private let onboardingChatGPTImportedMemoriesKey = "onboardingChatGPTImportedMemoriesCount" private let onboardingClaudeImportedMemoriesKey = "onboardingClaudeImportedMemoriesCount" + private var sessionUserID: String? - init(defaults: UserDefaults = .standard) { + init(defaults: UserDefaults = .standard, sessionUserID: String? = nil) { self.defaults = defaults + self.sessionUserID = Self.normalizedUserID( + sessionUserID ?? defaults.string(forKey: .authUserId) + ) + load() + } + + func setSessionUserID(_ userID: String?) { + let userID = Self.normalizedUserID(userID) + guard userID != sessionUserID else { return } + sessionUserID = userID load() } @@ -787,36 +798,48 @@ final class ImportConnectorStatusStore: ObservableObject { var metrics = metricsByID[connectorID] ?? ConnectorMetrics() if let sourceCount { metrics.sourceCount = max(sourceCount, 0) - defaults.set(metrics.sourceCount, forKey: sourceCountKeyPrefix + connectorID) + defaults.set(metrics.sourceCount, forKey: storageKey(prefix: sourceCountKeyPrefix, connectorID: connectorID)) } if let memoryCount { metrics.memoryCount = max(memoryCount, 0) - defaults.set(metrics.memoryCount, forKey: memoryCountKeyPrefix + connectorID) + defaults.set(metrics.memoryCount, forKey: storageKey(prefix: memoryCountKeyPrefix, connectorID: connectorID)) } metrics.lastSyncedAt = syncedAt - defaults.set(syncedAt.timeIntervalSince1970, forKey: lastSyncedAtKeyPrefix + connectorID) + defaults.set( + syncedAt.timeIntervalSince1970, + forKey: storageKey(prefix: lastSyncedAtKeyPrefix, connectorID: connectorID) + ) metrics.lastDeltaCount = lastDeltaCount - defaults.set(lastDeltaCount != nil, forKey: hasLastDeltaKeyPrefix + connectorID) + defaults.set( + lastDeltaCount != nil, + forKey: storageKey(prefix: hasLastDeltaKeyPrefix, connectorID: connectorID) + ) if let lastDeltaCount { - defaults.set(lastDeltaCount, forKey: lastDeltaCountKeyPrefix + connectorID) + defaults.set( + lastDeltaCount, + forKey: storageKey(prefix: lastDeltaCountKeyPrefix, connectorID: connectorID) + ) } else { - defaults.removeObject(forKey: lastDeltaCountKeyPrefix + connectorID) + defaults.removeObject(forKey: storageKey(prefix: lastDeltaCountKeyPrefix, connectorID: connectorID)) } if let availabilityText { metrics.availabilityText = availabilityText - defaults.set(availabilityText, forKey: availabilityTextKeyPrefix + connectorID) + defaults.set( + availabilityText, + forKey: storageKey(prefix: availabilityTextKeyPrefix, connectorID: connectorID) + ) } metricsByID[connectorID] = metrics connectorDidSync.send(connectorID) } private func clearStoredMetrics(for connectorID: String) { - defaults.removeObject(forKey: sourceCountKeyPrefix + connectorID) - defaults.removeObject(forKey: memoryCountKeyPrefix + connectorID) - defaults.removeObject(forKey: lastSyncedAtKeyPrefix + connectorID) - defaults.removeObject(forKey: lastDeltaCountKeyPrefix + connectorID) - defaults.removeObject(forKey: hasLastDeltaKeyPrefix + connectorID) - defaults.removeObject(forKey: availabilityTextKeyPrefix + connectorID) + defaults.removeObject(forKey: storageKey(prefix: sourceCountKeyPrefix, connectorID: connectorID)) + defaults.removeObject(forKey: storageKey(prefix: memoryCountKeyPrefix, connectorID: connectorID)) + defaults.removeObject(forKey: storageKey(prefix: lastSyncedAtKeyPrefix, connectorID: connectorID)) + defaults.removeObject(forKey: storageKey(prefix: lastDeltaCountKeyPrefix, connectorID: connectorID)) + defaults.removeObject(forKey: storageKey(prefix: hasLastDeltaKeyPrefix, connectorID: connectorID)) + defaults.removeObject(forKey: storageKey(prefix: availabilityTextKeyPrefix, connectorID: connectorID)) metricsByID[connectorID] = ConnectorMetrics() } @@ -831,25 +854,35 @@ final class ImportConnectorStatusStore: ObservableObject { } private func load() { + metricsByID = [:] + guard sessionUserID != nil else { return } for connector in ImportConnector.all { + migrateLegacyMetricsIfNeeded(connectorID: connector.id) var metrics = ConnectorMetrics() - if defaults.object(forKey: sourceCountKeyPrefix + connector.id) != nil { - metrics.sourceCount = defaults.integer(forKey: sourceCountKeyPrefix + connector.id) + let sourceCountKey = storageKey(prefix: sourceCountKeyPrefix, connectorID: connector.id) + let memoryCountKey = storageKey(prefix: memoryCountKeyPrefix, connectorID: connector.id) + let lastSyncedAtKey = storageKey(prefix: lastSyncedAtKeyPrefix, connectorID: connector.id) + let hasLastDeltaKey = storageKey(prefix: hasLastDeltaKeyPrefix, connectorID: connector.id) + let lastDeltaCountKey = storageKey(prefix: lastDeltaCountKeyPrefix, connectorID: connector.id) + let availabilityTextKey = storageKey(prefix: availabilityTextKeyPrefix, connectorID: connector.id) + + if defaults.object(forKey: sourceCountKey) != nil { + metrics.sourceCount = defaults.integer(forKey: sourceCountKey) } - if defaults.object(forKey: memoryCountKeyPrefix + connector.id) != nil { - metrics.memoryCount = defaults.integer(forKey: memoryCountKeyPrefix + connector.id) + if defaults.object(forKey: memoryCountKey) != nil { + metrics.memoryCount = defaults.integer(forKey: memoryCountKey) } - if defaults.object(forKey: lastSyncedAtKeyPrefix + connector.id) != nil { - let timestamp = defaults.double(forKey: lastSyncedAtKeyPrefix + connector.id) + if defaults.object(forKey: lastSyncedAtKey) != nil { + let timestamp = defaults.double(forKey: lastSyncedAtKey) if timestamp > 0 { metrics.lastSyncedAt = Date(timeIntervalSince1970: timestamp) } } - if defaults.bool(forKey: hasLastDeltaKeyPrefix + connector.id) { - metrics.lastDeltaCount = defaults.integer(forKey: lastDeltaCountKeyPrefix + connector.id) + if defaults.bool(forKey: hasLastDeltaKey) { + metrics.lastDeltaCount = defaults.integer(forKey: lastDeltaCountKey) } - metrics.availabilityText = defaults.string(forKey: availabilityTextKeyPrefix + connector.id) + metrics.availabilityText = defaults.string(forKey: availabilityTextKey) metricsByID[connector.id] = metrics } @@ -861,25 +894,79 @@ final class ImportConnectorStatusStore: ObservableObject { } private func hydrateLegacyManualImports() { + guard let sessionUserID else { return } + let ownerUserID = Self.normalizedUserID(defaults.string(forKey: .onboardingMemoryImportOwnerUserId)) let legacyChatGPTCount = defaults.integer(forKey: onboardingChatGPTImportedMemoriesKey) + let legacyClaudeCount = defaults.integer(forKey: onboardingClaudeImportedMemoriesKey) + if ownerUserID == nil, + (legacyChatGPTCount > 0 || legacyClaudeCount > 0) { + defaults.set(sessionUserID, forKey: .onboardingMemoryImportOwnerUserId) + } + + let resolvedOwnerUserID = Self.normalizedUserID( + defaults.string(forKey: .onboardingMemoryImportOwnerUserId) + ) + guard resolvedOwnerUserID == sessionUserID else { return } + + let chatGPTMemoryCountKey = storageKey(prefix: memoryCountKeyPrefix, connectorID: "chatgpt") if legacyChatGPTCount > 0, - defaults.object(forKey: memoryCountKeyPrefix + "chatgpt") == nil { + defaults.object(forKey: chatGPTMemoryCountKey) == nil { var metrics = metricsByID["chatgpt"] ?? ConnectorMetrics() metrics.memoryCount = legacyChatGPTCount metrics.availabilityText = "Imported during onboarding" metricsByID["chatgpt"] = metrics + defaults.set(legacyChatGPTCount, forKey: chatGPTMemoryCountKey) + defaults.set( + "Imported during onboarding", + forKey: storageKey(prefix: availabilityTextKeyPrefix, connectorID: "chatgpt") + ) } - let legacyClaudeCount = defaults.integer(forKey: onboardingClaudeImportedMemoriesKey) + let claudeMemoryCountKey = storageKey(prefix: memoryCountKeyPrefix, connectorID: "claude") if legacyClaudeCount > 0, - defaults.object(forKey: memoryCountKeyPrefix + "claude") == nil { + defaults.object(forKey: claudeMemoryCountKey) == nil { var metrics = metricsByID["claude"] ?? ConnectorMetrics() metrics.memoryCount = legacyClaudeCount metrics.availabilityText = "Imported during onboarding" metricsByID["claude"] = metrics + defaults.set(legacyClaudeCount, forKey: claudeMemoryCountKey) + defaults.set( + "Imported during onboarding", + forKey: storageKey(prefix: availabilityTextKeyPrefix, connectorID: "claude") + ) + } + } + + private func storageKey(prefix: String, connectorID: String) -> String { + guard let sessionUserID else { return prefix + connectorID } + return "\(prefix)user.\(sessionUserID).\(connectorID)" + } + + private func migrateLegacyMetricsIfNeeded(connectorID: String) { + guard sessionUserID != nil else { return } + for prefix in [ + sourceCountKeyPrefix, + memoryCountKeyPrefix, + lastSyncedAtKeyPrefix, + lastDeltaCountKeyPrefix, + hasLastDeltaKeyPrefix, + availabilityTextKeyPrefix, + ] { + let legacyKey = prefix + connectorID + let scopedKey = storageKey(prefix: prefix, connectorID: connectorID) + if defaults.object(forKey: scopedKey) == nil, + let legacyValue = defaults.object(forKey: legacyKey) { + defaults.set(legacyValue, forKey: scopedKey) + } + defaults.removeObject(forKey: legacyKey) } } + private static func normalizedUserID(_ userID: String?) -> String? { + let trimmed = userID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + private func refreshLocalFilesMetrics() async { guard let dbQueue = await RewindDatabase.shared.getDatabaseQueue() else { return } @@ -901,17 +988,28 @@ final class ImportConnectorStatusStore: ObservableObject { var metrics = metricsByID["local-files"] ?? ConnectorMetrics() metrics.sourceCount = result.count - defaults.set(result.count, forKey: sourceCountKeyPrefix + "local-files") + defaults.set( + result.count, + forKey: storageKey(prefix: sourceCountKeyPrefix, connectorID: "local-files") + ) if metrics.lastSyncedAt == nil, let lastIndexedAt = result.lastIndexedAt { metrics.lastSyncedAt = lastIndexedAt - defaults.set(lastIndexedAt.timeIntervalSince1970, forKey: lastSyncedAtKeyPrefix + "local-files") + defaults.set( + lastIndexedAt.timeIntervalSince1970, + forKey: storageKey(prefix: lastSyncedAtKeyPrefix, connectorID: "local-files") + ) } if metrics.lastSyncedAt != nil || result.count > 0 { metrics.availabilityText = "On-device index" - defaults.set("On-device index", forKey: availabilityTextKeyPrefix + "local-files") + defaults.set( + "On-device index", + forKey: storageKey(prefix: availabilityTextKeyPrefix, connectorID: "local-files") + ) } else { metrics.availabilityText = nil - defaults.removeObject(forKey: availabilityTextKeyPrefix + "local-files") + defaults.removeObject( + forKey: storageKey(prefix: availabilityTextKeyPrefix, connectorID: "local-files") + ) } metricsByID["local-files"] = metrics } catch { @@ -925,14 +1023,23 @@ final class ImportConnectorStatusStore: ObservableObject { case .connected(let noteCount, _): var metrics = metricsByID["apple-notes"] ?? ConnectorMetrics() metrics.sourceCount = noteCount - defaults.set(noteCount, forKey: sourceCountKeyPrefix + "apple-notes") + defaults.set( + noteCount, + forKey: storageKey(prefix: sourceCountKeyPrefix, connectorID: "apple-notes") + ) if metrics.lastSyncedAt == nil { let syncedAt = Date() metrics.lastSyncedAt = syncedAt - defaults.set(syncedAt.timeIntervalSince1970, forKey: lastSyncedAtKeyPrefix + "apple-notes") + defaults.set( + syncedAt.timeIntervalSince1970, + forKey: storageKey(prefix: lastSyncedAtKeyPrefix, connectorID: "apple-notes") + ) } metrics.availabilityText = "Private notes accessible" - defaults.set("Private notes accessible", forKey: availabilityTextKeyPrefix + "apple-notes") + defaults.set( + "Private notes accessible", + forKey: storageKey(prefix: availabilityTextKeyPrefix, connectorID: "apple-notes") + ) metricsByID["apple-notes"] = metrics case .needsAccess(_, let reasonCode), .error(_, let reasonCode): log("ImportConnectorStatusStore: Apple Notes refresh unavailable code=\(reasonCode)") diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStatusStore.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStatusStore.swift index 51d06c4ba1f..a3610607670 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStatusStore.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/HomeStatusStore.swift @@ -51,7 +51,7 @@ struct HomeStatusLoader { /// returning to Home can render cached values without repeating provider scans. @MainActor final class HomeStatusStore: ObservableObject { - static let omiDeviceHistoryDefaultsKey = "home-omi-device-account-history" + static let omiDeviceHistoryDefaultsKey = DefaultsKey.homeOmiDeviceAccountHistory.rawValue let connectorStatusStore: ImportConnectorStatusStore @@ -64,6 +64,8 @@ final class HomeStatusStore: ObservableObject { private let defaults: UserDefaults private let loader: HomeStatusLoader + private let currentUserIDProvider: () -> String? + private var sessionUserID: String? private var lastRefreshAt = Date.distantPast private var refreshTask: Task? private var refreshID: UUID? @@ -74,13 +76,25 @@ final class HomeStatusStore: ObservableObject { init( connectorStatusStore: ImportConnectorStatusStore? = nil, defaults: UserDefaults = .standard, - loader: HomeStatusLoader? = nil + loader: HomeStatusLoader? = nil, + currentUserIDProvider: (() -> String?)? = nil ) { - let connectorStatusStore = connectorStatusStore ?? ImportConnectorStatusStore(defaults: defaults) + let currentUserIDProvider = currentUserIDProvider ?? { + defaults.string(forKey: .authUserId) + } + let sessionUserID = Self.normalizedUserID(currentUserIDProvider()) + let connectorStatusStore = connectorStatusStore + ?? ImportConnectorStatusStore(defaults: defaults, sessionUserID: sessionUserID) self.connectorStatusStore = connectorStatusStore self.defaults = defaults self.loader = loader ?? .live(connectorStatusStore: connectorStatusStore) - accountHasOmiDeviceConversations = defaults.bool(forKey: Self.omiDeviceHistoryDefaultsKey) + self.currentUserIDProvider = currentUserIDProvider + self.sessionUserID = sessionUserID + accountHasOmiDeviceConversations = Self.loadPersistedDeviceHistory( + defaults: defaults, + userID: sessionUserID + ) + connectorStatusStore.setSessionUserID(sessionUserID) connectorStatusStore.objectWillChange .sink { [weak self] _ in @@ -96,6 +110,8 @@ final class HomeStatusStore: ObservableObject { } func refreshIfNeeded(force: Bool = false, now: Date = Date()) async { + ensureCurrentSessionScope() + if let refreshTask { await refreshTask.value return @@ -122,6 +138,13 @@ final class HomeStatusStore: ObservableObject { } func resetSessionState() { + resetTransientState() + sessionUserID = nil + connectorStatusStore.setSessionUserID(nil) + accountHasOmiDeviceConversations = false + } + + private func resetTransientState() { refreshGeneration += 1 refreshTask?.cancel() refreshTask = nil @@ -133,7 +156,19 @@ final class HomeStatusStore: ObservableObject { memoryCount = nil taskCount = nil memoryExportStatuses = [:] - accountHasOmiDeviceConversations = defaults.bool(forKey: Self.omiDeviceHistoryDefaultsKey) + } + + private func ensureCurrentSessionScope() { + let currentUserID = Self.normalizedUserID(currentUserIDProvider()) + guard currentUserID != sessionUserID else { return } + + resetTransientState() + sessionUserID = currentUserID + connectorStatusStore.setSessionUserID(currentUserID) + accountHasOmiDeviceConversations = Self.loadPersistedDeviceHistory( + defaults: defaults, + userID: currentUserID + ) } private func performRefresh(generation: Int) async { @@ -196,7 +231,30 @@ final class HomeStatusStore: ObservableObject { } if knowledgeCounts.hasOmiDeviceConversations == true { accountHasOmiDeviceConversations = true - defaults.set(true, forKey: Self.omiDeviceHistoryDefaultsKey) + if let key = Self.deviceHistoryDefaultsKey(userID: sessionUserID) { + defaults.set(true, forKey: key) + } + } + } + + private static func normalizedUserID(_ userID: String?) -> String? { + let trimmed = userID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func deviceHistoryDefaultsKey(userID: String?) -> String? { + guard let userID = normalizedUserID(userID) else { return nil } + return "\(omiDeviceHistoryDefaultsKey).user.\(userID)" + } + + private static func loadPersistedDeviceHistory(defaults: UserDefaults, userID: String?) -> Bool { + guard let scopedKey = deviceHistoryDefaultsKey(userID: userID) else { return false } + + if defaults.object(forKey: scopedKey) == nil, + defaults.object(forKey: .homeOmiDeviceAccountHistory) != nil { + defaults.set(defaults.bool(forKey: .homeOmiDeviceAccountHistory), forKey: scopedKey) + defaults.removeObject(forKey: .homeOmiDeviceAccountHistory) } + return defaults.bool(forKey: scopedKey) } } diff --git a/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift index 8c65330dad3..fc4839a31a0 100644 --- a/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift +++ b/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift @@ -125,10 +125,25 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { ? AssistantSettings.shared.voiceLanguages : [] let defaults = UserDefaults.standard - chatGPTImportedMemoriesCount = defaults.integer(forKey: chatGPTImportedMemoriesKey) - claudeImportedMemoriesCount = defaults.integer(forKey: claudeImportedMemoriesKey) - chatGPTImportSummary = defaults.string(forKey: chatGPTImportSummaryKey) ?? "" - claudeImportSummary = defaults.string(forKey: claudeImportSummaryKey) ?? "" + let currentUserID = Self.normalizedUserID(defaults.string(forKey: .authUserId)) + var ownerUserID = Self.normalizedUserID(defaults.string(forKey: .onboardingMemoryImportOwnerUserId)) + let hasLegacyImport = defaults.object(forKey: chatGPTImportedMemoriesKey) != nil + || defaults.object(forKey: chatGPTImportSummaryKey) != nil + || defaults.object(forKey: claudeImportedMemoriesKey) != nil + || defaults.object(forKey: claudeImportSummaryKey) != nil + if ownerUserID == nil, hasLegacyImport, let currentUserID { + defaults.set(currentUserID, forKey: .onboardingMemoryImportOwnerUserId) + ownerUserID = currentUserID + } + let canRestoreMemoryImports = ownerUserID == nil || ownerUserID == currentUserID + chatGPTImportedMemoriesCount = canRestoreMemoryImports + ? defaults.integer(forKey: chatGPTImportedMemoriesKey) : 0 + claudeImportedMemoriesCount = canRestoreMemoryImports + ? defaults.integer(forKey: claudeImportedMemoriesKey) : 0 + chatGPTImportSummary = canRestoreMemoryImports + ? defaults.string(forKey: chatGPTImportSummaryKey) ?? "" : "" + claudeImportSummary = canRestoreMemoryImports + ? defaults.string(forKey: claudeImportSummaryKey) ?? "" : "" } deinit { @@ -232,6 +247,16 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { } let defaults = UserDefaults.standard + if let currentUserID = Self.normalizedUserID(defaults.string(forKey: .authUserId)) { + let ownerUserID = Self.normalizedUserID(defaults.string(forKey: .onboardingMemoryImportOwnerUserId)) + if let ownerUserID, ownerUserID != currentUserID { + defaults.removeObject(forKey: chatGPTImportedMemoriesKey) + defaults.removeObject(forKey: chatGPTImportSummaryKey) + defaults.removeObject(forKey: claudeImportedMemoriesKey) + defaults.removeObject(forKey: claudeImportSummaryKey) + } + defaults.set(currentUserID, forKey: .onboardingMemoryImportOwnerUserId) + } switch source { case .chatgpt: chatGPTImportedMemoriesCount = memories @@ -261,6 +286,11 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { ) } + private static func normalizedUserID(_ userID: String?) -> String? { + let trimmed = userID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + func selectAppleNotesFolderAndSync() async { lastActionError = nil diff --git a/desktop/macos/Desktop/Tests/HomeStatusStoreTests.swift b/desktop/macos/Desktop/Tests/HomeStatusStoreTests.swift index bb88c3a6c2a..8d81ec67a65 100644 --- a/desktop/macos/Desktop/Tests/HomeStatusStoreTests.swift +++ b/desktop/macos/Desktop/Tests/HomeStatusStoreTests.swift @@ -210,6 +210,7 @@ final class HomeStatusStoreTests: XCTestCase { let testDefaults = makeDefaults() let defaults = testDefaults.defaults defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + defaults.set("user-a", forKey: "auth_userId") let connectorStore = ImportConnectorStatusStore(defaults: defaults) let store = HomeStatusStore( @@ -243,6 +244,7 @@ final class HomeStatusStoreTests: XCTestCase { let testDefaults = makeDefaults() let defaults = testDefaults.defaults defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + defaults.set("user-a", forKey: "auth_userId") defaults.set(true, forKey: HomeStatusStore.omiDeviceHistoryDefaultsKey) var deviceLookupRequests: [Bool] = [] @@ -270,6 +272,53 @@ final class HomeStatusStoreTests: XCTestCase { XCTAssertTrue(store.accountHasOmiDeviceConversations) XCTAssertEqual(deviceLookupRequests, [false]) + XCTAssertNil(defaults.object(forKey: HomeStatusStore.omiDeviceHistoryDefaultsKey)) + } + + func testAccountSwitchDoesNotExposePreviousAccountStatus() async throws { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + defaults.set("user-a", forKey: "auth_userId") + + let connectorStore = ImportConnectorStatusStore(defaults: defaults) + let store = HomeStatusStore( + connectorStatusStore: connectorStore, + defaults: defaults, + loader: HomeStatusLoader( + refreshConnectorStatuses: {}, + loadScreenshotCount: { nil }, + loadKnowledgeCounts: { _ in + HomeKnowledgeCounts( + conversations: nil, + memories: nil, + tasks: nil, + hasOmiDeviceConversations: defaults.string(forKey: "auth_userId") == "user-a" + ) + }, + loadMemoryExportStatuses: { [:] } + ) + ) + let email = try XCTUnwrap(ImportConnector.all.first { $0.id == "email" }) + + connectorStore.markSynced(connectorID: "email", sourceCount: 12) + await store.refreshIfNeeded(now: Date(timeIntervalSince1970: 50_000)) + XCTAssertTrue(store.accountHasOmiDeviceConversations) + XCTAssertTrue(connectorStore.snapshot(for: email).isConnected) + + defaults.set("user-b", forKey: "auth_userId") + store.resetSessionState() + await store.refreshIfNeeded(now: Date(timeIntervalSince1970: 50_100)) + + XCTAssertFalse(store.accountHasOmiDeviceConversations) + XCTAssertFalse(connectorStore.snapshot(for: email).isConnected) + + defaults.set("user-a", forKey: "auth_userId") + await store.refreshIfNeeded(force: true, now: Date(timeIntervalSince1970: 50_200)) + + XCTAssertTrue(store.accountHasOmiDeviceConversations) + XCTAssertTrue(connectorStore.snapshot(for: email).isConnected) + XCTAssertEqual(connectorStore.snapshot(for: email).primaryText, "12 emails") } private func makeDefaults() -> (defaults: UserDefaults, suiteName: String) { diff --git a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift index 11e1939b5ba..46cc301abdb 100644 --- a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift +++ b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift @@ -4,20 +4,13 @@ import XCTest @MainActor final class ImportConnectorStatusStoreTests: XCTestCase { - override func setUp() { - super.setUp() - resetImportConnectorDefaults() - } - - override func tearDown() { - resetImportConnectorDefaults() - super.tearDown() - } - func testSuccessfulZeroCountImportStaysConnectedAcrossStoreReloads() { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } let connector = ImportConnector.all.first { $0.id == "email" }! let syncedAt = Date(timeIntervalSince1970: 1_700_000_000) - let store = ImportConnectorStatusStore() + let store = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "test-user") store.markSynced( connectorID: "email", @@ -27,7 +20,8 @@ final class ImportConnectorStatusStoreTests: XCTestCase { syncedAt: syncedAt) let immediate = store.snapshot(for: connector) - let reloaded = ImportConnectorStatusStore().snapshot(for: connector) + let reloaded = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "test-user").snapshot( + for: connector) XCTAssertTrue(immediate.isConnected) XCTAssertEqual(immediate.actionTitle, "Sync now") @@ -37,8 +31,12 @@ final class ImportConnectorStatusStoreTests: XCTestCase { } func testLocalFilesStartNotConnectedWithoutSuccessfulScan() { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } let connector = ImportConnector.all.first { $0.id == "local-files" }! - let snapshot = ImportConnectorStatusStore().snapshot(for: connector) + let snapshot = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "test-user").snapshot( + for: connector) XCTAssertFalse(snapshot.isConnected) XCTAssertEqual(snapshot.actionTitle, "Connect") @@ -46,9 +44,12 @@ final class ImportConnectorStatusStoreTests: XCTestCase { } func testSuccessfulZeroFileScanStaysConnectedAcrossStoreReloads() { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } let connector = ImportConnector.all.first { $0.id == "local-files" }! let syncedAt = Date(timeIntervalSince1970: 1_700_000_000) - let store = ImportConnectorStatusStore() + let store = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "test-user") store.markSynced( connectorID: "local-files", @@ -59,7 +60,8 @@ final class ImportConnectorStatusStoreTests: XCTestCase { syncedAt: syncedAt) let immediate = store.snapshot(for: connector) - let reloaded = ImportConnectorStatusStore().snapshot(for: connector) + let reloaded = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "test-user").snapshot( + for: connector) XCTAssertTrue(immediate.isConnected) XCTAssertEqual(immediate.actionTitle, "Sync now") @@ -69,52 +71,68 @@ final class ImportConnectorStatusStoreTests: XCTestCase { } func testAvailabilityTextAloneDoesNotMarkConnectorConnected() { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } let connector = ImportConnector.all.first { $0.id == "apple-notes" }! - UserDefaults.standard.set("Private notes accessible", forKey: "appsImportConnectorAvailabilityText.apple-notes") + defaults.set("Private notes accessible", forKey: "appsImportConnectorAvailabilityText.apple-notes") - let store = ImportConnectorStatusStore() + let store = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "test-user") let snapshot = store.snapshot(for: connector) - let reloaded = ImportConnectorStatusStore().snapshot(for: connector) + let reloaded = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "test-user").snapshot( + for: connector) XCTAssertFalse(snapshot.isConnected) XCTAssertFalse(reloaded.isConnected) } func testPositiveCountWithoutSuccessfulSyncDoesNotMarkConnectorConnected() { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } let connector = ImportConnector.all.first { $0.id == "calendar" }! - UserDefaults.standard.set(3, forKey: "appsImportConnectorSourceCount.calendar") - let snapshot = ImportConnectorStatusStore().snapshot(for: connector) + defaults.set(3, forKey: "appsImportConnectorSourceCount.calendar") + let snapshot = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "test-user").snapshot( + for: connector) XCTAssertFalse(snapshot.isConnected) XCTAssertEqual(snapshot.actionTitle, "Connect") } func testLegacyManualImportCountStillMarksConnectorConnected() { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } let connector = ImportConnector.all.first { $0.id == "chatgpt" }! - UserDefaults.standard.set(4, forKey: "onboardingChatGPTImportedMemoriesCount") - let snapshot = ImportConnectorStatusStore().snapshot(for: connector) + defaults.set(4, forKey: "onboardingChatGPTImportedMemoriesCount") + let snapshot = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "test-user").snapshot( + for: connector) XCTAssertTrue(snapshot.isConnected) XCTAssertEqual(snapshot.actionTitle, "Update") } - private func resetImportConnectorDefaults() { - let prefixes = [ - "appsImportConnectorSourceCount.", - "appsImportConnectorMemoryCount.", - "appsImportConnectorLastSyncedAt.", - "appsImportConnectorLastDeltaCount.", - "appsImportConnectorHasLastDelta.", - "appsImportConnectorAvailabilityText.", - ] - for connector in ImportConnector.all { - for prefix in prefixes { - UserDefaults.standard.removeObject(forKey: prefix + connector.id) - } - } - UserDefaults.standard.removeObject(forKey: "onboardingChatGPTImportedMemoriesCount") - UserDefaults.standard.removeObject(forKey: "onboardingClaudeImportedMemoriesCount") + func testConnectorMetricsAreScopedToTheSignedInAccount() { + let testDefaults = makeDefaults() + let defaults = testDefaults.defaults + defer { defaults.removePersistentDomain(forName: testDefaults.suiteName) } + let connector = ImportConnector.all.first { $0.id == "email" }! + + let userAStore = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "user-a") + userAStore.markSynced(connectorID: "email", sourceCount: 12) + + let userBStore = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "user-b") + XCTAssertFalse(userBStore.snapshot(for: connector).isConnected) + + let reloadedUserAStore = ImportConnectorStatusStore(defaults: defaults, sessionUserID: "user-a") + XCTAssertTrue(reloadedUserAStore.snapshot(for: connector).isConnected) + XCTAssertEqual(reloadedUserAStore.snapshot(for: connector).primaryText, "12 emails") + } + + private func makeDefaults() -> (defaults: UserDefaults, suiteName: String) { + let suiteName = "ImportConnectorStatusStoreTests.\(UUID().uuidString)" + return (UserDefaults(suiteName: suiteName)!, suiteName) } } diff --git a/desktop/macos/changelog/unreleased/20260709-home-status-navigation-performance.json b/desktop/macos/changelog/unreleased/20260709-home-status-navigation-performance.json index 2ef88376884..5c99d294726 100644 --- a/desktop/macos/changelog/unreleased/20260709-home-status-navigation-performance.json +++ b/desktop/macos/changelog/unreleased/20260709-home-status-navigation-performance.json @@ -1,3 +1,3 @@ { - "change": "Made Home load instantly when returning from another page by reusing recently refreshed status data" + "change": "Made Home load instantly when returning from another page while keeping cached status isolated to the signed-in account" } From 817ce8c871c5fa73e9333d2e0298a44409d64988 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Fri, 10 Jul 2026 01:51:22 -0400 Subject: [PATCH 4/4] fix(desktop): restore UserDefaults key ratchet Migrate ViewModelContainer account ID reads to DefaultsKey so the PR merge result remains within the raw-key hygiene baseline.\n\nVerified:\n- python3 desktop/macos/scripts/check-userdefaults-key-ratchet.py\n- exact PR merge simulation: 183 raw keys (baseline 184)\n- xcrun swift test --package-path Desktop --scratch-path /tmp/omi-pr9372-account-cache-build --filter 'DefaultsKeyTests|StartupWarmupPolicyTests' (46 passed) --- desktop/macos/Desktop/Sources/ViewModelContainer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index 1101427f865..591681a813b 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -51,7 +51,7 @@ class ViewModelContainer: ObservableObject { /// Load critical startup data, then stage warmup work after the first usable window. func loadAllData() async { - let currentUserId = UserDefaults.standard.string(forKey: "auth_userId") + let currentUserId = UserDefaults.standard.string(forKey: .authUserId) if loadedUserId != nil, loadedUserId != currentUserId { resetStartupState() } @@ -148,7 +148,7 @@ class ViewModelContainer: ObservableObject { log("ViewModelContainer: Retrying database initialization...") // Re-configure userId in case it changed (e.g. sign-in completed since first attempt) - let userId = UserDefaults.standard.string(forKey: "auth_userId") + let userId = UserDefaults.standard.string(forKey: .authUserId) await RewindDatabase.shared.configure(userId: userId) do {