diff --git a/Normal/Services/KeychainItem.swift b/Normal/Services/KeychainItem.swift index 8410113..e486ff7 100644 --- a/Normal/Services/KeychainItem.swift +++ b/Normal/Services/KeychainItem.swift @@ -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 @@ -43,7 +49,7 @@ struct KeychainItem: Sendable { } } -extension KeychainItem { +extension KeychainStoring { func load(_ type: T.Type) -> T? { guard let data = loadData() else { return nil } return try? PropertyListDecoder().decode(type, from: data) diff --git a/Normal/Services/UsageLimitLedger.swift b/Normal/Services/UsageLimitLedger.swift index eb295fe..78b60a7 100644 --- a/Normal/Services/UsageLimitLedger.swift +++ b/Normal/Services/UsageLimitLedger.swift @@ -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? { diff --git a/NormalTests/Services/UsageLimitServiceTests.swift b/NormalTests/Services/UsageLimitServiceTests.swift index 9edd22a..185471c 100644 --- a/NormalTests/Services/UsageLimitServiceTests.swift +++ b/NormalTests/Services/UsageLimitServiceTests.swift @@ -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 } + } +}