Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Normal/Services/KeychainItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
}
8 changes: 6 additions & 2 deletions Normal/Services/UsageLimitLedger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ nonisolated protocol UsageLimitLedger: Sendable {
}

struct KeychainUsageLimitLedger: UsageLimitLedger {
private struct Record: Codable {
let lastLoosened: Date
}

private let item: KeychainItem

init(
Expand All @@ -16,15 +20,15 @@ struct KeychainUsageLimitLedger: UsageLimitLedger {
}

func loadLastLoosened() -> Date? {
item.load(Double.self).map(Date.init(timeIntervalSinceReferenceDate:))
item.load(Record.self)?.lastLoosened
}

func saveLastLoosened(_ date: Date?) {
guard let date else {
item.delete()
return
}
item.save(date.timeIntervalSinceReferenceDate)
item.save(Record(lastLoosened: date))
}
}

Expand Down
9 changes: 2 additions & 7 deletions Normal/Views/Usage/UsageLimitRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions Normal/Views/Usage/UsageLockDeadline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
17 changes: 12 additions & 5 deletions Normal/Views/Usage/UsageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@ 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 {
UsageLimitsGuideView { editing = .new }
} else {
limitsSection(now: context.date)
}
resetSection
resetSection(now: context.date)
}
}
.navigationTitle("Max Daily Limits")
Expand Down Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions NormalTests/Services/UsageLimitServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}
Loading