diff --git a/Package.swift b/Package.swift index 7dd8486..4e0251f 100644 --- a/Package.swift +++ b/Package.swift @@ -45,6 +45,10 @@ let package = Package( .testTarget( name: "CombineExtensionsTests", dependencies: ["CombineExtensions"] + ), + .testTarget( + name: "SwiftStorageTests", + dependencies: ["SwiftStorage"] ) ] ) diff --git a/README.md b/README.md index 47ed00c..25017c8 100644 --- a/README.md +++ b/README.md @@ -2,95 +2,200 @@ [![Swift](https://github.com/stalkermv/SwiftHelpers/actions/workflows/tests.yml/badge.svg)](https://github.com/stalkermv/SwiftHelpers/actions/workflows/tests.yml) -SwiftHelpers is a collection of convenient Swift extensions and helper functions designed to simplify common tasks and improve code readability in your Swift projects. These utilities cover a wide range of functionalities, from working with arrays and strings to manipulating dates and handling optional values. - -Features --------- - -* Array extensions for sorting, subscripting, and mutation -* String extensions for regex evaluation and validation -* Date extensions for easy date manipulation and formatting -* Optional extensions for error handling and unwrapping -* Codable extensions for easy JSON encoding and decoding -* Sequence extensions for unique filtering and transformations -* Bundle extensions for retrieving app version and build information -* Comparable extensions for value clamping - -Installation ------------- +SwiftHelpers is a comprehensive collection of Swift extensions and utilities designed to simplify common tasks and improve code readability in your Swift projects. The library is organized into focused modules covering foundation extensions, secure storage, Combine utilities, and development tools. + +## Modules + +### SwiftHelpers +The main module that combines FoundationExtensions and CombineExtensions for convenience. + +### FoundationExtensions +Core Swift and Foundation framework extensions: +- **Array extensions** for safe subscripting, sorting, filtering, and mutations +- **Sequence extensions** for unique filtering and transformations +- **Optional extensions** for safe unwrapping with detailed error handling +- **Comparable extensions** for value clamping +- **Date extensions** for easy date manipulation and ISO8601 parsing +- **Bundle extensions** for app version and build information +- **Calendar extensions** for current date handling + +### SwiftStorage +Secure storage solution with reactive updates: +- **SecureStorage** property wrapper for SwiftUI with automatic observation +- **KeychainSecureStorage** for secure keychain-based storage +- **InMemorySecureStorage** for in-memory storage with observation +- **DiskNonSecureStorage** for development/preview storage +- **SecureStorageKey** protocol for type-safe storage keys +- **AppStorageKey** protocol for enhanced AppStorage usage + +### CombineExtensions +Combine framework utilities: +- **TaskFuture** for bridging async/await with Combine publishers + +### Development +Development and debugging utilities: +- **String extensions** for Lorem ipsum generation +- **URL extensions** for random image generation +- **Binding extensions** for debug printing +- **View extensions** for software keyboard enforcement + +## Installation ### Swift Package Manager Add the following line to the dependencies in your `Package.swift` file: -swift - ```swift .package(url: "https://github.com/stalkermv/SwiftHelpers", from: "1.0.0") ``` -Then, add `SwiftHelpers` to your target's dependencies: - -swift +Then, add the desired modules to your target's dependencies: ```swift -.target(name: "YourTarget", dependencies: ["SwiftHelpers"]) +.target(name: "YourTarget", dependencies: [ + "SwiftHelpers", // Main module (includes FoundationExtensions + CombineExtensions) + "SwiftStorage", // Secure storage functionality + "Development" // Development utilities +]) ``` -Usage ------ - -After installing the library, simply import `SwiftHelpers` at the top of your Swift files and start using the provided extensions and helper functions. +## Usage +### Foundation Extensions ```swift -import SwiftHelpers +import FoundationExtensions -// Example usage -let numbers = [1, 3, 2, 4] -let sortedNumbers = numbers.sorted { $0 < $1 } +// Safe array subscripting +let numbers = [1, 2, 3, 4, 5] +let safeValue = numbers[safeIndex: 10] // Returns nil instead of crashing -let dateString = "2023-01-01T12:00:00Z" -let date = try? Date(iso8601: dateString) +// Array sorting by key path +struct Person { + let name: String + let age: Int +} +let people = [Person(name: "Alice", age: 30), Person(name: "Bob", age: 25)] +let sortedByName = people.sorted(keyPath: \.name) +let sortedByAge = people.sorted(keyPath: \.age, ascending: false) + +// Value clamping +let clampedValue = 15.clamped(to: 10...20) // Returns 15 +let clampedHigh = 25.clamped(to: 10...20) // Returns 20 + +// Safe optional unwrapping with detailed errors +let optionalValue: String? = nil +do { + let value = try optionalValue.unwrapped() +} catch { + print("Failed to unwrap: \(error)") +} + +// Unique filtering +let numbers = [1, 2, 2, 3, 3, 3, 4] +let uniqueNumbers = numbers.unique() // [1, 2, 3, 4] +let uniqueByProperty = people.unique(by: \.age) ``` -#### Storage -The library defines two protocols for synchronous and asynchronous storage objects, respectively: +### Secure Storage -* `Storage`: A synchronous storage object that defines a set of methods for saving, loading, and removing encoded objects from a storage object. -* `AsyncStorage`: An asynchronous storage object that defines a set of methods for saving, loading, and removing encoded objects from a storage object asynchronously. +```swift +import SwiftStorage +import SwiftUI + +// Basic secure storage with automatic observation +struct ContentView: View { + @SecureStorage("user_preference", defaultValue: "default") + var userPreference: String + + var body: some View { + VStack { + Text("Current preference: \(userPreference)") + Button("Update Preference") { + userPreference = "updated_\(Date().timeIntervalSince1970)" + } + if let error = _userPreference.error { + Text("Error: \(error.localizedDescription)") + } + } + } +} -The `Storage` target provides two default implementations of `Storage`: +// Type-safe storage with keys +enum UserSettings: SecureStorageKey { + typealias Value = String + static var defaultValue: String = "default" +} -* `KeychainStorage`: A synchronous storage object that uses the system keychain to store and retrieve data. -* `UserDefaults`: A synchronous storage object that uses the `UserDefaults` system to store and retrieve data. +struct SettingsView: View { + @SecureStorage(UserSettings.self) + var userSetting: String + + var body: some View { + Text("Setting: \(userSetting)") + } +} + +// Custom storage service +struct ContentView: View { + @SecureStorage("key", defaultValue: "default", store: InMemorySecureStorage.shared) + var value: String + + var body: some View { + Text(value) + } +} +``` -The library provides a property wrapper `StorableValue` that enables easy and safe storage of any `Codable` object. To use `StorableValue`, initialize it with the default value, a key, and a storage object. +### Combine Extensions ```swift -import SwiftHelpers -import Storage +import CombineExtensions +import Combine + +// Bridge async/await with Combine +let future = TaskFuture { + try await Task.sleep(nanoseconds: 1_000_000_000) + return 42 +} -// Example usage of StorableValue with UserDefaults -@StorableValue(key: "exampleKey", storage: .userDefaults) -var exampleValue: String = "default" +future + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + print("Completed successfully") + case .failure(let error): + print("Failed with error: \(error)") + } + }, + receiveValue: { value in + print("Received value: \(value)") + } + ) +``` -// Example usage of StorableValue with KeychainStorage -@StorableValue(key: "exampleKey", storage: .userDefaults) -var secureValue: ExampleStruct? = nil +### Development Utilities -enum ExampleEnum: String, Codable { - case firstCase - case secondCase -} +```swift +import Development -struct ExampleStruct: Codable { - var intValue: Int - var stringValue: String - var enumValue: ExampleEnum -} +// Lorem ipsum generation +let sentence = String.randomSentence() +let paragraph = String.randomParagraph() +let lorem = String.randomLorem() + +// Random image URLs +let imageURL = URL.randomImage(width: 300, height: 200) -secureValue = ExampleStruct(intValue: 123, stringValue: "example", enumValue: .firstCase) +// Debug binding printing +@State private var count = 0 +var body: some View { + Stepper("Count", value: $count.print("Counter")) +} +// Console output: +// {< GET} Counter: 0 +// {> SET} Counter: 1 ``` Contributing diff --git a/Sources/FoundationExtensions/Optional+Unwrapped.swift b/Sources/FoundationExtensions/Optional+Unwrapped.swift index aa7c4e1..9966cbc 100644 --- a/Sources/FoundationExtensions/Optional+Unwrapped.swift +++ b/Sources/FoundationExtensions/Optional+Unwrapped.swift @@ -11,13 +11,16 @@ extension Optional { /// Unwrap the optional or throw an error /// - Parameter error: The error to throw if the optional is `nil` /// - Returns: The unwrapped value - public func unwrapped(or error: Error = OptionalUnwrapError.emptyValue) throws -> Wrapped { + public func unwrapped( + or error: Error? = nil, + file: StaticString = #file, + line: UInt = #line + ) throws -> Wrapped { switch self { case .some(let value): return value case .none: - print(error, "\(type(of: self))") - throw error + throw error ?? OptionalUnwrapError.emptyValue(file: file, line: line) } } @@ -32,9 +35,9 @@ extension Optional { } public enum OptionalUnwrapError: LocalizedError { - case emptyValue + case emptyValue(Wrapped.Type = Wrapped.self, file: StaticString = #file, line: UInt = #line) case message(String) - + public var recoverySuggestion: String? { switch self { case .emptyValue: diff --git a/Sources/FoundationExtensions/OptionalProtocol.swift b/Sources/FoundationExtensions/OptionalProtocol.swift index 05ad7b0..9c1d652 100644 --- a/Sources/FoundationExtensions/OptionalProtocol.swift +++ b/Sources/FoundationExtensions/OptionalProtocol.swift @@ -62,11 +62,12 @@ extension Optional: OptionalProtocol { /// ``` /// /// - Throws: `OptionalUnwrapError.emptyValue` if the optional is `.none`. + @available(*, deprecated, message: "Use unwrapped() instead") public var wrappedValue: Wrapped { get throws { switch self { case .none: - throw OptionalUnwrapError.emptyValue + throw OptionalUnwrapError.emptyValue() case .some(let value): return value } diff --git a/Sources/SwiftHelpers/HexColorContainer.swift b/Sources/SwiftHelpers/HexColorContainer.swift index 7ed1d94..abba497 100644 --- a/Sources/SwiftHelpers/HexColorContainer.swift +++ b/Sources/SwiftHelpers/HexColorContainer.swift @@ -7,7 +7,7 @@ import Foundation -public struct HexColorContainer: Sendable, Hashable { +public struct HexColorContainer: Sendable, Hashable, Codable { public let red: CGFloat public let green: CGFloat diff --git a/Sources/SwiftStorage/SecureStorage/Keychain.swift b/Sources/SwiftStorage/SecureStorage/KeychainSupport/Keychain.swift similarity index 100% rename from Sources/SwiftStorage/SecureStorage/Keychain.swift rename to Sources/SwiftStorage/SecureStorage/KeychainSupport/Keychain.swift diff --git a/Sources/SwiftStorage/SecureStorage/Observation/StorageObservableValue.swift b/Sources/SwiftStorage/SecureStorage/Observation/StorageObservableValue.swift new file mode 100644 index 0000000..fe57738 --- /dev/null +++ b/Sources/SwiftStorage/SecureStorage/Observation/StorageObservableValue.swift @@ -0,0 +1,58 @@ +// +// StorageObservableValue.swift +// SwiftHelpers +// +// Created by Valeriy Malishevskyi on 13.10.2025. +// + +import Foundation +import SwiftUI + +@MainActor +final class StorageObservableValue: ObservableObject { + let storage: SecureStorageService + let key: String + + @Published private(set) var value: Value + @Published var error: Error? + + var task: Task? + + init(storage: SecureStorageService, key: String, initialValue value: Value) { + self.storage = storage + self.key = key + self.value = value + } + + func updateValue(_ newValue: Value) { + do { + self.value = newValue + try storage.set(newValue, forKey: key) + } catch { + self.error = error + } + } + + func subscribe() { + guard task == nil else { return } + task = Task { + do { + for try await value in storage.observe(key: key) as AsyncThrowingStream { + self.value = value + self.error = nil + } + } catch { + self.error = error + } + } + } + + func unsubscribe() { + task?.cancel() + task = nil + } + + deinit { + task?.cancel() + } +} diff --git a/Sources/SwiftStorage/SecureStorage/Observation/StorageObservationsRegistrar.swift b/Sources/SwiftStorage/SecureStorage/Observation/StorageObservationsRegistrar.swift new file mode 100644 index 0000000..d934a6d --- /dev/null +++ b/Sources/SwiftStorage/SecureStorage/Observation/StorageObservationsRegistrar.swift @@ -0,0 +1,30 @@ +// +// StorageObservationsRegistrar.swift +// SwiftHelpers +// +// Created by Valeriy Malishevskyi on 13.10.2025. +// + +import Foundation + +@MainActor +final class StorageObservationsRegistrar { + static let shared = StorageObservationsRegistrar() + + private var subscriptions: [String: AnyObject] = [:] + + func register(_ observation: StorageObservableValue, forKey key: String) { + subscriptions[key] = observation + } + + func unregister(forKey key: String) { + subscriptions.removeValue(forKey: key) + } + + func observation(forKey key: String) -> StorageObservableValue? { + if let subscription = subscriptions[key] as? StorageObservableValue { + return subscription + } + return nil + } +} diff --git a/Sources/SwiftStorage/SecureStorage/SecureStorage.swift b/Sources/SwiftStorage/SecureStorage/SecureStorage.swift index 3b1c41a..66034f6 100644 --- a/Sources/SwiftStorage/SecureStorage/SecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/SecureStorage.swift @@ -10,71 +10,57 @@ import SwiftUI import FoundationExtensions @propertyWrapper @MainActor -public struct SecureStorage: DynamicProperty where Value: Codable { +public struct SecureStorage: DynamicProperty where Value: Codable & Sendable { - final class Container { - /// Inicates that object installed on a View - var installed = false - } - - private let key: String - private let defaultValue: Value - private let storage: SecureStorageService - private let container = Container() + @StateObject private var observableValue: StorageObservableValue - @StateObject private var state = State() - - public init(_ key: String, defaultValue: Value, store: SecureStorageService = KeychainSecureStorage.shared) { - self.key = key - self.defaultValue = defaultValue - + public init( + _ key: String, + defaultValue: Value, + store: SecureStorageService = KeychainSecureStorage.shared + ) { + let storage: SecureStorageService if ProcessInfo.isPreview { - self.storage = InMemorySecureStorage.shared + #if DEBUG + do { + storage = try DiskNonSecureStorage(fileName: "preview_secure_storage.json") + } catch { + // Fallback to in-memory storage if document directory is not available + storage = InMemorySecureStorage.shared + } + #else + storage = store + #endif } else { - self.storage = store + storage = store } + let observableValue = StorageObservationsRegistrar.shared.observation(forKey: key) + ?? StorageObservableValue( + storage: storage, + key: key, + initialValue: defaultValue + ) + StorageObservationsRegistrar.shared.register(observableValue, forKey: key) + self._observableValue = StateObject(wrappedValue: observableValue) } public var wrappedValue: Value { get { - do { - let value = try storage.value(type: Value.self, forKey: key) - return value - } catch { - print("Failed to get value for key \(key): \(error)") - return defaultValue - } + observableValue.value } nonmutating set { - do { - if container.installed { state.objectWillChange.send() } - - try storage.set(newValue, forKey: key) - } catch { - print("Failed to set value for key \(key): \(error)") - } + observableValue.updateValue(newValue) } } - nonisolated public func update() { - DispatchQueue.main.async { - _update() - } + public var error: Error? { + observableValue.error } - func _update() { - guard !container.installed else { return } - defer { container.installed = true } - - let subscription = Subscription(key: key) { [state] in - state.objectWillChange.send() - } - - self.storage.subscribe(subscription: subscription) - - self.state.subscriberState.unsubscribe = { - storage.unsubscribe(subscription: subscription) // TODO: Implement unsubscribe + public nonisolated func update() { + Task { @MainActor in + observableValue.subscribe() } } } @@ -85,20 +71,4 @@ extension SecureStorage where Value: ExpressibleByNilLiteral { } } -extension SecureStorage { - @MainActor final class State: ObservableObject { - let subscriberState = SubscriberState() - } -} - -@MainActor internal final class SubscriberState { - var unsubscribe: (@MainActor () -> Void)? - - deinit { - Task(priority: .high) { @MainActor [unsubscribe] in - unsubscribe?() - } - } -} - -#endif +#endif \ No newline at end of file diff --git a/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift b/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift new file mode 100644 index 0000000..bb4ed0d --- /dev/null +++ b/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift @@ -0,0 +1,12 @@ +// +// SecureStorageError.swift +// SwiftHelpers +// +// Created by Valeriy Malishevskyi on 13.10.2025. +// + +enum SecureStorageError: Error { + case valueNotFound + case encodingFailed + case documentDirectoryNotFound +} diff --git a/Sources/SwiftStorage/SecureStorage/SecureStorageService.swift b/Sources/SwiftStorage/SecureStorage/SecureStorageService.swift deleted file mode 100644 index 5255a35..0000000 --- a/Sources/SwiftStorage/SecureStorage/SecureStorageService.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// SecureStorageService.swift -// SwiftUIHelpers -// -// Created by Valeriy Malishevskyi on 04.09.2024. -// - -@MainActor -public protocol SecureStorageService { - func value(type: T.Type, forKey key: String) throws -> T - func set(_ value: T, forKey key: String) throws - func subscribe(subscription: Subscription) - func unsubscribe(subscription: Subscription) -} diff --git a/Sources/SwiftStorage/SecureStorage/Service/DiskNonSecureStorage.swift b/Sources/SwiftStorage/SecureStorage/Service/DiskNonSecureStorage.swift new file mode 100644 index 0000000..d69ad59 --- /dev/null +++ b/Sources/SwiftStorage/SecureStorage/Service/DiskNonSecureStorage.swift @@ -0,0 +1,106 @@ +// +// DiskNonSecureStorage.swift +// SwiftHelpers +// +// Created by Valeriy Malishevskyi on 13.10.2025. +// + +import Foundation +import Combine + +#if DEBUG +struct DiskNonSecureStorage: SecureStorageService { + + let fileName: String + let fileManager: FileManager + + let changePublisher = PassthroughSubject() + + init(fileName: String, fileManager: FileManager = .default) throws { + self.fileName = fileName + self.fileManager = fileManager + + try createFileIfNeeded() + } + + private func documentDirectoryURL() throws -> URL { + guard let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw SecureStorageError.documentDirectoryNotFound + } + return url + } + + private func fileURL() throws -> URL { + return try documentDirectoryURL().appendingPathComponent(fileName) + } + + private func createFileIfNeeded() throws { + let url = try fileURL() + if !fileManager.fileExists(atPath: url.path) { + // Initialize with empty dictionary + let emptyDictionary: [String: Data] = [:] + let data = try JSONEncoder().encode(emptyDictionary) + try data.write(to: url) + } + } + + func value(type: T.Type, forKey key: String) throws -> T where T : Decodable { + let url = try fileURL() + let data = try Data(contentsOf: url) + + // Decode the dictionary of key-value pairs + let dictionary = try JSONDecoder().decode([String: Data].self, from: data) + + guard let keyData = dictionary[key] else { + throw SecureStorageError.valueNotFound + } + + return try JSONDecoder().decode(T.self, from: keyData) + } + + func set(_ value: T, forKey key: String) throws where T : Encodable { + let url = try fileURL() + + // Read existing dictionary or create new one + var dictionary: [String: Data] = [:] + if fileManager.fileExists(atPath: url.path) { + let data = try Data(contentsOf: url) + dictionary = try JSONDecoder().decode([String: Data].self, from: data) + } + + // Encode the new value and store it in the dictionary + let valueData = try JSONEncoder().encode(value) + dictionary[key] = valueData + + // Write the updated dictionary back to the file + let dictionaryData = try JSONEncoder().encode(dictionary) + try dictionaryData.write(to: url) + + changePublisher.send(key) + } + + func observe(key: String) -> AsyncStream where T : Decodable, T : Encodable { + AsyncStream { continuation in + do { + try continuation.yield(self.value(type: T.self, forKey: key)) + } catch { + print("Failed to yield initial value for key \(key): \(error)") + } + + let subscription = changePublisher + .filter { $0 == key } + .sink { _ in + if let value: T = try? self.value(type: T.self, forKey: key) { + continuation.yield(value) + } + } + + continuation.onTermination = { @Sendable _ in + subscription.cancel() + } + } + } +} +#endif + +extension AnyCancellable: @unchecked @retroactive Sendable {} diff --git a/Sources/SwiftStorage/SecureStorage/InMemorySecureStorage.swift b/Sources/SwiftStorage/SecureStorage/Service/InMemorySecureStorage.swift similarity index 59% rename from Sources/SwiftStorage/SecureStorage/InMemorySecureStorage.swift rename to Sources/SwiftStorage/SecureStorage/Service/InMemorySecureStorage.swift index cfa90e6..7e839f3 100644 --- a/Sources/SwiftStorage/SecureStorage/InMemorySecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/Service/InMemorySecureStorage.swift @@ -6,11 +6,12 @@ // import Foundation +import Combine public final class InMemorySecureStorage { private let storage = NSCache() - private var subscriptions: [Subscription] = [] + private let changePublisher = PassthroughSubject() /// Initialize with a default service name and access mode private init() {} @@ -40,21 +41,31 @@ extension InMemorySecureStorage: SecureStorageService { throw SecureStorageError.encodingFailed } - for subscription in subscriptions { - Task { - subscription.update() - } - } - storage.setObject(data as NSData, forKey: key as NSString) + changePublisher.send(key) } - public func subscribe(subscription: Subscription) { - subscriptions.append(subscription) - print("Subscribed to \(subscription.key)") - } - - public func unsubscribe(subscription: Subscription) { - subscriptions.removeAll { $0.key == subscription.key } + public func observe(key: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + do { + let value = try self.value(type: T.self, forKey: key) + continuation.yield(value) + } catch { + continuation.finish(throwing: error) + return + } + + let subscription = changePublisher + .filter { $0 == key } + .sink { _ in + if let value: T = try? self.value(type: T.self, forKey: key) { + continuation.yield(value) + } + } + + continuation.onTermination = { @Sendable _ in + subscription.cancel() + } + } } } diff --git a/Sources/SwiftStorage/SecureStorage/KeychainSecureStorage.swift b/Sources/SwiftStorage/SecureStorage/Service/KeychainSecureStorage.swift similarity index 61% rename from Sources/SwiftStorage/SecureStorage/KeychainSecureStorage.swift rename to Sources/SwiftStorage/SecureStorage/Service/KeychainSecureStorage.swift index bcacabb..8aba0bb 100644 --- a/Sources/SwiftStorage/SecureStorage/KeychainSecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/Service/KeychainSecureStorage.swift @@ -6,13 +6,16 @@ // import Foundation +import Combine public final class KeychainSecureStorage { private let keychain: Keychain + private let changePublisher = PassthroughSubject() +// private let observers = KeychainObserverRegistry() /// Initialize with a default service name and access mode - private init(serviceName: String = Bundle.main.serviceName) { + init(serviceName: String = Bundle.main.serviceName) { self.keychain = Keychain( serviceName: serviceName, accessMode: kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String @@ -21,8 +24,6 @@ public final class KeychainSecureStorage { @MainActor public static let shared = KeychainSecureStorage() - var subscriptions: [Subscription] = [] - /// Convenience method to convert AnyHashable to String private func keyAsString(_ key: AnyHashable) -> String { guard let keyString = key as? String else { @@ -33,6 +34,7 @@ public final class KeychainSecureStorage { } extension KeychainSecureStorage: SecureStorageService { + public func value(type: T.Type, forKey key: String) throws -> T where T: Decodable { guard let data = try keychain.getData(key: key) else { @@ -47,34 +49,31 @@ extension KeychainSecureStorage: SecureStorageService { } keychain.addOrUpdate(key: key, data: data) - - - for subscription in subscriptions { - subscription.update() - } - } - - public func subscribe(subscription: Subscription) { - subscriptions.append(subscription) + changePublisher.send(key) } - public func unsubscribe(subscription: Subscription) { - subscriptions.removeAll(where: { $0.key == subscription.key }) - } -} - -enum SecureStorageError: Error { - case valueNotFound - case encodingFailed -} - -public struct Subscription: Sendable { - let key: String - let update: @MainActor @Sendable () -> Void - - public init(key: String, update: @MainActor @escaping () -> Void) { - self.key = key - self.update = update + public func observe(key: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + do { + let value = try self.value(type: T.self, forKey: key) + continuation.yield(value) + } catch { + continuation.finish(throwing: error) + return + } + + let subscription = changePublisher + .filter { $0 == key } + .sink { _ in + if let value: T = try? self.value(type: T.self, forKey: key) { + continuation.yield(value) + } + } + + continuation.onTermination = { @Sendable _ in + subscription.cancel() + } + } } } diff --git a/Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift b/Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift new file mode 100644 index 0000000..a78c7e7 --- /dev/null +++ b/Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift @@ -0,0 +1,33 @@ +// +// SecureStorageService.swift +// SwiftUIHelpers +// +// Created by Valeriy Malishevskyi on 04.09.2024. +// + +public protocol SecureStorageService { + func value(type: T.Type, forKey key: String) throws -> T + func set(_ value: T, forKey key: String) throws + + func observe(key: String) -> AsyncThrowingStream +} + +extension SecureStorageService { + public func observe(key: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + do { + let value = try value(type: T.self, forKey: key) + continuation.yield(value) + } catch { + continuation.finish(throwing: error) + return + } + + // Keep the stream alive for continuous observation + // Subclasses should override this method to provide proper notification mechanism + continuation.onTermination = { _ in + // Stream terminated + } + } + } +} diff --git a/Tests/SwiftStorageTests/SecureStorageObservationTests.swift b/Tests/SwiftStorageTests/SecureStorageObservationTests.swift new file mode 100644 index 0000000..a26374b --- /dev/null +++ b/Tests/SwiftStorageTests/SecureStorageObservationTests.swift @@ -0,0 +1,143 @@ +// +// SecureStorageObservationTests.swift +// SwiftHelpers +// +// Created by Valeriy Malishevskyi on 13.10.2025. +// + +import Testing +@testable import SwiftStorage + +@MainActor +struct SecureStorageObservationTests { + + @Test func inMemorySecureStorageObservation() async throws { + let storage = InMemorySecureStorage.shared + + // Set initial value + try storage.set("initial", forKey: "test_key") + + // Create observation stream + let stream = storage.observe(key: "test_key") as AsyncThrowingStream + + // Collect values from the stream + var receivedValues: [String] = [] + let task = Task { + do { + for try await value in stream { + receivedValues.append(value) + // Stop after receiving 3 values + if receivedValues.count >= 3 { + break + } + } + } catch { + Issue.record("Stream failed with error: \(error)") + } + } + + // Wait a bit for initial value + try await Task.sleep(for: .milliseconds(10)) + + // Update the value twice + try storage.set("updated1", forKey: "test_key") + try await Task.sleep(for: .milliseconds(10)) + + try storage.set("updated2", forKey: "test_key") + + // Wait for the task to complete + await task.value + + // Verify we received all expected values + #expect(receivedValues.count == 3) + #expect(receivedValues[0] == "initial") + #expect(receivedValues[1] == "updated1") + #expect(receivedValues[2] == "updated2") + } + + @Test func keychainSecureStorageObservation() async throws { + let storage = KeychainSecureStorage(serviceName: "TestKeychain") + + // Set initial value + try storage.set("initial", forKey: "test_keychain_key") + + // Create observation stream + let stream = storage.observe(key: "test_keychain_key") as AsyncThrowingStream + + // Collect values from the stream + var receivedValues: [String] = [] + let task = Task { + do { + for try await value in stream { + receivedValues.append(value) + // Stop after receiving 3 values + if receivedValues.count >= 3 { + break + } + } + } catch { + Issue.record("Stream failed with error: \(error)") + } + } + + // Wait a bit for initial value + try await Task.sleep(for: .milliseconds(10)) + + // Update the value twice + try storage.set("updated1", forKey: "test_keychain_key") + try await Task.sleep(for: .milliseconds(10)) + + try storage.set("updated2", forKey: "test_keychain_key") + + // Wait for the task to complete + await task.value + + // Verify we received all expected values + #expect(receivedValues.count == 3) + #expect(receivedValues[0] == "initial") + #expect(receivedValues[1] == "updated1") + #expect(receivedValues[2] == "updated2") + } + + @Test func diskNonSecureStorageObservation() async throws { + #if DEBUG + let storage = try DiskNonSecureStorage(fileName: "test_observation.json") + + // Set initial value + try storage.set("initial", forKey: "test_disk_key") + + // Create observation stream + let stream = storage.observe(key: "test_disk_key") as AsyncStream + + // Collect values from the stream + var receivedValues: [String] = [] + let task = Task { + for await value in stream { + receivedValues.append(value) + // Stop after receiving 3 values + if receivedValues.count >= 3 { + break + } + } + } + + // Wait a bit for initial value + try await Task.sleep(for: .milliseconds(10)) + + // Update the value twice + try storage.set("updated1", forKey: "test_disk_key") + try await Task.sleep(for: .milliseconds(10)) + + try storage.set("updated2", forKey: "test_disk_key") + + // Wait for the task to complete + await task.value + + // Verify we received all expected values + #expect(receivedValues.count == 3) + #expect(receivedValues[0] == "initial") + #expect(receivedValues[1] == "updated1") + #expect(receivedValues[2] == "updated2") + #endif + } +}