From d6de94d591fa1307b33f7ae6cfec60de779c073a Mon Sep 17 00:00:00 2001 From: Valeriy Malishevskyi Date: Mon, 13 Oct 2025 22:16:10 +0300 Subject: [PATCH 1/4] Refactor SecureStorage to support observation and async streams Introduces StorageObservableValue and StorageObservationsRegistrar for observable storage values. Refactors SecureStorage and SecureStorageService to use async streams for value observation, removing legacy subscription logic. Adds DiskNonSecureStorage for preview/debug use, moves storage implementations to Service subfolder, and defines SecureStorageError. --- .../{ => KeychainSupport}/Keychain.swift | 0 .../Observation/StorageObservableValue.swift | 44 ++++++++ .../StorageObservationsRegistrar.swift | 30 +++++ .../SecureStorage/SecureStorage.swift | 104 ++++++++---------- .../SecureStorage/SecureStorageError.swift | 11 ++ .../SecureStorage/SecureStorageService.swift | 14 --- .../Service/DiskNonSecureStorage.swift | 71 ++++++++++++ .../{ => Service}/InMemorySecureStorage.swift | 16 --- .../{ => Service}/KeychainSecureStorage.swift | 34 +----- .../Service/SecureStorageService.swift | 26 +++++ 10 files changed, 229 insertions(+), 121 deletions(-) rename Sources/SwiftStorage/SecureStorage/{ => KeychainSupport}/Keychain.swift (100%) create mode 100644 Sources/SwiftStorage/SecureStorage/Observation/StorageObservableValue.swift create mode 100644 Sources/SwiftStorage/SecureStorage/Observation/StorageObservationsRegistrar.swift create mode 100644 Sources/SwiftStorage/SecureStorage/SecureStorageError.swift delete mode 100644 Sources/SwiftStorage/SecureStorage/SecureStorageService.swift create mode 100644 Sources/SwiftStorage/SecureStorage/Service/DiskNonSecureStorage.swift rename Sources/SwiftStorage/SecureStorage/{ => Service}/InMemorySecureStorage.swift (73%) rename Sources/SwiftStorage/SecureStorage/{ => Service}/KeychainSecureStorage.swift (67%) create mode 100644 Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift 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..f4e49e0 --- /dev/null +++ b/Sources/SwiftStorage/SecureStorage/Observation/StorageObservableValue.swift @@ -0,0 +1,44 @@ +// +// 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 { + for try await value in storage.observe(key: key) as AsyncStream { + self.value = value + } + } + } +} 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..9921562 100644 --- a/Sources/SwiftStorage/SecureStorage/SecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/SecureStorage.swift @@ -12,69 +12,49 @@ import FoundationExtensions @propertyWrapper @MainActor public struct SecureStorage: DynamicProperty where Value: Codable { - 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 state = State() + @StateObject private var observableValue: StorageObservableValue - public init(_ key: String, defaultValue: Value, store: SecureStorageService = KeychainSecureStorage.shared) { - self.key = key - self.defaultValue = defaultValue - - if ProcessInfo.isPreview { - self.storage = InMemorySecureStorage.shared + public init( + _ key: String, + defaultValue: Value, + store: SecureStorageService = KeychainSecureStorage.shared + ) { + let store = if ProcessInfo.isPreview { + #if DEBUG + DiskNonSecureStorage(fileName: "preview_secure_storage.json") + #else + store + #endif } else { - self.storage = store + store } + let observableValue = StorageObservationsRegistrar.shared.observation(forKey: key) + ?? StorageObservableValue( + storage: store, + 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 +65,24 @@ extension SecureStorage where Value: ExpressibleByNilLiteral { } } -extension SecureStorage { - @MainActor final class State: ObservableObject { - let subscriberState = SubscriberState() - } -} +#endif + -@MainActor internal final class SubscriberState { - var unsubscribe: (@MainActor () -> Void)? - - deinit { - Task(priority: .high) { @MainActor [unsubscribe] in - unsubscribe?() +private struct PreviewView: View { + @SecureStorage("exampleKey", defaultValue: "Default Value") + var exampleValue: String? + + var body: some View { + Button("Update Value \(exampleValue)") { + exampleValue = "Updated Value \(Date())" + } + if let error = _exampleValue.error { + Text("Error: \(String(describing: error))") } } } +#Preview { + PreviewView() + PreviewView() +} -#endif diff --git a/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift b/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift new file mode 100644 index 0000000..6e2662b --- /dev/null +++ b/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift @@ -0,0 +1,11 @@ +// +// SecureStorageError.swift +// SwiftHelpers +// +// Created by Valeriy Malishevskyi on 13.10.2025. +// + +enum SecureStorageError: Error { + case valueNotFound + case encodingFailed +} 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..75fac1d --- /dev/null +++ b/Sources/SwiftStorage/SecureStorage/Service/DiskNonSecureStorage.swift @@ -0,0 +1,71 @@ +// +// 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) { + self.fileName = fileName + self.fileManager = fileManager + + createFileIfNeeded() + } + + private func createFileIfNeeded() { + let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName) + if !fileManager.fileExists(atPath: url.path) { + try? Data().write(to: url) + } + } + + func value(type: T.Type, forKey key: String) throws -> T where T : Decodable { + let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName) + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(T.self, from: data) + } + + func set(_ value: T, forKey key: String) throws where T : Encodable { + let data = try JSONEncoder().encode(value) + let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName) + try data.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 73% rename from Sources/SwiftStorage/SecureStorage/InMemorySecureStorage.swift rename to Sources/SwiftStorage/SecureStorage/Service/InMemorySecureStorage.swift index cfa90e6..9c01a4a 100644 --- a/Sources/SwiftStorage/SecureStorage/InMemorySecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/Service/InMemorySecureStorage.swift @@ -10,7 +10,6 @@ import Foundation public final class InMemorySecureStorage { private let storage = NSCache() - private var subscriptions: [Subscription] = [] /// Initialize with a default service name and access mode private init() {} @@ -40,21 +39,6 @@ extension InMemorySecureStorage: SecureStorageService { throw SecureStorageError.encodingFailed } - for subscription in subscriptions { - Task { - subscription.update() - } - } - storage.setObject(data as NSData, forKey: key as NSString) } - - public func subscribe(subscription: Subscription) { - subscriptions.append(subscription) - print("Subscribed to \(subscription.key)") - } - - public func unsubscribe(subscription: Subscription) { - subscriptions.removeAll { $0.key == subscription.key } - } } diff --git a/Sources/SwiftStorage/SecureStorage/KeychainSecureStorage.swift b/Sources/SwiftStorage/SecureStorage/Service/KeychainSecureStorage.swift similarity index 67% rename from Sources/SwiftStorage/SecureStorage/KeychainSecureStorage.swift rename to Sources/SwiftStorage/SecureStorage/Service/KeychainSecureStorage.swift index bcacabb..60c5d0e 100644 --- a/Sources/SwiftStorage/SecureStorage/KeychainSecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/Service/KeychainSecureStorage.swift @@ -10,9 +10,10 @@ import Foundation public final class KeychainSecureStorage { private let keychain: Keychain +// 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 +22,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 +32,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 +47,6 @@ extension KeychainSecureStorage: SecureStorageService { } keychain.addOrUpdate(key: key, data: data) - - - for subscription in subscriptions { - subscription.update() - } - } - - public func subscribe(subscription: Subscription) { - subscriptions.append(subscription) - } - - 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 } } diff --git a/Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift b/Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift new file mode 100644 index 0000000..1e25751 --- /dev/null +++ b/Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift @@ -0,0 +1,26 @@ +// +// 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) -> AsyncStream +} + +extension SecureStorageService { + public func observe(key: String) -> AsyncStream { + AsyncStream { continuation in + do { + let value = try value(type: T.self, forKey: key) + continuation.yield(value) + } catch { + continuation.finish() + } + } + } +} From 3313bad8724285d6d5955efe6fe17dfa57dac0b2 Mon Sep 17 00:00:00 2001 From: Valeriy Malishevskyi Date: Mon, 13 Oct 2025 22:17:03 +0300 Subject: [PATCH 2/4] Improve Optional unwrapping and HexColorContainer Enhanced Optional.unwrapped to include file and line info in errors and made OptionalUnwrapError more informative. Deprecated wrappedValue in favor of unwrapped(). Added Codable conformance to HexColorContainer. --- .../FoundationExtensions/Optional+Unwrapped.swift | 13 ++++++++----- Sources/FoundationExtensions/OptionalProtocol.swift | 3 ++- Sources/SwiftHelpers/HexColorContainer.swift | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) 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 From dc71b699c49b5775c69ba26d9a9adaa566e9b1de Mon Sep 17 00:00:00 2001 From: Valeriy Malishevskyi Date: Mon, 13 Oct 2025 22:48:49 +0300 Subject: [PATCH 3/4] Add observation support to SecureStorage services Introduces observation streams to SecureStorage services, allowing clients to observe value changes for specific keys. Updates protocols and implementations for in-memory, keychain, and disk storage to support AsyncThrowingStream-based observation. Adds error handling for missing document directories and new tests for observation functionality. --- Package.swift | 4 + .../Observation/StorageObservableValue.swift | 22 ++- .../SecureStorage/SecureStorage.swift | 40 ++--- .../SecureStorage/SecureStorageError.swift | 1 + .../Service/DiskNonSecureStorage.swift | 55 +++++-- .../Service/InMemorySecureStorage.swift | 27 ++++ .../Service/KeychainSecureStorage.swift | 27 ++++ .../Service/SecureStorageService.swift | 15 +- .../SecureStorageObservationTests.swift | 143 ++++++++++++++++++ 9 files changed, 289 insertions(+), 45 deletions(-) create mode 100644 Tests/SwiftStorageTests/SecureStorageObservationTests.swift 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/Sources/SwiftStorage/SecureStorage/Observation/StorageObservableValue.swift b/Sources/SwiftStorage/SecureStorage/Observation/StorageObservableValue.swift index f4e49e0..fe57738 100644 --- a/Sources/SwiftStorage/SecureStorage/Observation/StorageObservableValue.swift +++ b/Sources/SwiftStorage/SecureStorage/Observation/StorageObservableValue.swift @@ -9,14 +9,14 @@ import Foundation import SwiftUI @MainActor -final class StorageObservableValue: ObservableObject { +final class StorageObservableValue: ObservableObject { let storage: SecureStorageService let key: String @Published private(set) var value: Value @Published var error: Error? - var task: Task? + var task: Task? init(storage: SecureStorageService, key: String, initialValue value: Value) { self.storage = storage @@ -36,9 +36,23 @@ final class StorageObservableValue: ObservableObject { func subscribe() { guard task == nil else { return } task = Task { - for try await value in storage.observe(key: key) as AsyncStream { - self.value = value + 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/SecureStorage.swift b/Sources/SwiftStorage/SecureStorage/SecureStorage.swift index 9921562..6810097 100644 --- a/Sources/SwiftStorage/SecureStorage/SecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/SecureStorage.swift @@ -10,7 +10,7 @@ import SwiftUI import FoundationExtensions @propertyWrapper @MainActor -public struct SecureStorage: DynamicProperty where Value: Codable { +public struct SecureStorage: DynamicProperty where Value: Codable & Sendable { @StateObject private var observableValue: StorageObservableValue @@ -19,18 +19,24 @@ public struct SecureStorage: DynamicProperty where Value: Codable { defaultValue: Value, store: SecureStorageService = KeychainSecureStorage.shared ) { - let store = if ProcessInfo.isPreview { + let storage: SecureStorageService + if ProcessInfo.isPreview { #if DEBUG - DiskNonSecureStorage(fileName: "preview_secure_storage.json") + 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 - store + finalStore = store #endif } else { - store + storage = store } let observableValue = StorageObservationsRegistrar.shared.observation(forKey: key) ?? StorageObservableValue( - storage: store, + storage: storage, key: key, initialValue: defaultValue ) @@ -65,24 +71,4 @@ extension SecureStorage where Value: ExpressibleByNilLiteral { } } -#endif - - -private struct PreviewView: View { - @SecureStorage("exampleKey", defaultValue: "Default Value") - var exampleValue: String? - - var body: some View { - Button("Update Value \(exampleValue)") { - exampleValue = "Updated Value \(Date())" - } - if let error = _exampleValue.error { - Text("Error: \(String(describing: error))") - } - } -} -#Preview { - PreviewView() - PreviewView() -} - +#endif \ No newline at end of file diff --git a/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift b/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift index 6e2662b..bb4ed0d 100644 --- a/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift +++ b/Sources/SwiftStorage/SecureStorage/SecureStorageError.swift @@ -8,4 +8,5 @@ enum SecureStorageError: Error { case valueNotFound case encodingFailed + case documentDirectoryNotFound } diff --git a/Sources/SwiftStorage/SecureStorage/Service/DiskNonSecureStorage.swift b/Sources/SwiftStorage/SecureStorage/Service/DiskNonSecureStorage.swift index 75fac1d..d69ad59 100644 --- a/Sources/SwiftStorage/SecureStorage/Service/DiskNonSecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/Service/DiskNonSecureStorage.swift @@ -16,30 +16,65 @@ struct DiskNonSecureStorage: SecureStorageService { let changePublisher = PassthroughSubject() - init(fileName: String, fileManager: FileManager = .default) { + init(fileName: String, fileManager: FileManager = .default) throws { self.fileName = fileName self.fileManager = fileManager - createFileIfNeeded() + try createFileIfNeeded() } - private func createFileIfNeeded() { - let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName) + 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) { - try? Data().write(to: url) + // 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 = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName) + let url = try fileURL() let data = try Data(contentsOf: url) - return try JSONDecoder().decode(T.self, from: data) + + // 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 data = try JSONEncoder().encode(value) - let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName) - try data.write(to: url) + 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) } diff --git a/Sources/SwiftStorage/SecureStorage/Service/InMemorySecureStorage.swift b/Sources/SwiftStorage/SecureStorage/Service/InMemorySecureStorage.swift index 9c01a4a..7e839f3 100644 --- a/Sources/SwiftStorage/SecureStorage/Service/InMemorySecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/Service/InMemorySecureStorage.swift @@ -6,10 +6,12 @@ // import Foundation +import Combine public final class InMemorySecureStorage { private let storage = NSCache() + private let changePublisher = PassthroughSubject() /// Initialize with a default service name and access mode private init() {} @@ -40,5 +42,30 @@ extension InMemorySecureStorage: SecureStorageService { } storage.setObject(data as NSData, forKey: key as NSString) + changePublisher.send(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/Service/KeychainSecureStorage.swift b/Sources/SwiftStorage/SecureStorage/Service/KeychainSecureStorage.swift index 60c5d0e..8aba0bb 100644 --- a/Sources/SwiftStorage/SecureStorage/Service/KeychainSecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/Service/KeychainSecureStorage.swift @@ -6,10 +6,12 @@ // 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 @@ -47,6 +49,31 @@ extension KeychainSecureStorage: SecureStorageService { } keychain.addOrUpdate(key: key, data: data) + changePublisher.send(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/Service/SecureStorageService.swift b/Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift index 1e25751..a78c7e7 100644 --- a/Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift +++ b/Sources/SwiftStorage/SecureStorage/Service/SecureStorageService.swift @@ -9,17 +9,24 @@ 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) -> AsyncStream + func observe(key: String) -> AsyncThrowingStream } extension SecureStorageService { - public func observe(key: String) -> AsyncStream { - AsyncStream { continuation in + 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() + 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 + } +} From a4b015b11fcae09ff2c3043f28b65c92e903fbaa Mon Sep 17 00:00:00 2001 From: Valeriy Malishevskyi Date: Mon, 13 Oct 2025 23:01:06 +0300 Subject: [PATCH 4/4] Update README and fix SecureStorage store assignment Expanded the README with detailed module descriptions, usage examples, and installation instructions for SwiftHelpers. Fixed a variable assignment in SecureStorage.swift to correctly assign the provided store to the storage property. --- README.md | 221 +++++++++++++----- .../SecureStorage/SecureStorage.swift | 2 +- 2 files changed, 164 insertions(+), 59 deletions(-) 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/SwiftStorage/SecureStorage/SecureStorage.swift b/Sources/SwiftStorage/SecureStorage/SecureStorage.swift index 6810097..66034f6 100644 --- a/Sources/SwiftStorage/SecureStorage/SecureStorage.swift +++ b/Sources/SwiftStorage/SecureStorage/SecureStorage.swift @@ -29,7 +29,7 @@ public struct SecureStorage: DynamicProperty where Value: Codable & Senda storage = InMemorySecureStorage.shared } #else - finalStore = store + storage = store #endif } else { storage = store