Created
March 4, 2026 17:33
-
-
Save acosmicflamingo/9dc97da1e909779b8f94a6454646bab4 to your computer and use it in GitHub Desktop.
Power Keychain Persistence Strategy via Point-Free's Sharing library
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Dependencies | |
| import Foundation | |
| import Security | |
| import Sharing | |
| // MARK: - DependencyValues | |
| extension DependencyValues { | |
| public var keychainClient: KeychainClient { | |
| get { self[KeychainClient.self] } | |
| set { self[KeychainClient.self] = newValue } | |
| } | |
| } | |
| extension KeychainClient: DependencyKey { | |
| public static let liveValue = Self.system | |
| public static let previewValue = Self.inMemory | |
| public static let testValue = Self.inMemory | |
| } | |
| // MARK: - KeychainClient | |
| public struct KeychainClient: Sendable { | |
| public var save: @Sendable (Data, String) throws -> Void | |
| public var load: @Sendable (String) throws -> Data? | |
| public var delete: @Sendable (String) throws -> Void | |
| } | |
| // MARK: - Live | |
| extension KeychainClient { | |
| public static var system: Self { | |
| let queue = DispatchQueue(label: "com.keychainClient", qos: .userInitiated) | |
| return Self( | |
| save: { data, key in | |
| try queue.sync { try _writeToKeychain(data: data, key: key) } | |
| }, | |
| load: { key in | |
| queue.sync { _readFromKeychain(key: key) } | |
| }, | |
| delete: { key in | |
| try queue.sync { try _deleteFromKeychain(key: key) } | |
| } | |
| ) | |
| } | |
| } | |
| // MARK: - In-Memory (tests & previews) | |
| extension KeychainClient { | |
| public static var inMemory: Self { | |
| let storage = LockIsolated<[String: Data]>([:]) | |
| return Self( | |
| save: { data, key in storage.withValue { $0[key] = data } }, | |
| load: { key in storage.value[key] }, | |
| delete: { key in storage.withValue { _ = $0.removeValue(forKey: key) } } | |
| ) | |
| } | |
| } | |
| // MARK: - Unimplemented | |
| extension KeychainClient { | |
| public static var unimplemented: Self { | |
| Self( | |
| save: { _, _ in XCTFail(#"@Dependency(\.keychainClient.save) is unimplemented"#) }, | |
| load: { _ in XCTFail(#"@Dependency(\.keychainClient.load) is unimplemented"#); return nil }, | |
| delete: { _ in XCTFail(#"@Dependency(\.keychainClient.delete) is unimplemented"#) } | |
| ) | |
| } | |
| } | |
| // MARK: - SharedReaderKey Extension | |
| extension SharedReaderKey { | |
| /// Creates a shared key that reads and writes a `Codable` value to the keychain, | |
| /// backed by `KeychainClient` from swift-dependencies. | |
| /// | |
| /// Usage: | |
| /// ```swift | |
| /// extension SharedKey where Self == KeychainStorageKey<SubscriptionState>.Default { | |
| /// static var fooState: Self { | |
| /// Self[.keychainStorage("com.myapp.fooState"), default: .init()] | |
| /// } | |
| /// } | |
| /// | |
| /// // In a TCA reducer or SwiftUI view: | |
| /// @Shared(.fooState) var fooState | |
| /// ``` | |
| public static func keychainStorage<Value: Codable>( | |
| _ key: String, | |
| decoder: JSONDecoder = JSONDecoder(), | |
| encoder: JSONEncoder = JSONEncoder(), | |
| ) -> Self where Self == KeychainStorageKey<Value> { | |
| KeychainStorageKey(key: key, decoder: decoder, encoder: encoder) | |
| } | |
| } | |
| // MARK: - KeychainStorageKey | |
| /// A `SharedKey` that persists `Codable` values via `KeychainClient`. | |
| /// | |
| /// Use ``SharedReaderKey/keychainStorage(_:decoder:encoder:)`` to create values of this type. | |
| public final class KeychainStorageKey<Value: Codable & Sendable>: SharedKey { | |
| private let key: String | |
| private let decoder: JSONDecoder | |
| private let encoder: JSONEncoder | |
| public var id: KeychainStorageKeyID { | |
| KeychainStorageKeyID(key: key) | |
| } | |
| fileprivate init(key: String, decoder: JSONDecoder, encoder: JSONEncoder) { | |
| self.key = key | |
| self.decoder = decoder | |
| self.encoder = encoder | |
| } | |
| // MARK: SharedReaderKey | |
| public func load(context _: LoadContext<Value>, continuation: LoadContinuation<Value>) { | |
| do { | |
| @Dependency(\.keychainClient) var keychain | |
| guard let data = try keychain.load(key) | |
| else { | |
| continuation.resumeReturningInitialValue() | |
| return | |
| } | |
| continuation.resume(with: Result { try decoder.decode(Value.self, from: data) }) | |
| } catch { | |
| continuation.resume(throwing: error) | |
| } | |
| } | |
| /// Uses Darwin notifications to propagate writes to all other `@Shared` instances | |
| /// holding the same key, within the same process. | |
| public func subscribe( | |
| context _: LoadContext<Value>, | |
| subscriber: SharedSubscriber<Value>, | |
| ) -> SharedSubscription { | |
| let center = CFNotificationCenterGetDarwinNotifyCenter() | |
| let name = notificationName as CFString | |
| let handler = KeychainNotificationHandler { [weak self] in | |
| guard let self else { return } | |
| do { | |
| @Dependency(\.keychainClient) var keychain | |
| guard let data = try keychain.load(key) | |
| else { | |
| subscriber.yieldReturningInitialValue() | |
| return | |
| } | |
| subscriber.yield(with: Result { try self.decoder.decode(Value.self, from: data) }) | |
| } catch { | |
| subscriber.yield(throwing: error) | |
| } | |
| } | |
| let ptr = UncheckedSendable(wrappedValue: Unmanaged.passRetained(handler).toOpaque()) | |
| CFNotificationCenterAddObserver( | |
| center, | |
| ptr.wrappedValue, | |
| { _, ptr, _, _, _ in | |
| guard let ptr else { return } | |
| Unmanaged<KeychainNotificationHandler>.fromOpaque(ptr) | |
| .takeUnretainedValue() | |
| .callback() | |
| }, | |
| name, | |
| nil, | |
| .deliverImmediately, | |
| ) | |
| return SharedSubscription { | |
| CFNotificationCenterRemoveObserver( | |
| center, | |
| ptr.wrappedValue, | |
| CFNotificationName(name), | |
| nil, | |
| ) | |
| Unmanaged<KeychainNotificationHandler>.fromOpaque(ptr.wrappedValue).release() | |
| } | |
| } | |
| // MARK: SharedKey | |
| public func save(_ value: Value, context _: SaveContext, continuation: SaveContinuation) { | |
| do { | |
| @Dependency(\.keychainClient) var keychain | |
| let data = try encoder.encode(value) | |
| try keychain.save(data, key) | |
| // Notify all other @Shared instances watching this key | |
| CFNotificationCenterPostNotification( | |
| CFNotificationCenterGetDarwinNotifyCenter(), | |
| CFNotificationName(notificationName as CFString), | |
| nil, | |
| nil, | |
| true, | |
| ) | |
| continuation.resume() | |
| } catch { | |
| continuation.resume(throwing: error) | |
| } | |
| } | |
| private var notificationName: String { | |
| "com.keychainStorageKey.changed.\(key)" | |
| } | |
| } | |
| // MARK: - Supporting Types | |
| /// Bridges the C-style CFNotification callback to a Swift closure. | |
| /// Must be file-scoped (not nested) because Swift classes cannot be nested | |
| /// inside generic functions. | |
| private final class KeychainNotificationHandler { | |
| let callback: () -> Void | |
| init(_ callback: @escaping () -> Void) { self.callback = callback } | |
| } | |
| public struct KeychainStorageKeyID: Hashable { | |
| fileprivate let key: String | |
| } | |
| extension KeychainStorageKey: CustomStringConvertible { | |
| public var description: String { ".keychainStorage(\(key))" } | |
| } | |
| // MARK: - Keychain Internals | |
| private func _baseQuery(key: String) -> [String: Any] { | |
| [ | |
| kSecClass as String: kSecClassGenericPassword, | |
| kSecAttrAccount as String: key, | |
| kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, | |
| ] | |
| } | |
| private func _readFromKeychain(key: String) -> Data? { | |
| var query = _baseQuery(key: key) | |
| query[kSecReturnData as String] = kCFBooleanTrue! | |
| query[kSecMatchLimit as String] = kSecMatchLimitOne | |
| var result: AnyObject? | |
| guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess | |
| else { return nil } | |
| return result as? Data | |
| } | |
| private func _writeToKeychain(data: Data, key: String) throws { | |
| let updateStatus = SecItemUpdate( | |
| _baseQuery(key: key) as CFDictionary, | |
| [kSecValueData as String: data] as CFDictionary, | |
| ) | |
| if updateStatus == errSecItemNotFound { | |
| var addQuery = _baseQuery(key: key) | |
| addQuery[kSecValueData as String] = data | |
| let addStatus = SecItemAdd(addQuery as CFDictionary, nil) | |
| guard addStatus == errSecSuccess | |
| else { | |
| throw KeychainError.writeFailed(status: addStatus) | |
| } | |
| } else if updateStatus != errSecSuccess { | |
| throw KeychainError.writeFailed(status: updateStatus) | |
| } | |
| } | |
| private func _deleteFromKeychain(key: String) throws { | |
| let status = SecItemDelete(_baseQuery(key: key) as CFDictionary) | |
| guard status == errSecSuccess || status == errSecItemNotFound | |
| else { | |
| throw KeychainError.deleteFailed(status: status) | |
| } | |
| } | |
| // MARK: - Errors | |
| public enum KeychainError: Error, LocalizedError { | |
| case writeFailed(status: OSStatus) | |
| case deleteFailed(status: OSStatus) | |
| public var errorDescription: String? { | |
| switch self { | |
| case let .writeFailed(status): "Keychain write failed with OSStatus \(status)" | |
| case let .deleteFailed(status): "Keychain delete failed with OSStatus \(status)" | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment