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
10 changes: 8 additions & 2 deletions Normal/Services/KeychainItem.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import Foundation
import Security

nonisolated protocol KeychainStoring: Sendable {
func loadData() -> Data?
func saveData(_ data: Data)
func delete()
}

/// A single generic-password slot in the Keychain.
///
/// Used for counters that must survive a delete-and-reinstall — the whole point
/// of storing them outside the app container.
struct KeychainItem: Sendable {
struct KeychainItem: KeychainStoring {
let service: String
let account: String

Expand Down Expand Up @@ -43,7 +49,7 @@ struct KeychainItem: Sendable {
}
}

extension KeychainItem {
extension KeychainStoring {
func load<T: Decodable>(_ type: T.Type) -> T? {
guard let data = loadData() else { return nil }
return try? PropertyListDecoder().decode(type, from: data)
Expand Down
10 changes: 6 additions & 4 deletions Normal/Services/UsageLimitLedger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ struct KeychainUsageLimitLedger: UsageLimitLedger {
let lastLoosened: Date
}

private let item: KeychainItem
private let item: any KeychainStoring

init(
service: String = "com.normalengineering.normal.usageLimits",
account: String = "lastLoosened"
item: any KeychainStoring = KeychainItem(
service: "com.normalengineering.normal.usageLimits",
account: "lastLoosened"
)
) {
item = KeychainItem(service: service, account: account)
self.item = item
}

func loadLastLoosened() -> Date? {
Expand Down
24 changes: 20 additions & 4 deletions NormalTests/Services/UsageLimitServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -429,18 +429,34 @@ struct UsageLimitServiceTests {
}

@Test func theKeychainLedgerSurvivesARelaunch() {
let service = "com.normalengineering.normal.tests.\(UUID().uuidString)"
let item = InMemoryKeychainItem()
let stored = Date(timeIntervalSinceReferenceDate: 500_000)
KeychainUsageLimitLedger(service: service).saveLastLoosened(stored)
defer { KeychainUsageLimitLedger(service: service).saveLastLoosened(nil) }
KeychainUsageLimitLedger(item: item).saveLastLoosened(stored)

let relaunched = UsageLimitService(
activityCenter: FakeDeviceActivityCenter(),
sharedStore: FakeSharedStore(),
ledger: KeychainUsageLimitLedger(service: service),
ledger: KeychainUsageLimitLedger(item: item),
limitShield: InMemoryDailyLimitShield()
)

#expect(relaunched.decideResetChange(now: stored + .days(2)) == .locked(until: stored + .days(7)))
}
}

private final class InMemoryKeychainItem: KeychainStoring, @unchecked Sendable {
private let lock = NSLock()
private var data: Data?

func loadData() -> Data? {
lock.withLock { data }
}

func saveData(_ data: Data) {
lock.withLock { self.data = data }
}

func delete() {
lock.withLock { data = nil }
}
}
Loading