diff --git a/docs/plans/2026-03-29-iphone-refresh-reliability-fix-design.md b/docs/plans/2026-03-29-iphone-refresh-reliability-fix-design.md new file mode 100644 index 0000000..afb3e7b --- /dev/null +++ b/docs/plans/2026-03-29-iphone-refresh-reliability-fix-design.md @@ -0,0 +1,58 @@ +# iPhone Refresh Reliability Fix Design + +## Goal + +Make iPhone Day Wrapped refresh reliable by fixing: + +- overlapping foreground refreshes that repeatedly recreate the hidden Screen Time report host +- missing credentials in widget and background bridge requests +- weak diagnostics around superseded refreshes and shared credential availability + +## Current Failures + +- The iPhone app presents the hidden `DeviceActivityReport` host, but the report extension often never starts. +- `sceneBecameActive()` is triggered from multiple SwiftUI lifecycle hooks, which causes duplicate selected-day refreshes and repeated report-host presentation. +- Background and widget bridge requests can fail with `Identity not available` because signing keys are stored in a way that is not consistently readable outside the foreground app process. +- The app falls back to the Mac-only snapshot, which hides the actual failure stage from the user. + +## Chosen Approach + +Implement a full reliability fix in three layers: + +1. Serialize selected-day refreshes in the iPhone app. +2. Remove duplicate scene-activation refresh triggers and stale refresh observers. +3. Move signing credentials to a shared keychain access group so app, widget, and related extension code can read the same identity material when needed. + +## Refresh Orchestration + +- Add a refresh generation token to `AppModel`. +- When a new refresh starts, it supersedes any older wait loop. +- Guard `refreshSelectedDay()` so only one selected-day refresh is active at a time for a given generation. +- Keep the hidden `DeviceActivityReport` host mounted, but only change its token when a real refresh begins. +- Ensure scene activation only schedules one foreground refresh attempt. + +## Shared Credentials + +- Add a shared keychain access group entitlement to the iPhone app, widget extension, and report extension. +- Update `AuthStore` to read and write keys using that access group. +- Add a small helper that validates whether both identity and key material are available before bridge calls. +- Use the stronger credential check in background and widget-facing flows so logs distinguish missing identity from missing keys. + +## Diagnostics + +- Log when a refresh is skipped because another refresh is already active. +- Log when a refresh wait loop exits because it was superseded by a newer request. +- Log when shared credential material is unavailable for background or widget work. +- Preserve the existing report lifecycle markers so diagnostics still point to the exact failed stage. + +## Testing + +- Validate that foreground activation no longer emits duplicate refresh/report-host sequences. +- Validate that manual refresh can produce a fresh `mobile-day-.json` without being replaced by a second activation refresh. +- Validate that widget and background snapshot fetches use shared credentials successfully. +- Run iOS-targeted build or project validation plus repository typecheck/lint as applicable. + +## Risks + +- Shared keychain access requires matching entitlements across all relevant iOS targets. +- `DeviceActivityReport` remains platform-controlled, so extension startup can still be slow; the fix focuses on removing app-side races and credential failures. diff --git a/electron/main/features/__tests__/virtualTimeline.test.ts b/electron/main/features/__tests__/virtualTimeline.test.ts index 381904e..268df41 100644 --- a/electron/main/features/__tests__/virtualTimeline.test.ts +++ b/electron/main/features/__tests__/virtualTimeline.test.ts @@ -26,6 +26,11 @@ describe("buildVirtualTimelineItems", () => { showPagination: true, spacingAfter: 16, }); + expect( + items[0]?.type === "header" + ? items[0].events.map((event) => event.id) + : [], + ).toEqual(["1", "2", "3"]); expect(items[1]).toMatchObject({ type: "row", date: "Today", diff --git a/electron/main/infra/db/repositories/EventRepository.ts b/electron/main/infra/db/repositories/EventRepository.ts index ae98d2f..ba210d2 100644 --- a/electron/main/infra/db/repositories/EventRepository.ts +++ b/electron/main/infra/db/repositories/EventRepository.ts @@ -161,6 +161,12 @@ export function getEvents(options: GetEventsOptions): Event[] { ); } + if (options.needsAddictionReview) { + conditions.push( + "e.addiction_candidate IS NOT NULL AND e.tracked_addiction IS NULL", + ); + } + if (options.appBundleId) { conditions.push("e.app_bundle_id = ?"); params.push(options.appBundleId); @@ -255,6 +261,12 @@ export function getEventsCount(options: GetEventsOptions): number { ); } + if (options.needsAddictionReview) { + conditions.push( + "addiction_candidate IS NOT NULL AND tracked_addiction IS NULL", + ); + } + if (options.appBundleId) { conditions.push("app_bundle_id = ?"); params.push(options.appBundleId); diff --git a/electron/main/ipc/validation.ts b/electron/main/ipc/validation.ts index c601961..b78b965 100644 --- a/electron/main/ipc/validation.ts +++ b/electron/main/ipc/validation.ts @@ -54,6 +54,7 @@ const zGetEventsOptions = z projectProgress: z.boolean().optional(), trackedAddiction: zLimitedString(200).optional(), hasTrackedAddiction: z.boolean().optional(), + needsAddictionReview: z.boolean().optional(), appBundleId: zLimitedString(500).optional(), urlHost: zLimitedString(500).optional(), startDate: z.number().int().optional(), diff --git a/electron/shared/types.ts b/electron/shared/types.ts index 4c6b8cc..e04c658 100644 --- a/electron/shared/types.ts +++ b/electron/shared/types.ts @@ -266,6 +266,7 @@ export interface GetEventsOptions { projectProgress?: boolean; trackedAddiction?: string; hasTrackedAddiction?: boolean; + needsAddictionReview?: boolean; appBundleId?: string; urlHost?: string; startDate?: number; diff --git a/ios/ScreencapMobile/ScreencapMobile.entitlements b/ios/ScreencapMobile/ScreencapMobile.entitlements index 1078823..21f333d 100644 --- a/ios/ScreencapMobile/ScreencapMobile.entitlements +++ b/ios/ScreencapMobile/ScreencapMobile.entitlements @@ -4,6 +4,10 @@ com.apple.developer.family-controls + keychain-access-groups + + $(AppIdentifierPrefix)app.screencap.mobile.shared + com.apple.security.application-groups group.app.screencap.mobile diff --git a/ios/ScreencapMobile/Sources/AppModel.swift b/ios/ScreencapMobile/Sources/AppModel.swift index 2bc7882..d430a30 100644 --- a/ios/ScreencapMobile/Sources/AppModel.swift +++ b/ios/ScreencapMobile/Sources/AppModel.swift @@ -29,6 +29,9 @@ final class AppModel: ObservableObject { @Published var infoMessage: String? private var autoSyncTimer: Timer? + private var isHandlingSceneActivation = false + private var activeRefreshSequence = 0 + private var activeRefreshDayStartMs: Int64? #if DEBUG private var isDemoLayoutEnabled: Bool { @@ -37,6 +40,7 @@ final class AppModel: ObservableObject { #endif init() { + AuthStore.migrateLegacyKeyMaterialIfNeeded() identity = AuthStore.loadIdentity() snapshot = AppGroupStore.loadSnapshot() authorizationStatus = AuthorizationCenter.shared.authorizationStatus @@ -50,13 +54,25 @@ final class AppModel: ObservableObject { #endif } - func sceneBecameActive() async { + func sceneBecameActive(trigger: String = "scene-active") async { #if DEBUG if isDemoLayoutEnabled { applyDemoLayoutStateIfNeeded() return } #endif + if isHandlingSceneActivation { + AppGroupStore.appendLog( + scope: "app", + message: + "coalesced scene activation trigger=\(trigger) selectedDayStartMs=\(selectedDayStartMs())" + ) + return + } + isHandlingSceneActivation = true + defer { isHandlingSceneActivation = false } + + AuthStore.migrateLegacyKeyMaterialIfNeeded() authorizationStatus = AuthorizationCenter.shared.authorizationStatus snapshot = AppGroupStore.loadSnapshot() if let snapshot { @@ -66,12 +82,13 @@ final class AppModel: ObservableObject { Self.scheduleBackgroundRefresh() AppGroupStore.appendLog( scope: "app", - message: "scene became active selectedDayStartMs=\(selectedDayStartMs()) auth=\(authorizationStatusLabel())" + message: + "scene became active trigger=\(trigger) selectedDayStartMs=\(selectedDayStartMs()) auth=\(authorizationStatusLabel()) credentials=\(AuthStore.signedRequestCredentialsDescription())" ) if identity != nil { if authorizationStatus == .approved { - await refreshSelectedDay() + await refreshSelectedDay(trigger: "scene-active") } else { await performMacSync( dayStartMs: selectedDayStartMs(), @@ -99,7 +116,7 @@ final class AppModel: ObservableObject { message: "authorization status after request=\(authorizationStatusLabel())" ) if authorizationStatus == .approved { - await refreshSelectedDay() + await refreshSelectedDay(trigger: "authorization") } } catch { errorMessage = error.localizedDescription @@ -131,13 +148,14 @@ final class AppModel: ObservableObject { } } - func refreshSelectedDay() async { + func refreshSelectedDay(trigger: String = "manual") async { let dayStartMs = selectedDayStartMs() errorMessage = nil infoMessage = nil AppGroupStore.appendLog( scope: "refresh", - message: "refresh selected day requested dayStartMs=\(dayStartMs) auth=\(authorizationStatusLabel())" + message: + "refresh selected day requested dayStartMs=\(dayStartMs) trigger=\(trigger) auth=\(authorizationStatusLabel())" ) guard authorizationStatus == .approved else { @@ -150,10 +168,34 @@ final class AppModel: ObservableObject { return } + if isRefreshing, activeRefreshDayStartMs == dayStartMs { + AppGroupStore.appendLog( + scope: "refresh", + message: + "skipped duplicate in-flight refresh dayStartMs=\(dayStartMs) trigger=\(trigger) sequence=\(activeRefreshSequence)" + ) + return + } + if isRefreshing, let activeRefreshDayStartMs { + AppGroupStore.appendLog( + scope: "refresh", + message: + "superseding in-flight refresh oldDayStartMs=\(activeRefreshDayStartMs) newDayStartMs=\(dayStartMs) trigger=\(trigger) previousSequence=\(activeRefreshSequence)" + ) + } + let refreshStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000) + activeRefreshSequence += 1 + let refreshSequence = activeRefreshSequence + activeRefreshDayStartMs = dayStartMs reportRefreshToken = AppGroupStore.noteRefreshRequested(dayStartMs: dayStartMs) isRefreshing = true - await waitForSnapshot(dayStartMs: dayStartMs, refreshStartedAtMs: refreshStartedAtMs) + await waitForSnapshot( + dayStartMs: dayStartMs, + refreshStartedAtMs: refreshStartedAtMs, + refreshSequence: refreshSequence, + trigger: trigger + ) } func syncFromMac() async { @@ -200,7 +242,7 @@ final class AppModel: ObservableObject { } if authorizationStatus == .approved { - await refreshSelectedDay() + await refreshSelectedDay(trigger: "repair") } else { await performMacSync( dayStartMs: dayStartMs, @@ -258,14 +300,14 @@ final class AppModel: ObservableObject { func previousDay() { selectedDay = Calendar.current.date(byAdding: .day, value: -1, to: selectedDay) ?? selectedDay - Task { await refreshSelectedDay() } + Task { await refreshSelectedDay(trigger: "previous-day") } } func nextDay() { let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: selectedDay) ?? selectedDay let today = Calendar.current.startOfDay(for: Date()) selectedDay = min(tomorrow, today) - Task { await refreshSelectedDay() } + Task { await refreshSelectedDay(trigger: "next-day") } } func forgetDevice() { @@ -291,6 +333,7 @@ final class AppModel: ObservableObject { "identity.userId=\(identity?.userId ?? "none")", "identity.username=\(identity?.username ?? "none")", "identity.backendBaseURL=\(identity?.backendBaseURL ?? "none")", + "identity.credentialsStatus=\(AuthStore.signedRequestCredentialsDescription())", "snapshotFile=\(AppGroupStore.fileSummary(url: AppGroupStore.snapshotURL()))", "selectedMobileDayFile=\(AppGroupStore.fileSummary(url: AppGroupStore.mobileDayURL(dayStartMs: selectedDayStartMs)))", "diagnostics.requestedToken=\(diagnostics.requestedToken)", @@ -361,7 +404,7 @@ final class AppModel: ObservableObject { selectedDay = Date(timeIntervalSince1970: TimeInterval(dayStartMs) / 1000) Task { - await refreshSelectedDay() + await refreshSelectedDay(trigger: "deep-link") } } @@ -408,15 +451,25 @@ final class AppModel: ObservableObject { } nonisolated private static func performBackgroundRefresh() async -> Bool { - guard AuthStore.loadIdentity() != nil else { + switch AuthStore.loadSignedRequestCredentials() { + case .missingIdentity: AppGroupStore.appendLog(scope: "bg-refresh", message: "skipped background refresh because identity is missing") return true + case .missingKeyMaterial: + let message = + "skipped background refresh because signing keys are unavailable; open the iPhone app once to refresh shared credentials" + AppGroupStore.noteMacSync(kind: "auto", succeeded: false, error: message) + AppGroupStore.appendLog(scope: "bg-refresh", message: message) + return false + case .available: + break } let todayStartMs = Int64(Calendar.current.startOfDay(for: Date()).timeIntervalSince1970 * 1000) AppGroupStore.appendLog( scope: "bg-refresh", - message: "running background refresh dayStartMs=\(todayStartMs)" + message: + "running background refresh dayStartMs=\(todayStartMs) credentials=\(AuthStore.signedRequestCredentialsDescription())" ) if let day = AppGroupStore.loadMobileDay(dayStartMs: todayStartMs) { @@ -487,18 +540,35 @@ final class AppModel: ObservableObject { ) } - private func waitForSnapshot(dayStartMs: Int64, refreshStartedAtMs: Int64) async { - defer { isRefreshing = false } + private func waitForSnapshot( + dayStartMs: Int64, + refreshStartedAtMs: Int64, + refreshSequence: Int, + trigger: String + ) async { + defer { + if activeRefreshSequence == refreshSequence { + isRefreshing = false + activeRefreshDayStartMs = nil + } + } for _ in 0 ..< 60 { + guard ensureCurrentRefresh(sequence: refreshSequence, dayStartMs: dayStartMs, stage: "poll-start") else { + return + } try? await Task.sleep(nanoseconds: 500_000_000) + guard ensureCurrentRefresh(sequence: refreshSequence, dayStartMs: dayStartMs, stage: "poll-resume") else { + return + } if let day = AppGroupStore.loadMobileDay(dayStartMs: dayStartMs), day.syncedAt >= refreshStartedAtMs - 2_000 { AppGroupStore.appendLog( scope: "refresh", - message: "found fresh mobile day dayStartMs=\(day.dayStartMs) syncedAt=\(day.syncedAt)" + message: + "found fresh mobile day dayStartMs=\(day.dayStartMs) syncedAt=\(day.syncedAt) sequence=\(refreshSequence) trigger=\(trigger)" ) if identity != nil { do { @@ -508,12 +578,18 @@ final class AppModel: ObservableObject { AppGroupStore.saveUploadStatus(dayStartMs: dayStartMs, message: "Upload failed") } } + guard ensureCurrentRefresh(sequence: refreshSequence, dayStartMs: dayStartMs, stage: "post-upload") else { + return + } if await performMacSync( dayStartMs: dayStartMs, kind: "manual", recordErrors: true, updateVisibleSnapshot: true ) != nil { + guard ensureCurrentRefresh(sequence: refreshSequence, dayStartMs: dayStartMs, stage: "post-mac-sync") else { + return + } uploadStatus = AppGroupStore.loadUploadStatus(dayStartMs: dayStartMs) return } @@ -531,17 +607,20 @@ final class AppModel: ObservableObject { break } - if let reportError = diagnostics.lastReportError, !reportError.isEmpty { - errorMessage = "Screen Time export failed: \(reportError)" - break + if let reportError = diagnostics.lastReportError, !reportError.isEmpty { + errorMessage = "Screen Time export failed: \(reportError)" + break + } } - } + guard ensureCurrentRefresh(sequence: refreshSequence, dayStartMs: dayStartMs, stage: "timeout") else { + return + } let diagnostics = AppGroupStore.loadDiagnostics() AppGroupStore.appendLog( scope: "refresh", message: - "local wait ended without fresh day dayStartMs=\(dayStartMs) hostPresented=\(diagnostics.reportHostPresentedAtMs.map(String.init) ?? "nil") reportStarted=\(diagnostics.reportStartedAtMs.map(String.init) ?? "nil") reportFinished=\(diagnostics.reportFinishedAtMs.map(String.init) ?? "nil") producedDayStartMs=\(diagnostics.producedDayStartMs.map(String.init) ?? "nil")" + "local wait ended without fresh day dayStartMs=\(dayStartMs) trigger=\(trigger) sequence=\(refreshSequence) hostPresented=\(diagnostics.reportHostPresentedAtMs.map(String.init) ?? "nil") reportStarted=\(diagnostics.reportStartedAtMs.map(String.init) ?? "nil") reportFinished=\(diagnostics.reportFinishedAtMs.map(String.init) ?? "nil") producedDayStartMs=\(diagnostics.producedDayStartMs.map(String.init) ?? "nil")" ) if await performMacSync( @@ -550,6 +629,9 @@ final class AppModel: ObservableObject { recordErrors: false, updateVisibleSnapshot: true ) != nil { + guard ensureCurrentRefresh(sequence: refreshSequence, dayStartMs: dayStartMs, stage: "fallback-mac-sync") else { + return + } uploadStatus = AppGroupStore.loadUploadStatus(dayStartMs: dayStartMs) infoMessage = "Using the latest snapshot from Mac while the iPhone export catches up." } @@ -559,6 +641,22 @@ final class AppModel: ObservableObject { } } + private func ensureCurrentRefresh( + sequence: Int, + dayStartMs: Int64, + stage: String + ) -> Bool { + guard activeRefreshSequence == sequence, activeRefreshDayStartMs == dayStartMs else { + AppGroupStore.appendLog( + scope: "refresh", + message: + "abandoning stale refresh dayStartMs=\(dayStartMs) sequence=\(sequence) stage=\(stage) activeSequence=\(activeRefreshSequence) activeDayStartMs=\(activeRefreshDayStartMs.map(String.init) ?? "nil")" + ) + return false + } + return true + } + private func clearLocalArtifacts(dayStartMs: Int64) { AppGroupStore.clearSnapshot() AppGroupStore.deleteMobileDay(dayStartMs: dayStartMs) diff --git a/ios/ScreencapMobile/Sources/RootView.swift b/ios/ScreencapMobile/Sources/RootView.swift index d81d3b5..a1c6056 100644 --- a/ios/ScreencapMobile/Sources/RootView.swift +++ b/ios/ScreencapMobile/Sources/RootView.swift @@ -6,6 +6,8 @@ struct RootView: View { @Environment(\.scenePhase) private var scenePhase @State private var pairingInput = "" @State private var scannerPresented = false + @State private var didRunInitialActivation = false + @State private var shouldHandleNextActivePhase = false private let actionColumns = [ GridItem(.flexible(minimum: 0, maximum: .infinity), spacing: 12), @@ -50,15 +52,26 @@ struct RootView: View { .ignoresSafeArea() } .task { - await model.sceneBecameActive() + guard !didRunInitialActivation else { return } + didRunInitialActivation = true + if scenePhase == .active { + await model.sceneBecameActive(trigger: "initial-task") + } else { + shouldHandleNextActivePhase = true + } } .onChange(of: scenePhase) { _, nextPhase in if nextPhase == .active { + guard didRunInitialActivation, shouldHandleNextActivePhase else { return } + shouldHandleNextActivePhase = false Task { - await model.sceneBecameActive() + await model.sceneBecameActive(trigger: "scene-phase-active") + } + } else { + shouldHandleNextActivePhase = true + if nextPhase == .background { + model.sceneMovedToBackground() } - } else if nextPhase == .background { - model.sceneMovedToBackground() } } } diff --git a/ios/ScreencapMobileReport/ScreencapMobileReport.entitlements b/ios/ScreencapMobileReport/ScreencapMobileReport.entitlements index 1078823..21f333d 100644 --- a/ios/ScreencapMobileReport/ScreencapMobileReport.entitlements +++ b/ios/ScreencapMobileReport/ScreencapMobileReport.entitlements @@ -4,6 +4,10 @@ com.apple.developer.family-controls + keychain-access-groups + + $(AppIdentifierPrefix)app.screencap.mobile.shared + com.apple.security.application-groups group.app.screencap.mobile diff --git a/ios/ScreencapMobileWidget/ScreencapMobileWidget.entitlements b/ios/ScreencapMobileWidget/ScreencapMobileWidget.entitlements index 723be4f..d41f12f 100644 --- a/ios/ScreencapMobileWidget/ScreencapMobileWidget.entitlements +++ b/ios/ScreencapMobileWidget/ScreencapMobileWidget.entitlements @@ -2,6 +2,10 @@ + keychain-access-groups + + $(AppIdentifierPrefix)app.screencap.mobile.shared + com.apple.security.application-groups group.app.screencap.mobile diff --git a/ios/ScreencapMobileWidget/Sources/ChangeWidgetDayIntent.swift b/ios/ScreencapMobileWidget/Sources/ChangeWidgetDayIntent.swift index 7085f90..4e6ff6c 100644 --- a/ios/ScreencapMobileWidget/Sources/ChangeWidgetDayIntent.swift +++ b/ios/ScreencapMobileWidget/Sources/ChangeWidgetDayIntent.swift @@ -49,6 +49,13 @@ private enum WidgetDayNavigator { ) if AppGroupStore.loadCachedSnapshot(dayStartMs: candidateDayStartMs) == nil { + if case .missingKeyMaterial = AuthStore.loadSignedRequestCredentials() { + AppGroupStore.appendLog( + scope: "widget-day", + message: + "widget fetch is missing shared signing keys; open the iPhone app once to refresh shared credentials" + ) + } do { let snapshot = try await BackendClient.fetchSnapshot(dayStartMs: candidateDayStartMs) try AppGroupStore.saveCachedSnapshot(snapshot) diff --git a/ios/Shared/AuthStore.swift b/ios/Shared/AuthStore.swift index 4b4d7f5..0f28f05 100644 --- a/ios/Shared/AuthStore.swift +++ b/ios/Shared/AuthStore.swift @@ -6,6 +6,15 @@ enum AuthStore { private static let identityDefaultsKey = "device.identity" private static let signKeyAccount = "sign-private-raw" private static let dhKeyAccount = "dh-private-raw" + private static let sharedAccessGroupSuffix = ".app.screencap.mobile.shared" + private static let keychainAccessGroupsEntitlement = "keychain-access-groups" + private static var cachedSharedAccessGroup: String? + + enum SignedRequestCredentials { + case available(DeviceIdentity, StoredKeyMaterial) + case missingIdentity + case missingKeyMaterial + } private static var defaults: UserDefaults { AppGroupStore.defaults @@ -19,9 +28,105 @@ enum AuthStore { } static func loadKeyMaterial() -> StoredKeyMaterial? { + if + let accessGroup = sharedAccessGroup(), + let sharedKeys = readKeyMaterial(accessGroup: accessGroup) + { + return sharedKeys + } + + guard let legacyKeys = readKeyMaterial(accessGroup: nil) else { + return nil + } + + migrateLegacyKeyMaterialIfNeeded(using: legacyKeys) + return legacyKeys + } + + static func loadSignedRequestCredentials() -> SignedRequestCredentials { + guard let identity = loadIdentity() else { + return .missingIdentity + } + guard let keys = loadKeyMaterial() else { + return .missingKeyMaterial + } + return .available(identity, keys) + } + + static func signedRequestCredentialsDescription() -> String { + switch loadSignedRequestCredentials() { + case .available: + return "available" + case .missingIdentity: + return "missingIdentity" + case .missingKeyMaterial: + return "missingKeyMaterial" + } + } + + static func save(identity: DeviceIdentity, keys: StoredKeyMaterial) throws { + let data = try JSONEncoder().encode(identity) + defaults.set(data, forKey: identityDefaultsKey) + try storeKeyMaterial(keys) + } + + static func migrateLegacyKeyMaterialIfNeeded() { + guard let legacyKeys = readKeyMaterial(accessGroup: nil) else { + return + } + migrateLegacyKeyMaterialIfNeeded(using: legacyKeys) + } + + static func clear() { + defaults.removeObject(forKey: identityDefaultsKey) + keychainDelete(account: signKeyAccount, accessGroup: sharedAccessGroup()) + keychainDelete(account: dhKeyAccount, accessGroup: sharedAccessGroup()) + keychainDelete(account: signKeyAccount, accessGroup: nil) + keychainDelete(account: dhKeyAccount, accessGroup: nil) + } + + private static func storeKeyMaterial(_ keys: StoredKeyMaterial) throws { + if let accessGroup = sharedAccessGroup() { + try keychainWrite( + account: signKeyAccount, + value: keys.signPrivateKeyRawB64, + accessGroup: accessGroup + ) + try keychainWrite( + account: dhKeyAccount, + value: keys.dhPrivateKeyRawB64, + accessGroup: accessGroup + ) + return + } + + try keychainWrite(account: signKeyAccount, value: keys.signPrivateKeyRawB64, accessGroup: nil) + try keychainWrite(account: dhKeyAccount, value: keys.dhPrivateKeyRawB64, accessGroup: nil) + } + + private static func migrateLegacyKeyMaterialIfNeeded(using keys: StoredKeyMaterial) { + guard let accessGroup = sharedAccessGroup() else { + return + } + guard readKeyMaterial(accessGroup: accessGroup) == nil else { + return + } + try? keychainWrite( + account: signKeyAccount, + value: keys.signPrivateKeyRawB64, + accessGroup: accessGroup + ) + try? keychainWrite( + account: dhKeyAccount, + value: keys.dhPrivateKeyRawB64, + accessGroup: accessGroup + ) + } + + private static func readKeyMaterial(accessGroup: String?) -> StoredKeyMaterial? { guard - let signData = keychainRead(account: signKeyAccount), - let dhData = keychainRead(account: dhKeyAccount), + let signData = keychainRead(account: signKeyAccount, accessGroup: accessGroup), + let dhData = keychainRead(account: dhKeyAccount, accessGroup: accessGroup), let signValue = String(data: signData, encoding: .utf8), let dhValue = String(data: dhData, encoding: .utf8) else { @@ -34,27 +139,38 @@ enum AuthStore { ) } - static func save(identity: DeviceIdentity, keys: StoredKeyMaterial) throws { - let data = try JSONEncoder().encode(identity) - defaults.set(data, forKey: identityDefaultsKey) - try keychainWrite(account: signKeyAccount, value: keys.signPrivateKeyRawB64) - try keychainWrite(account: dhKeyAccount, value: keys.dhPrivateKeyRawB64) - } - - static func clear() { - defaults.removeObject(forKey: identityDefaultsKey) - keychainDelete(account: signKeyAccount) - keychainDelete(account: dhKeyAccount) + private static func sharedAccessGroup() -> String? { + if let cachedSharedAccessGroup { + return cachedSharedAccessGroup + } + guard let task = SecTaskCreateFromSelf(nil) else { + return nil + } + guard + let value = SecTaskCopyValueForEntitlement( + task, + keychainAccessGroupsEntitlement as CFString, + nil + ) as? [String] + else { + return nil + } + let accessGroup = + value.first(where: { $0.hasSuffix(sharedAccessGroupSuffix) }) + ?? value.first + cachedSharedAccessGroup = accessGroup + return accessGroup } - private static func keychainWrite(account: String, value: String) throws { - keychainDelete(account: account) - let status = SecItemAdd([ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: account, - kSecValueData: Data(value.utf8), - ] as CFDictionary, nil) + private static func keychainWrite( + account: String, + value: String, + accessGroup: String? + ) throws { + keychainDelete(account: account, accessGroup: accessGroup) + var query = baseKeychainQuery(account: account, accessGroup: accessGroup) + query[kSecValueData] = Data(value.utf8) + let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: [ @@ -63,15 +179,12 @@ enum AuthStore { } } - private static func keychainRead(account: String) -> Data? { + private static func keychainRead(account: String, accessGroup: String?) -> Data? { var item: CFTypeRef? - let status = SecItemCopyMatching([ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: account, - kSecReturnData: true, - kSecMatchLimit: kSecMatchLimitOne, - ] as CFDictionary, &item) + var query = baseKeychainQuery(account: account, accessGroup: accessGroup) + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne + let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess else { return nil @@ -79,11 +192,22 @@ enum AuthStore { return item as? Data } - private static func keychainDelete(account: String) { - SecItemDelete([ + private static func keychainDelete(account: String, accessGroup: String?) { + SecItemDelete(baseKeychainQuery(account: account, accessGroup: accessGroup) as CFDictionary) + } + + private static func baseKeychainQuery( + account: String, + accessGroup: String? + ) -> [CFString: Any] { + var query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account, - ] as CFDictionary) + ] + if let accessGroup { + query[kSecAttrAccessGroup] = accessGroup + } + return query } } diff --git a/ios/Shared/BackendClient.swift b/ios/Shared/BackendClient.swift index 80bf668..a146b94 100644 --- a/ios/Shared/BackendClient.swift +++ b/ios/Shared/BackendClient.swift @@ -159,13 +159,21 @@ enum BackendClient { method: String, body: Data? = nil ) async throws -> Data { - guard - let identity = AuthStore.loadIdentity(), - let keys = AuthStore.loadKeyMaterial() - else { + let identity: DeviceIdentity + let keys: StoredKeyMaterial + switch AuthStore.loadSignedRequestCredentials() { + case let .available(nextIdentity, nextKeys): + identity = nextIdentity + keys = nextKeys + case .missingIdentity: throw NSError(domain: "BackendClient", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Identity not available", ]) + case .missingKeyMaterial: + throw NSError(domain: "BackendClient", code: 4, userInfo: [ + NSLocalizedDescriptionKey: + "Signing keys not available. Open the iPhone app once to refresh shared credentials.", + ]) } let normalizedPath = path.hasPrefix("/") ? path : "/" + path diff --git a/src/components/timeline/Timeline.tsx b/src/components/timeline/Timeline.tsx index 16a1f92..80e0c4c 100644 --- a/src/components/timeline/Timeline.tsx +++ b/src/components/timeline/Timeline.tsx @@ -80,6 +80,7 @@ const TimelineList = memo(function TimelineList({ {item.type === "header" ? ( { + updateFilters({ + needsAddictionReview: filtersRef.current.needsAddictionReview + ? undefined + : true, + }); + }, [updateFilters]); const handleAddictionChange = useCallback( (v?: string) => updateFilters({ trackedAddiction: v }), [updateFilters], @@ -408,6 +416,15 @@ export const TimelineFilters = memo(function TimelineFilters() { }); } + if (filters.needsAddictionReview) { + result.push({ + key: "needsReview", + label: "Needs Review", + icon: , + onRemove: () => updateFilters({ needsAddictionReview: undefined }), + }); + } + if (filters.trackedAddiction) { result.push({ key: "addiction", @@ -590,6 +607,20 @@ export const TimelineFilters = memo(function TimelineFilters() { Progress only + + )} diff --git a/src/components/timeline/TimelineGroup.tsx b/src/components/timeline/TimelineGroup.tsx index 456162b..0372238 100644 --- a/src/components/timeline/TimelineGroup.tsx +++ b/src/components/timeline/TimelineGroup.tsx @@ -1,4 +1,5 @@ -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Check, ChevronLeft, ChevronRight } from "lucide-react"; +import { useCallback, useState } from "react"; import { Button } from "@/components/ui/button"; import { useAppStore } from "@/stores/app"; import type { Event } from "@/types"; @@ -15,6 +16,7 @@ interface TimelineGroupProps { interface TimelineGroupHeaderProps { date: string; + events?: Event[]; showPagination?: boolean; hasNextPage?: boolean; totalPages?: number; @@ -28,43 +30,84 @@ interface TimelineEventRowProps { export function TimelineGroupHeader({ date, + events = [], showPagination = false, hasNextPage = false, totalPages = 1, }: TimelineGroupHeaderProps) { const pagination = useAppStore((s) => s.pagination); const setPagination = useAppStore((s) => s.setPagination); + const filters = useAppStore((s) => s.filters); + const updateEvent = useAppStore((s) => s.updateEvent); + const [confirming, setConfirming] = useState(false); + + const eventsNeedingReview = events.filter( + (event) => event.addictionCandidate && !event.trackedAddiction, + ); + + const handleConfirmAll = useCallback(async () => { + if (eventsNeedingReview.length === 0 || !window.api) return; + setConfirming(true); + try { + const ids = eventsNeedingReview.map((event) => event.id); + await window.api.storage.confirmAddiction(ids); + for (const event of eventsNeedingReview) { + updateEvent(event.id, { + trackedAddiction: event.addictionCandidate, + addictionCandidate: null, + }); + } + } finally { + setConfirming(false); + } + }, [eventsNeedingReview, updateEvent]); return (

{date}

- {showPagination && ( -
- - Page {pagination.page + 1} of {totalPages} - +
+ {filters.needsAddictionReview && eventsNeedingReview.length > 0 && ( - -
- )} + )} + {showPagination && ( +
+ + Page {pagination.page + 1} of {totalPages} + + + +
+ )} +
); } @@ -109,6 +152,7 @@ export function TimelineGroup({