diff --git a/Normal/Services/KeychainItem.swift b/Normal/Services/KeychainItem.swift index c4ddfd5..8410113 100644 --- a/Normal/Services/KeychainItem.swift +++ b/Normal/Services/KeychainItem.swift @@ -52,7 +52,10 @@ extension KeychainItem { func save(_ value: some Encodable) { let encoder = PropertyListEncoder() encoder.outputFormat = .binary - guard let data = try? encoder.encode(value) else { return } - saveData(data) + do { + try saveData(encoder.encode(value)) + } catch { + assertionFailure("Keychain value failed to encode: \(error)") + } } } diff --git a/Normal/Services/UsageLimitLedger.swift b/Normal/Services/UsageLimitLedger.swift index add9440..eb295fe 100644 --- a/Normal/Services/UsageLimitLedger.swift +++ b/Normal/Services/UsageLimitLedger.swift @@ -6,6 +6,10 @@ nonisolated protocol UsageLimitLedger: Sendable { } struct KeychainUsageLimitLedger: UsageLimitLedger { + private struct Record: Codable { + let lastLoosened: Date + } + private let item: KeychainItem init( @@ -16,7 +20,7 @@ struct KeychainUsageLimitLedger: UsageLimitLedger { } func loadLastLoosened() -> Date? { - item.load(Double.self).map(Date.init(timeIntervalSinceReferenceDate:)) + item.load(Record.self)?.lastLoosened } func saveLastLoosened(_ date: Date?) { @@ -24,7 +28,7 @@ struct KeychainUsageLimitLedger: UsageLimitLedger { item.delete() return } - item.save(date.timeIntervalSinceReferenceDate) + item.save(Record(lastLoosened: date)) } } diff --git a/Normal/Views/Usage/UsageLimitRow.swift b/Normal/Views/Usage/UsageLimitRow.swift index 8ab00e3..12cc3cf 100644 --- a/Normal/Views/Usage/UsageLimitRow.swift +++ b/Normal/Views/Usage/UsageLimitRow.swift @@ -16,13 +16,8 @@ struct UsageLimitRow: View { .foregroundStyle(.secondary) StatusBadge(title: state.label, systemImage: state.icon, tint: state.color) if case let .grace(until) = limit.lockState(now: now) { - HStack(spacing: DS.Spacing.xs) { - Image(systemName: "pencil") - Text("\(UsageLockDeadline.countdown(to: until, now: now)) left to adjust") - } - .font(.caption.weight(.medium)) - .foregroundStyle(.orange) - .accessibilityIdentifier("usage.graceCountdown") + UsageGraceCountdown(until: until, now: now) + .accessibilityIdentifier("usage.graceCountdown") } } Spacer(minLength: DS.Spacing.sm) diff --git a/Normal/Views/Usage/UsageLockDeadline.swift b/Normal/Views/Usage/UsageLockDeadline.swift index 4b2ac96..251db80 100644 --- a/Normal/Views/Usage/UsageLockDeadline.swift +++ b/Normal/Views/Usage/UsageLockDeadline.swift @@ -21,3 +21,17 @@ enum UsageLockDeadline { .monospacedDigit() } } + +struct UsageGraceCountdown: View { + let until: Date + let now: Date + + var body: some View { + HStack(spacing: DS.Spacing.xs) { + Image(systemName: "pencil") + Text("\(UsageLockDeadline.countdown(to: until, now: now)) left to adjust") + } + .font(.caption.weight(.medium)) + .foregroundStyle(.orange) + } +} diff --git a/Normal/Views/Usage/UsageView.swift b/Normal/Views/Usage/UsageView.swift index 8ab93f7..6e6d7d6 100644 --- a/Normal/Views/Usage/UsageView.swift +++ b/Normal/Views/Usage/UsageView.swift @@ -14,7 +14,8 @@ struct UsageView: View { } var body: some View { - TimelineView(UsageLockSchedule(dates: limits.flatMap(\.lockRefreshDates))) { context in + let lockDates = limits.flatMap(\.lockRefreshDates) + usageLimitService.resetLockRefreshDates() + TimelineView(UsageLockSchedule(dates: lockDates)) { context in List { noticeSection if limits.isEmpty { @@ -22,7 +23,7 @@ struct UsageView: View { } else { limitsSection(now: context.date) } - resetSection + resetSection(now: context.date) } } .navigationTitle("Max Daily Limits") @@ -65,13 +66,19 @@ struct UsageView: View { } } - private var resetSection: some View { + private func resetSection(now: Date) -> some View { Section { NavigationLink { UsageResetTimeView(currentMinutes: settings.dailyLimitResetMinutes) } label: { - LabeledContent("Resets At") { - Text(usageLimitService.nextReset(), style: .time) + VStack(alignment: .leading, spacing: DS.Spacing.xs) { + LabeledContent("Resets At") { + Text(usageLimitService.nextReset(), style: .time) + } + if !limits.isEmpty, case let .grace(until) = usageLimitService.resetLockState(now: now) { + UsageGraceCountdown(until: until, now: now) + .accessibilityIdentifier("usage.resetGraceCountdown") + } } } .accessibilityIdentifier("usage.resetTime") diff --git a/NormalTests/Services/UsageLimitServiceTests.swift b/NormalTests/Services/UsageLimitServiceTests.swift index ea3e92a..9edd22a 100644 --- a/NormalTests/Services/UsageLimitServiceTests.swift +++ b/NormalTests/Services/UsageLimitServiceTests.swift @@ -427,4 +427,20 @@ struct UsageLimitServiceTests { #expect(h.service.decideResetChange(now: stored + .days(2)) == .locked(until: stored + .days(7))) #expect(h.service.decideResetChange(now: stored + .days(7)) == .allowed(.cooldownElapsed)) } + + @Test func theKeychainLedgerSurvivesARelaunch() { + let service = "com.normalengineering.normal.tests.\(UUID().uuidString)" + let stored = Date(timeIntervalSinceReferenceDate: 500_000) + KeychainUsageLimitLedger(service: service).saveLastLoosened(stored) + defer { KeychainUsageLimitLedger(service: service).saveLastLoosened(nil) } + + let relaunched = UsageLimitService( + activityCenter: FakeDeviceActivityCenter(), + sharedStore: FakeSharedStore(), + ledger: KeychainUsageLimitLedger(service: service), + limitShield: InMemoryDailyLimitShield() + ) + + #expect(relaunched.decideResetChange(now: stored + .days(2)) == .locked(until: stored + .days(7))) + } }