diff --git a/Package.swift b/Package.swift index ec1ca4fe..2fd4e538 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,9 @@ -// swift-tools-version:5.0 +// swift-tools-version:6.2 +// swiftlint:disable prefixed_toplevel_constant // The swift-tools-version declares the minimum version of Swift required to build this package. -import PackageDescription import class Foundation.ProcessInfo +import PackageDescription let shouldTest = ProcessInfo.processInfo.environment["TEST"] == "1" @@ -15,22 +16,40 @@ func resolveDependencies() -> [Package.Dependency] { ] } +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("InferSendableFromCaptures"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("ExistentialAny"), +] + func resolveTargets() -> [Target] { - let baseTarget = Target.target(name: "SwiftyUserDefaults", dependencies: [], path: "Sources") - let testTarget = Target.testTarget(name: "SwiftyUserDefaultsTests", dependencies: ["SwiftyUserDefaults", "Quick", "Nimble"]) + let baseTarget = Target.target( + name: "SwiftyUserDefaults", + dependencies: [], + path: "Sources", + exclude: ["Info.plist"], + swiftSettings: swiftSettings + ) + let testTarget = Target.testTarget( + name: "SwiftyUserDefaultsTests", + dependencies: ["SwiftyUserDefaults", "Quick", "Nimble"], + swiftSettings: swiftSettings + ) return shouldTest ? [baseTarget, testTarget] : [baseTarget] } - let package = Package( name: "SwiftyUserDefaults", platforms: [ - .macOS(.v10_11), .iOS(.v9), .tvOS(.v9), .watchOS(.v2) + .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), ], products: [ .library(name: "SwiftyUserDefaults", targets: ["SwiftyUserDefaults"]), ], dependencies: resolveDependencies(), - targets: resolveTargets() + targets: resolveTargets(), + swiftLanguageModes: [.v6] ) diff --git a/Sources/BuiltIns.swift b/Sources/BuiltIns.swift index 8a727265..83114601 100644 --- a/Sources/BuiltIns.swift +++ b/Sources/BuiltIns.swift @@ -24,50 +24,56 @@ import Foundation -extension DefaultsSerializable { - public static var _defaultsArray: DefaultsArrayBridge<[T]> { return DefaultsArrayBridge() } +public extension DefaultsSerializable { + static var _defaultsArray: DefaultsArrayBridge<[T]> { return DefaultsArrayBridge() } } + extension Date: DefaultsSerializable { public static var _defaults: DefaultsObjectBridge { return DefaultsObjectBridge() } } + extension String: DefaultsSerializable { public static var _defaults: DefaultsStringBridge { return DefaultsStringBridge() } } + extension Int: DefaultsSerializable { public static var _defaults: DefaultsIntBridge { return DefaultsIntBridge() } } + extension Double: DefaultsSerializable { public static var _defaults: DefaultsDoubleBridge { return DefaultsDoubleBridge() } } + extension Bool: DefaultsSerializable { public static var _defaults: DefaultsBoolBridge { return DefaultsBoolBridge() } } + extension Data: DefaultsSerializable { public static var _defaults: DefaultsDataBridge { return DefaultsDataBridge() } } extension URL: DefaultsSerializable { #if os(Linux) - public static var _defaults: DefaultsKeyedArchiverBridge { return DefaultsKeyedArchiverBridge() } + public static var _defaults: DefaultsKeyedArchiverBridge { return DefaultsKeyedArchiverBridge() } #else - public static var _defaults: DefaultsUrlBridge { return DefaultsUrlBridge() } + public static var _defaults: DefaultsUrlBridge { return DefaultsUrlBridge() } #endif public static var _defaultsArray: DefaultsKeyedArchiverBridge<[URL]> { return DefaultsKeyedArchiverBridge() } } -extension DefaultsSerializable where Self: Codable { - public static var _defaults: DefaultsCodableBridge { return DefaultsCodableBridge() } - public static var _defaultsArray: DefaultsCodableBridge<[Self]> { return DefaultsCodableBridge() } +public extension DefaultsSerializable where Self: Codable { + static var _defaults: DefaultsCodableBridge { return DefaultsCodableBridge() } + static var _defaultsArray: DefaultsCodableBridge<[Self]> { return DefaultsCodableBridge() } } -extension DefaultsSerializable where Self: RawRepresentable { - public static var _defaults: DefaultsRawRepresentableBridge { return DefaultsRawRepresentableBridge() } - public static var _defaultsArray: DefaultsRawRepresentableArrayBridge<[Self]> { return DefaultsRawRepresentableArrayBridge() } +public extension DefaultsSerializable where Self: RawRepresentable { + static var _defaults: DefaultsRawRepresentableBridge { return DefaultsRawRepresentableBridge() } + static var _defaultsArray: DefaultsRawRepresentableArrayBridge<[Self]> { return DefaultsRawRepresentableArrayBridge() } } -extension DefaultsSerializable where Self: NSCoding { - public static var _defaults: DefaultsKeyedArchiverBridge { return DefaultsKeyedArchiverBridge() } - public static var _defaultsArray: DefaultsKeyedArchiverBridge<[Self]> { return DefaultsKeyedArchiverBridge() } +public extension DefaultsSerializable where Self: NSCoding { + static var _defaults: DefaultsKeyedArchiverBridge { return DefaultsKeyedArchiverBridge() } + static var _defaultsArray: DefaultsKeyedArchiverBridge<[Self]> { return DefaultsKeyedArchiverBridge() } } extension Dictionary: DefaultsSerializable where Key == String { @@ -77,6 +83,7 @@ extension Dictionary: DefaultsSerializable where Key == String { public static var _defaults: Bridge { return Bridge() } public static var _defaultsArray: ArrayBridge { return ArrayBridge() } } + extension Array: DefaultsSerializable where Element: DefaultsSerializable { public typealias T = [Element.T] public typealias Bridge = Element.ArrayBridge @@ -84,6 +91,7 @@ extension Array: DefaultsSerializable where Element: DefaultsSerializable { public static var _defaults: Bridge { return Element._defaultsArray } + public static var _defaultsArray: ArrayBridge { fatalError("Multidimensional arrays are not supported yet") } diff --git a/Sources/Defaults+Observing.swift b/Sources/Defaults+Observing.swift index ab7349bb..ed4fcbaf 100644 --- a/Sources/Defaults+Observing.swift +++ b/Sources/Defaults+Observing.swift @@ -26,28 +26,40 @@ import Foundation #if !os(Linux) -public extension DefaultsAdapter { + public extension DefaultsAdapter { + /// Observe changes on a `UserDefaults` key. + /// + /// The `handler` is invoked on whichever thread posts the KVO notification + /// (typically the writer's thread). It is not guaranteed to run on the + /// main thread. Dispatch onto your own actor or queue inside the handler + /// if you need a specific isolation. + func observe(_ key: DefaultsKey, + options: NSKeyValueObservingOptions = [.new, .old], + handler: @escaping @Sendable (DefaultsObserver.Update) -> Void) -> DefaultsDisposable + { + return defaults.observe(key, options: options, handler: handler) + } - func observe(_ key: DefaultsKey, - options: NSKeyValueObservingOptions = [.new, .old], - handler: @escaping (DefaultsObserver.Update) -> Void) -> DefaultsDisposable { - return defaults.observe(key, options: options, handler: handler) + /// Observe changes on a `UserDefaults` key path. + /// + /// The `handler` is invoked on whichever thread posts the KVO notification + /// (typically the writer's thread). It is not guaranteed to run on the + /// main thread. Dispatch onto your own actor or queue inside the handler + /// if you need a specific isolation. + func observe(_ keyPath: KeyPath>, + options: NSKeyValueObservingOptions = [.old, .new], + handler: @escaping @Sendable (DefaultsObserver.Update) -> Void) -> DefaultsDisposable + { + return defaults.observe(keyStore[keyPath: keyPath], + options: options, + handler: handler) + } } - func observe(_ keyPath: KeyPath>, - options: NSKeyValueObservingOptions = [.old, .new], - handler: @escaping (DefaultsObserver.Update) -> Void) -> DefaultsDisposable { - return defaults.observe(keyStore[keyPath: keyPath], - options: options, - handler: handler) + public extension UserDefaults { + func observe(_ key: DefaultsKey, options: NSKeyValueObservingOptions = [.old, .new], handler: @escaping @Sendable (DefaultsObserver.Update) -> Void) -> DefaultsDisposable { + return DefaultsObserver(key: key, userDefaults: self, options: options, handler: handler) + } } -} - -public extension UserDefaults { - - func observe(_ key: DefaultsKey, options: NSKeyValueObservingOptions = [.old, .new], handler: @escaping (DefaultsObserver.Update) -> Void) -> DefaultsDisposable { - return DefaultsObserver(key: key, userDefaults: self, options: options, handler: handler) - } -} #endif diff --git a/Sources/Defaults+Subscripts.swift b/Sources/Defaults+Subscripts.swift index 75938dd2..6c74565b 100644 --- a/Sources/Defaults+Subscripts.swift +++ b/Sources/Defaults+Subscripts.swift @@ -24,15 +24,15 @@ import Foundation -public protocol DefaultsProviding { +public protocol DefaultsProviding: Sendable { associatedtype KeyStore: DefaultsKeyStore - - subscript(key key: DefaultsKey) -> T.T where T: OptionalType, T.T == T { get nonmutating set } - subscript(key key: DefaultsKey) -> T.T where T.T == T { get nonmutating set } - subscript(keyPath: KeyPath>) -> T.T where T: OptionalType, T.T == T { get nonmutating set } - subscript(keyPath: KeyPath>) -> T.T where T.T == T { get nonmutating set } - subscript(dynamicMember keyPath: KeyPath>) -> T.T where T: OptionalType, T.T == T { get nonmutating set } - subscript(dynamicMember keyPath: KeyPath>) -> T.T where T.T == T { get nonmutating set } + + subscript(key _: DefaultsKey) -> T.T where T: OptionalType, T.T == T { get nonmutating set } + subscript(key _: DefaultsKey) -> T.T where T.T == T { get nonmutating set } + subscript(_: KeyPath>) -> T.T where T: OptionalType, T.T == T { get nonmutating set } + subscript(_: KeyPath>) -> T.T where T.T == T { get nonmutating set } + subscript(dynamicMember _: KeyPath>) -> T.T where T: OptionalType, T.T == T { get nonmutating set } + subscript(dynamicMember _: KeyPath>) -> T.T where T.T == T { get nonmutating set } } extension DefaultsAdapter: DefaultsProviding { @@ -94,7 +94,6 @@ extension DefaultsAdapter: DefaultsProviding { } public extension UserDefaults { - subscript(key: DefaultsKey) -> T.T where T: OptionalType, T.T == T { get { if let value = T._defaults.get(key: key._key, userDefaults: self), let _value = value as? T.T.Wrapped { diff --git a/Sources/Defaults.swift b/Sources/Defaults.swift index 25ac25ce..37ab0969 100644 --- a/Sources/Defaults.swift +++ b/Sources/Defaults.swift @@ -24,18 +24,23 @@ import Foundation -/// Global shortcut for `UserDefaults.standard` +// swiftlint:disable identifier_name prefixed_toplevel_constant +/// Global shortcut for `UserDefaults.standard`. /// /// **Pro-Tip:** If you want to use shared user defaults, just -/// redefine this global shortcut in your app target, like so: -/// ~~~ -/// var Defaults = DefaultsAdapter(defaults: UserDefaults(suiteName: "com.my.app")!, keyStore: DefaultsKeys()) -/// ~~~ - -public var Defaults = DefaultsAdapter(defaults: .standard, keyStore: .init()) +/// redefine this global shortcut in your app target, like so: +/// ~~~ +/// var Defaults = DefaultsAdapter(defaults: UserDefaults(suiteName: "com.my.app")!, keyStore: DefaultsKeys()) +/// ~~~ +/// +/// `nonisolated(unsafe)` makes the mutation explicit under Swift 6 strict +/// concurrency. The value type (`DefaultsAdapter`) is itself `Sendable`, so +/// the only risk is the initial assignment race, which has always been the +/// caller's responsibility to do at startup before any reads. +public nonisolated(unsafe) var Defaults = DefaultsAdapter(defaults: .standard, keyStore: .init()) +// swiftlint:enable identifier_name prefixed_toplevel_constant public extension UserDefaults { - /// Returns `true` if `key` exists func hasKey(_ key: DefaultsKey) -> Bool { return object(forKey: key._key) != nil @@ -57,8 +62,7 @@ public extension UserDefaults { } } -internal extension UserDefaults { - +extension UserDefaults { func number(forKey key: String) -> NSNumber? { return object(forKey: key) as? NSNumber } diff --git a/Sources/DefaultsAdapter.swift b/Sources/DefaultsAdapter.swift index 98994bf5..1cc93fa4 100644 --- a/Sources/DefaultsAdapter.swift +++ b/Sources/DefaultsAdapter.swift @@ -41,18 +41,23 @@ import Foundation /// Defaults.launchCount += 1 /// ``` @dynamicMemberLookup -public struct DefaultsAdapter { +public struct DefaultsAdapter: Sendable { + private let defaultsBox: UncheckedSendable + + /// The wrapped `UserDefaults` instance. Held via `UncheckedSendable` because + /// Foundation does not expose `UserDefaults` as `Sendable`, even though + /// Apple documents the type as thread-safe. + public var defaults: UserDefaults { defaultsBox.wrappedValue } - public let defaults: UserDefaults public let keyStore: KeyStore public init(defaults: UserDefaults, keyStore: KeyStore) { - self.defaults = defaults + self.defaultsBox = UncheckedSendable(defaults) self.keyStore = keyStore } @available(*, unavailable) - public subscript(dynamicMember member: String) -> Never { + public subscript(dynamicMember _: String) -> Never { fatalError() } diff --git a/Sources/DefaultsBridges.swift b/Sources/DefaultsBridges.swift index 32615a60..825fbbb9 100644 --- a/Sources/DefaultsBridges.swift +++ b/Sources/DefaultsBridges.swift @@ -24,8 +24,7 @@ import Foundation -public protocol DefaultsBridge { - +public protocol DefaultsBridge: Sendable { associatedtype T /// This method provides a way of saving your data in UserDefaults. Usually needed @@ -49,7 +48,6 @@ public protocol DefaultsBridge { } public struct DefaultsObjectBridge: DefaultsBridge { - public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -60,13 +58,14 @@ public struct DefaultsObjectBridge: DefaultsBridge { return userDefaults.object(forKey: key) as? T } - public func deserialize(_ object: Any) -> T? { + public func deserialize(_: Any) -> T? { return nil } } -public struct DefaultsArrayBridge: DefaultsBridge { +extension DefaultsObjectBridge: Sendable where T: Sendable {} +public struct DefaultsArrayBridge: DefaultsBridge { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -77,13 +76,14 @@ public struct DefaultsArrayBridge: DefaultsBridge { return userDefaults.array(forKey: key) as? T } - public func deserialize(_ object: Any) -> T? { + public func deserialize(_: Any) -> T? { return nil } } -public struct DefaultsStringBridge: DefaultsBridge { +extension DefaultsArrayBridge: Sendable where T: Sendable {} +public struct DefaultsStringBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: String?, userDefaults: UserDefaults) { @@ -94,13 +94,12 @@ public struct DefaultsStringBridge: DefaultsBridge { return userDefaults.string(forKey: key) } - public func deserialize(_ object: Any) -> String? { + public func deserialize(_: Any) -> String? { return nil } } -public struct DefaultsIntBridge: DefaultsBridge { - +public struct DefaultsIntBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: Int?, userDefaults: UserDefaults) { @@ -114,20 +113,20 @@ public struct DefaultsIntBridge: DefaultsBridge { // Fallback for launch arguments if let string = userDefaults.object(forKey: key) as? String, - let int = Int(string) { + let int = Int(string) + { return int } return nil } - public func deserialize(_ object: Any) -> Int? { + public func deserialize(_: Any) -> Int? { return nil } } -public struct DefaultsDoubleBridge: DefaultsBridge { - +public struct DefaultsDoubleBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: Double?, userDefaults: UserDefaults) { @@ -141,20 +140,20 @@ public struct DefaultsDoubleBridge: DefaultsBridge { // Fallback for launch arguments if let string = userDefaults.object(forKey: key) as? String, - let double = Double(string) { + let double = Double(string) + { return double } return nil } - public func deserialize(_ object: Any) -> Double? { + public func deserialize(_: Any) -> Double? { return nil } } -public struct DefaultsBoolBridge: DefaultsBridge { - +public struct DefaultsBoolBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: Bool?, userDefaults: UserDefaults) { @@ -175,13 +174,12 @@ public struct DefaultsBoolBridge: DefaultsBridge { return (userDefaults.object(forKey: key) as? String)?.bool } - public func deserialize(_ object: Any) -> Bool? { + public func deserialize(_: Any) -> Bool? { return nil } } -public struct DefaultsDataBridge: DefaultsBridge { - +public struct DefaultsDataBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: Data?, userDefaults: UserDefaults) { @@ -192,13 +190,12 @@ public struct DefaultsDataBridge: DefaultsBridge { return userDefaults.data(forKey: key) } - public func deserialize(_ object: Any) -> Data? { + public func deserialize(_: Any) -> Data? { return nil } } -public struct DefaultsUrlBridge: DefaultsBridge { - +public struct DefaultsUrlBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: URL?, userDefaults: UserDefaults) { @@ -228,7 +225,6 @@ public struct DefaultsUrlBridge: DefaultsBridge { } public struct DefaultsCodableBridge: DefaultsBridge { - public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -253,8 +249,9 @@ public struct DefaultsCodableBridge: DefaultsBridge { } } -public struct DefaultsKeyedArchiverBridge: DefaultsBridge { +extension DefaultsCodableBridge: Sendable where T: Sendable {} +public struct DefaultsKeyedArchiverBridge: DefaultsBridge { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -283,8 +280,9 @@ public struct DefaultsKeyedArchiverBridge: DefaultsBridge { } } -public struct DefaultsRawRepresentableBridge: DefaultsBridge { +extension DefaultsKeyedArchiverBridge: Sendable where T: Sendable {} +public struct DefaultsRawRepresentableBridge: DefaultsBridge { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -302,8 +300,9 @@ public struct DefaultsRawRepresentableBridge: DefaultsBridg } } -public struct DefaultsRawRepresentableArrayBridge: DefaultsBridge where T.Element: RawRepresentable { +extension DefaultsRawRepresentableBridge: Sendable where T: Sendable {} +public struct DefaultsRawRepresentableArrayBridge: DefaultsBridge where T.Element: RawRepresentable { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -322,8 +321,9 @@ public struct DefaultsRawRepresentableArrayBridge: DefaultsBridge } } -public struct DefaultsOptionalBridge: DefaultsBridge { +extension DefaultsRawRepresentableArrayBridge: Sendable where T: Sendable {} +public struct DefaultsOptionalBridge: DefaultsBridge { public typealias T = Bridge.T? private let bridge: Bridge @@ -345,8 +345,9 @@ public struct DefaultsOptionalBridge: DefaultsBridge { } } -public struct DefaultsOptionalArrayBridge: DefaultsBridge where Bridge.T: Collection { +extension DefaultsOptionalBridge: Sendable where Bridge: Sendable {} +public struct DefaultsOptionalArrayBridge: DefaultsBridge where Bridge.T: Collection { public typealias T = Bridge.T? private let bridge: Bridge @@ -367,3 +368,5 @@ public struct DefaultsOptionalArrayBridge: DefaultsBridg return bridge.deserialize(object) } } + +extension DefaultsOptionalArrayBridge: Sendable where Bridge: Sendable {} diff --git a/Sources/DefaultsKey.swift b/Sources/DefaultsKey.swift index 80b89be2..497b0d28 100644 --- a/Sources/DefaultsKey.swift +++ b/Sources/DefaultsKey.swift @@ -28,41 +28,47 @@ import Foundation /// Specialize with value type /// and pass key name to the initializer to create a key. -public struct DefaultsKey { - +/// +/// Constrained to `ValueType.T: Sendable` so a key is always safe to carry +/// across actor boundaries. Types that can be stored in `UserDefaults` +/// (plist primitives, `Codable` values, `RawRepresentable` values) are +/// already `Sendable` in practice; this constraint makes that contract +/// explicit at the type level. +public struct DefaultsKey where ValueType.T: Sendable { public let _key: String public let defaultValue: ValueType.T? - internal var isOptional: Bool + let isOptional: Bool public init(_ key: String, defaultValue: ValueType.T) { - self._key = key + _key = key self.defaultValue = defaultValue - self.isOptional = false + isOptional = false } // Couldn't figure out a way of how to pass a nil/none value from extension, thus this initializer. // Used for creating an optional key (without defaultValue) private init(key: String) { - self._key = key - self.defaultValue = nil - self.isOptional = true + _key = key + defaultValue = nil + isOptional = true } @available(*, unavailable, message: "This key needs a `defaultValue` parameter. If this type does not have a default value, consider using an optional key.") - public init(_ key: String) { + public init(_: String) { fatalError() } } public extension DefaultsKey where ValueType: DefaultsSerializable, ValueType: OptionalType, ValueType.Wrapped: DefaultsSerializable { - init(_ key: String) { self.init(key: key) } init(_ key: String, defaultValue: ValueType.T) { - self._key = key + _key = key self.defaultValue = defaultValue - self.isOptional = true + isOptional = true } } + +extension DefaultsKey: Sendable {} diff --git a/Sources/DefaultsKeys.swift b/Sources/DefaultsKeys.swift index b8c228eb..2ae2ade8 100644 --- a/Sources/DefaultsKeys.swift +++ b/Sources/DefaultsKeys.swift @@ -24,8 +24,8 @@ import Foundation -public protocol DefaultsKeyStore {} +public protocol DefaultsKeyStore: Sendable {} -public struct DefaultsKeys: DefaultsKeyStore { +public struct DefaultsKeys: DefaultsKeyStore, Sendable { public init() {} } diff --git a/Sources/DefaultsObserver.swift b/Sources/DefaultsObserver.swift index 80a2d1e5..5995024c 100644 --- a/Sources/DefaultsObserver.swift +++ b/Sources/DefaultsObserver.swift @@ -24,84 +24,99 @@ import Foundation -public protocol DefaultsDisposable { +public protocol DefaultsDisposable: Sendable { func dispose() } #if !os(Linux) -public final class DefaultsObserver: NSObject, DefaultsDisposable where T == T.T { - - public struct Update { - public let kind: NSKeyValueChange - public let indexes: IndexSet? - public let isPrior: Bool - public let newValue: T.T? - public let oldValue: T.T? - - init(dict: [NSKeyValueChangeKey: Any], key: DefaultsKey) { - // swiftlint:disable:next force_cast - kind = NSKeyValueChange(rawValue: dict[.kindKey] as! UInt)! - indexes = dict[.indexesKey] as? IndexSet - isPrior = dict[.notificationIsPriorKey] as? Bool ?? false - oldValue = Update.deserialize(dict[.oldKey], for: key) ?? key.defaultValue - newValue = Update.deserialize(dict[.newKey], for: key) ?? key.defaultValue - } + /// Observes KVO changes on a `UserDefaults` key. Marked `@unchecked Sendable` + /// because it stores `didRemoveObserver` mutable state guarded by `lock` and + /// dispatches the user-provided `handler` on whichever thread KVO fires it on. + /// The handler closure is `@Sendable` so it can cross isolation boundaries. + /// + /// Note: the handler is invoked on whichever thread `UserDefaults` posts the + /// KVO notification on (typically the writer's thread). Hop to your own + /// actor or queue inside the handler if you need a specific isolation. + public final class DefaultsObserver: NSObject, DefaultsDisposable, @unchecked Sendable where T == T.T, T: Sendable { + public struct Update { + public let kind: NSKeyValueChange + public let indexes: IndexSet? + public let isPrior: Bool + public let newValue: T.T? + public let oldValue: T.T? + + init(dict: [NSKeyValueChangeKey: Any], key: DefaultsKey) { + // swiftlint:disable:next force_cast + kind = NSKeyValueChange(rawValue: dict[.kindKey] as! UInt)! + indexes = dict[.indexesKey] as? IndexSet + isPrior = dict[.notificationIsPriorKey] as? Bool ?? false + oldValue = Update.deserialize(dict[.oldKey], for: key) ?? key.defaultValue + newValue = Update.deserialize(dict[.newKey], for: key) ?? key.defaultValue + } - private static func deserialize(_ value: Any?, for key: DefaultsKey) -> T.T? where T.T == T { - guard let value = value else { return nil } + private static func deserialize(_ value: Any?, for key: DefaultsKey) -> U.T? where U.T == U { + guard let value = value else { return nil } - let deserialized = T._defaults.deserialize(value) + let deserialized = U._defaults.deserialize(value) - let ret: T.T? - if key.isOptional, let _deserialized = deserialized, let __deserialized = _deserialized as? OptionalTypeCheck, !__deserialized.isNil { - ret = __deserialized as? T.T - } else if !key.isOptional { - ret = deserialized ?? value as? T.T - } else { - ret = value as? T.T - } + let ret: U.T? + if key.isOptional, let _deserialized = deserialized, let __deserialized = _deserialized as? OptionalTypeCheck, !__deserialized.isNil { + ret = __deserialized as? U.T + } else if !key.isOptional { + ret = deserialized ?? value as? U.T + } else { + ret = value as? U.T + } - return ret + return ret + } } - } - - private let key: DefaultsKey - private let userDefaults: UserDefaults - private let handler: ((Update) -> Void) - private var didRemoveObserver = false - init(key: DefaultsKey, userDefaults: UserDefaults, options: NSKeyValueObservingOptions, handler: @escaping ((Update) -> Void)) { - self.key = key - self.userDefaults = userDefaults - self.handler = handler - super.init() + private let key: DefaultsKey + private let userDefaults: UserDefaults + private let handler: @Sendable (Update) -> Void + private let lock = NSLock() + private var didRemoveObserver = false - userDefaults.addObserver(self, forKeyPath: key._key, options: options, context: nil) - } + init(key: DefaultsKey, userDefaults: UserDefaults, options: NSKeyValueObservingOptions, handler: @escaping @Sendable (Update) -> Void) { + self.key = key + self.userDefaults = userDefaults + self.handler = handler + super.init() - deinit { - dispose() - } + userDefaults.addObserver(self, forKeyPath: key._key, options: options, context: nil) + } - // swiftlint:disable:next block_based_kvo - public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { - guard let change = change, object != nil, keyPath == key._key else { - return + deinit { + dispose() } - let update = Update(dict: change, key: key) - handler(update) - } + // swiftlint:disable:next block_based_kvo + override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context _: UnsafeMutableRawPointer?) { + guard let change = change, object != nil, keyPath == key._key else { + return + } - public func dispose() { - // We use this local property because when you use `removeObserver` when you are - // not actually observing anymore, you'll receive a runtime error. - if didRemoveObserver { return } + let update = Update(dict: change, key: key) + handler(update) + } - didRemoveObserver = true - userDefaults.removeObserver(self, forKeyPath: key._key, context: nil) + public func dispose() { + // We use this local property because when you use `removeObserver` when you are + // not actually observing anymore, you'll receive a runtime error. + // + // The lock covers the `removeObserver` call too so that a deinit-triggered + // `dispose()` racing with an explicit `dispose()` cannot both reach + // `removeObserver`. + lock.lock() + defer { lock.unlock() } + guard !didRemoveObserver else { return } + didRemoveObserver = true + userDefaults.removeObserver(self, forKeyPath: key._key, context: nil) + } } -} + + extension DefaultsObserver.Update: Sendable {} #endif diff --git a/Sources/PropertyWrappers.swift b/Sources/PropertyWrappers.swift index 585038f1..6020a060 100644 --- a/Sources/PropertyWrappers.swift +++ b/Sources/PropertyWrappers.swift @@ -22,66 +22,97 @@ // SOFTWARE. // +import Foundation + #if swift(>=5.1) -public struct SwiftyUserDefaultOptions: OptionSet { + public struct SwiftyUserDefaultOptions: OptionSet, Sendable { + /// Cache the value in memory after the first read. + /// + /// Pair with `.observed` so that external writes (other code paths, app + /// extensions writing through the same suite, or another process) keep + /// the in-memory cache consistent with `UserDefaults`. Using `.cached` + /// alone leaves the cache stale after any out-of-band write. + public static let cached = SwiftyUserDefaultOptions(rawValue: 1 << 0) - public static let cached = SwiftyUserDefaultOptions(rawValue: 1 << 0) - public static let observed = SwiftyUserDefaultOptions(rawValue: 1 << 2) + /// Observe `UserDefaults` for changes and update the cached value. + public static let observed = SwiftyUserDefaultOptions(rawValue: 1 << 2) - public let rawValue: Int + public let rawValue: Int - public init(rawValue: Int) { - self.rawValue = rawValue + public init(rawValue: Int) { + self.rawValue = rawValue + } } -} - -@propertyWrapper -public final class SwiftyUserDefault where T.T == T { - public let key: DefaultsKey - public let options: SwiftyUserDefaultOptions + /// Property wrapper for `Defaults`-backed values. Marked `@unchecked Sendable` + /// because it carries a mutable `_value` cache and `observation` reference + /// guarded by `lock`. The wrapped getter/setter reads and writes through the + /// global `Defaults` adapter or one passed at init, both of which delegate to + /// `UserDefaults` (documented thread-safe). + /// + /// `lock` is an `NSRecursiveLock` because the setter holds it across the + /// `Defaults[key:] = newValue` write, and that write triggers KVO. When + /// `.observed` is set, the KVO observer callback runs synchronously on the + /// same thread and re-enters the lock to update the cache. A non-recursive + /// lock would deadlock in that re-entry path. + @propertyWrapper + public final class SwiftyUserDefault: @unchecked Sendable where T.T == T, T: Sendable { + public let key: DefaultsKey + public let options: SwiftyUserDefaultOptions - public var wrappedValue: T { - get { - if options.contains(.cached) { - return _value ?? Defaults[key: key] - } else { - return Defaults[key: key] + public var wrappedValue: T { + get { + if options.contains(.cached) { + lock.lock() + let cached = _value + lock.unlock() + return cached ?? Defaults[key: key] + } else { + return Defaults[key: key] + } + } + set { + lock.lock() + defer { lock.unlock() } + _value = newValue + Defaults[key: key] = newValue } } - set { - _value = newValue - Defaults[key: key] = newValue - } - } - private var _value: T.T? - private var observation: DefaultsDisposable? + private let lock = NSRecursiveLock() + private var _value: T.T? + private var observation: (any DefaultsDisposable)? - public init(keyPath: KeyPath>, adapter: DefaultsAdapter, options: SwiftyUserDefaultOptions = []) { - self.key = adapter.keyStore[keyPath: keyPath] - self.options = options + public init(keyPath: KeyPath>, adapter: DefaultsAdapter, options: SwiftyUserDefaultOptions = []) { + key = adapter.keyStore[keyPath: keyPath] + self.options = options - if options.contains(.observed) { - observation = adapter.observe(key) { [weak self] update in - self?._value = update.newValue + if options.contains(.observed) { + observation = adapter.observe(key) { [weak self] update in + guard let self else { return } + self.lock.lock() + self._value = update.newValue + self.lock.unlock() + } } } - } - public init(keyPath: KeyPath>, options: SwiftyUserDefaultOptions = []) { - self.key = Defaults.keyStore[keyPath: keyPath] - self.options = options + public init(keyPath: KeyPath>, options: SwiftyUserDefaultOptions = []) { + key = Defaults.keyStore[keyPath: keyPath] + self.options = options - if options.contains(.observed) { - observation = Defaults.observe(key) { [weak self] update in - self?._value = update.newValue + if options.contains(.observed) { + observation = Defaults.observe(key) { [weak self] update in + guard let self else { return } + self.lock.lock() + self._value = update.newValue + self.lock.unlock() + } } } - } - deinit { - observation?.dispose() + deinit { + observation?.dispose() + } } -} #endif diff --git a/Sources/UncheckedSendable.swift b/Sources/UncheckedSendable.swift new file mode 100644 index 00000000..5405b228 --- /dev/null +++ b/Sources/UncheckedSendable.swift @@ -0,0 +1,37 @@ +// +// SwiftyUserDefaults +// +// Copyright (c) 2015-present Radosław Pietruszewski, Łukasz Mróz +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// Library-internal helper that lets a non-`Sendable` value cross isolation +/// boundaries under explicit programmer responsibility. Used at the single +/// boundary where the library has to hold a `UserDefaults` instance, which +/// Apple documents as thread-safe but does not mark `Sendable`. +struct UncheckedSendable: @unchecked Sendable { + let wrappedValue: Value + + init(_ wrappedValue: Value) { + self.wrappedValue = wrappedValue + } +} diff --git a/Tests/SwiftyUserDefaultsTests/Built-ins/Defaults+Dictionary.swift b/Tests/SwiftyUserDefaultsTests/Built-ins/Defaults+Dictionary.swift index 1ceafd98..4cb362db 100644 --- a/Tests/SwiftyUserDefaultsTests/Built-ins/Defaults+Dictionary.swift +++ b/Tests/SwiftyUserDefaultsTests/Built-ins/Defaults+Dictionary.swift @@ -22,15 +22,14 @@ // SOFTWARE. // -import Quick import Foundation +import Quick final class DefaultsDictionarySpec: QuickSpec, DefaultsSerializableSpec { + typealias Serializable = [String: String] - typealias Serializable = [String: AnyHashable] - - var customValue: [String: AnyHashable] = ["a": "b"] - var defaultValue: [String: AnyHashable] = ["c": "d", "e": 1] + var customValue: [String: String] = ["a": "b"] + var defaultValue: [String: String] = ["c": "d", "e": "1"] var keyStore = FrogKeyStore() override func spec() { diff --git a/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift b/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift new file mode 100644 index 00000000..c1be29d5 --- /dev/null +++ b/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift @@ -0,0 +1,108 @@ +// +// SwiftyUserDefaults +// +// Copyright (c) 2015-present Radosław Pietruszewski, Łukasz Mróz +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import Nimble +import Quick +@testable import SwiftyUserDefaults + +/// Exercises the thread-safety guarantees introduced in the strict-concurrency +/// migration: the dispose() lock covers removeObserver, the lock is non-recursive +/// for the observer, and disposal is idempotent under contention. +final class DefaultsObserverConcurrencySpec: QuickSpec { + override func spec() { + var userDefaults: UserDefaults! + + beforeEach { + userDefaults = UserDefaults(suiteName: UUID().uuidString)! + userDefaults.cleanObjects() + } + + describe("dispose()") { + then("is idempotent on a single thread") { + let key = DefaultsKey("counter", defaultValue: 0) + let observer = userDefaults.observe(key) { _ in } + + observer.dispose() + observer.dispose() + observer.dispose() + // No crash, no NSException from `removeObserver` (the lock + // around `didRemoveObserver` covers the removal too). + expect(observer).toNot(beNil()) + } + + then("is safe under concurrent dispose calls from many threads") { + let key = DefaultsKey("counter", defaultValue: 0) + let observer = userDefaults.observe(key) { _ in } + + DispatchQueue.concurrentPerform(iterations: 50) { _ in + observer.dispose() + } + // No `removeObserver` double-removal exception under contention. + expect(observer).toNot(beNil()) + } + + then("stops delivering updates after dispose") { + let key = DefaultsKey("counter", defaultValue: 0) + let received = Locked(0) + + let observer = userDefaults.observe(key) { _ in + received.value += 1 + } + + userDefaults[key] = 1 + userDefaults[key] = 2 + + observer.dispose() + + userDefaults[key] = 3 + userDefaults[key] = 4 + + expect(received.value).toEventually(equal(2)) + // Wait a moment to confirm no further updates arrive after dispose. + Thread.sleep(forTimeInterval: 0.1) + expect(received.value) == 2 + } + } + + describe("observe handler") { + then("delivers updates across concurrent writes without dropping") { + let key = DefaultsKey("counter", defaultValue: 0) + let received = Locked(0) + let defaultsRef = userDefaults! + + let observer = defaultsRef.observe(key) { _ in + received.value += 1 + } + defer { observer.dispose() } + + DispatchQueue.concurrentPerform(iterations: 20) { i in + defaultsRef[key] = i + } + + expect(received.value).toEventually(equal(20), timeout: 3) + } + } + } +} diff --git a/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift b/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift new file mode 100644 index 00000000..1353fc91 --- /dev/null +++ b/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift @@ -0,0 +1,140 @@ +// +// SwiftyUserDefaults +// +// Copyright (c) 2015-present Radosław Pietruszewski, Łukasz Mróz +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import Nimble +import Quick +@testable import SwiftyUserDefaults + +/// Key store used by the SwiftyUserDefault property wrapper specs. +final class WrapperSpecKeys: DefaultsKeyStore, @unchecked Sendable { + let counter = DefaultsKey("wrapper.counter", defaultValue: 0) + let label = DefaultsKey("wrapper.label", defaultValue: "") +} + +/// A class that uses the @SwiftyUserDefault property wrapper. Declared at +/// file scope so the wrapper observer's `[weak self]` capture has a stable +/// owner across test cases. +final class WrapperHost: @unchecked Sendable { + static let keyStore = WrapperSpecKeys() + nonisolated(unsafe) static var sharedAdapter: DefaultsAdapter! + + @SwiftyUserDefault(keyPath: \WrapperSpecKeys.counter, adapter: WrapperHost.sharedAdapter, options: [.cached, .observed]) + var counter: Int + + @SwiftyUserDefault(keyPath: \WrapperSpecKeys.label, adapter: WrapperHost.sharedAdapter, options: []) + var label: String +} + +/// The wrapper's getter/setter route through the global `Defaults`, so the +/// specs rebind it to a per-test `UserDefaults` suite. +private func rebindGlobalDefaults(to userDefaults: UserDefaults) { + Defaults = DefaultsAdapter(defaults: userDefaults, keyStore: DefaultsKeys()) +} + +/// Exercises the SwiftyUserDefault property wrapper under concurrent access +/// and verifies the lock change (NSLock → NSRecursiveLock) closes the +/// cache/storage interleave window opened by the migration's initial lock. +final class SwiftyUserDefaultWrapperSpec: QuickSpec { + override func spec() { + var userDefaults: UserDefaults! + + beforeEach { + userDefaults = UserDefaults(suiteName: UUID().uuidString)! + userDefaults.cleanObjects() + WrapperHost.sharedAdapter = DefaultsAdapter(defaults: userDefaults, keyStore: WrapperHost.keyStore) + rebindGlobalDefaults(to: userDefaults) + } + + afterEach { + // Other specs assume `Defaults` points at `UserDefaults.standard`. + rebindGlobalDefaults(to: .standard) + } + + describe("@SwiftyUserDefault setter") { + then("setting via wrapper writes through to UserDefaults") { + let host = WrapperHost() + host.counter = 7 + + expect(userDefaults.integer(forKey: WrapperHost.keyStore.counter._key)) == 7 + } + + then("getter reflects external writes when .observed is set") { + let host = WrapperHost() + userDefaults[WrapperHost.keyStore.counter] = 42 + + expect(host.counter).toEventually(equal(42)) + } + + then("concurrent setters with distinct values leave cache and storage in agreement") { + let host = WrapperHost() + + // Each thread writes its own distinct value. With a missing or + // half-scoped lock the cache and on-disk value can end up + // pointing at different writers. With the NSRecursiveLock fix + // (lock held across both writes) they must agree, whatever + // the winning value is. + DispatchQueue.concurrentPerform(iterations: 200) { i in + host.counter = i + 1 + } + + // Let any in-flight KVO callbacks settle before sampling. + Thread.sleep(forTimeInterval: 0.05) + + let finalCache = host.counter + let finalStorage = userDefaults.integer(forKey: WrapperHost.keyStore.counter._key) + expect(finalCache) == finalStorage + expect(finalCache) >= 1 + expect(finalCache) <= 200 + } + + then("concurrent read and write does not crash") { + let host = WrapperHost() + let reads = Locked(0) + + DispatchQueue.concurrentPerform(iterations: 100) { i in + if i.isMultiple(of: 2) { + host.counter = i + } else { + _ = host.counter + reads.value += 1 + } + } + + expect(reads.value) > 0 + } + } + + describe("@SwiftyUserDefault with .cached + .observed") { + then("cache is populated by the observer when an external writer updates the key") { + let host = WrapperHost() + _ = host.counter + + userDefaults[WrapperHost.keyStore.counter] = 123 + + expect(host.counter).toEventually(equal(123), timeout: 2) + } + } + } +} diff --git a/Tests/SwiftyUserDefaultsTests/TestHelpers/DefaultsSerializableSpec.swift b/Tests/SwiftyUserDefaultsTests/TestHelpers/DefaultsSerializableSpec.swift index 5fb7391d..408592c3 100644 --- a/Tests/SwiftyUserDefaultsTests/TestHelpers/DefaultsSerializableSpec.swift +++ b/Tests/SwiftyUserDefaultsTests/TestHelpers/DefaultsSerializableSpec.swift @@ -23,20 +23,19 @@ // import Foundation -import Quick import Nimble -@testable import SwiftyUserDefaults +import Quick +@preconcurrency @testable import SwiftyUserDefaults protocol DefaultsSerializableSpec { - associatedtype Serializable: DefaultsSerializable & Equatable + associatedtype Serializable: DefaultsSerializable & Equatable & Sendable where Serializable.T: Sendable, Serializable.ArrayBridge.T: Sendable var defaultValue: Serializable.T { get } var customValue: Serializable.T { get } var keyStore: FrogKeyStore { get } } -extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable.T == Serializable, Serializable.ArrayBridge.T == [Serializable.T] { - +extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable.T == Serializable, Serializable.ArrayBridge.T == [Serializable.T], Serializable: Sendable { func testValues() { when("key-default value") { var defaults: DefaultsAdapter>! @@ -69,11 +68,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("get a default value with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + then("get a default value with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(defaults.testValue) == self.defaultValue - } + expect(defaults.testValue) == self.defaultValue + } #endif then("get a default array value") { @@ -83,11 +82,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("get a default array value with dynamicMemberLookup") { - self.keyStore.testArray = DefaultsKey<[Serializable]>("test", defaultValue: [self.defaultValue]) + then("get a default array value with dynamicMemberLookup") { + self.keyStore.testArray = DefaultsKey<[Serializable]>("test", defaultValue: [self.defaultValue]) - expect(defaults.testArray) == [self.defaultValue] - } + expect(defaults.testArray) == [self.defaultValue] + } #endif then("save a value") { @@ -99,13 +98,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save a value with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - let expectedValue = self.customValue - defaults.testValue = expectedValue + then("save a value with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + let expectedValue = self.customValue + defaults.testValue = expectedValue - expect(defaults.testValue) == expectedValue - } + expect(defaults.testValue) == expectedValue + } #endif then("save an array value") { @@ -117,13 +116,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save an array value with dynamicMemberLookup") { - self.keyStore.testArray = DefaultsKey<[Serializable]>("test", defaultValue: [self.defaultValue]) - let expectedValue = [self.customValue] - defaults.testArray = expectedValue + then("save an array value with dynamicMemberLookup") { + self.keyStore.testArray = DefaultsKey<[Serializable]>("test", defaultValue: [self.defaultValue]) + let expectedValue = [self.customValue] + defaults.testArray = expectedValue - expect(defaults.testArray) == expectedValue - } + expect(defaults.testArray) == expectedValue + } #endif then("remove a value") { @@ -136,15 +135,15 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove a value with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - defaults.testValue = self.customValue + then("remove a value with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults.testValue = self.customValue - removeObject("test") + removeObject("test") - expect(defaults.hasKey(\.testValue)) == false - expect(defaults.testValue) == self.defaultValue - } + expect(defaults.hasKey(\.testValue)) == false + expect(defaults.testValue) == self.defaultValue + } #endif then("remove an array value") { @@ -157,15 +156,15 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove an array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) - defaults.testOptionalArray = [self.customValue] + then("remove an array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) + defaults.testOptionalArray = [self.customValue] - defaults.remove(\.testOptionalArray) + defaults.remove(\.testOptionalArray) - expect(defaults.hasKey(\.testOptionalArray)) == false - expect(defaults.testOptionalArray) == [self.defaultValue] - } + expect(defaults.hasKey(\.testOptionalArray)) == false + expect(defaults.testOptionalArray) == [self.defaultValue] + } #endif } } @@ -200,11 +199,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("get a default value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + then("get a default value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(defaults.testOptionalValue) == self.defaultValue - } + expect(defaults.testOptionalValue) == self.defaultValue + } #endif then("get a default array value") { @@ -214,11 +213,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("get a default array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) + then("get a default array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) - expect(defaults.testOptionalArray) == [self.defaultValue] - } + expect(defaults.testOptionalArray) == [self.defaultValue] + } #endif then("save a value") { @@ -230,13 +229,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save a value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - let expectedValue = self.customValue - defaults.testOptionalValue = expectedValue + then("save a value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + let expectedValue = self.customValue + defaults.testOptionalValue = expectedValue - expect(defaults.testOptionalValue) == expectedValue - } + expect(defaults.testOptionalValue) == expectedValue + } #endif then("save an array value") { @@ -248,13 +247,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save an array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) - let expectedValue = [self.customValue] - defaults.testOptionalArray = expectedValue + then("save an array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) + let expectedValue = [self.customValue] + defaults.testOptionalArray = expectedValue - expect(defaults.testOptionalArray) == expectedValue - } + expect(defaults.testOptionalArray) == expectedValue + } #endif then("remove a value") { @@ -266,13 +265,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove a value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + then("remove a value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - defaults.testOptionalValue = nil + defaults.testOptionalValue = nil - expect(defaults.testOptionalValue) == self.defaultValue - } + expect(defaults.testOptionalValue) == self.defaultValue + } #endif then("remove an array value") { @@ -284,13 +283,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove an array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) + then("remove an array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) - defaults.testOptionalArray = nil + defaults.testOptionalArray = nil - expect(defaults.testOptionalArray) == [self.defaultValue] - } + expect(defaults.testOptionalArray) == [self.defaultValue] + } #endif } } @@ -327,13 +326,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save a value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") - let expectedValue = self.customValue - defaults.testOptionalValue = expectedValue + then("save a value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") + let expectedValue = self.customValue + defaults.testOptionalValue = expectedValue - expect(defaults.testOptionalValue) == expectedValue - } + expect(defaults.testOptionalValue) == expectedValue + } #endif then("save an array value") { @@ -345,13 +344,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save an array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test") - let expectedValue = [self.customValue] - defaults.testOptionalArray = expectedValue + then("save an array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test") + let expectedValue = [self.customValue] + defaults.testOptionalArray = expectedValue - expect(defaults.testOptionalArray) == expectedValue - } + expect(defaults.testOptionalArray) == expectedValue + } #endif then("remove a value") { @@ -365,15 +364,15 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove a value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") + then("remove a value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") - defaults.testOptionalValue = self.defaultValue - expect(defaults.testOptionalValue) == self.defaultValue + defaults.testOptionalValue = self.defaultValue + expect(defaults.testOptionalValue) == self.defaultValue - defaults.testOptionalValue = nil - expect(defaults.testOptionalValue).to(beNil()) - } + defaults.testOptionalValue = nil + expect(defaults.testOptionalValue).to(beNil()) + } #endif then("remove an array value") { @@ -387,15 +386,15 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove an array with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test") + then("remove an array with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test") - defaults.testOptionalArray = [self.defaultValue] - expect(defaults.testOptionalArray) == [self.defaultValue] + defaults.testOptionalArray = [self.defaultValue] + expect(defaults.testOptionalArray) == [self.defaultValue] - defaults.testOptionalArray = nil - expect(defaults.testOptionalArray).to(beNil()) - } + defaults.testOptionalArray = nil + expect(defaults.testOptionalArray).to(beNil()) + } #endif then("compare optional value to non-optional value") { @@ -405,11 +404,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("compare optional value to non-optional value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") - expect(defaults.testOptionalValue == nil).to(beTrue()) - expect(defaults.testOptionalValue != self.defaultValue).to(beTrue()) - } + then("compare optional value to non-optional value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") + expect(defaults.testOptionalValue == nil).to(beTrue()) + expect(defaults.testOptionalValue != self.defaultValue).to(beTrue()) + } #endif } } @@ -474,11 +473,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable func injectPlistArguments(to userDefaults: UserDefaults) { let arguments = transformToProperKeyValue() - .reduce(into: [String]()) { (result, element) in + .reduce(into: [String]()) { result, element in let (key, value) = element let (stringValue, _) = value result.append(contentsOf: ["-" + key, stringValue]) - } + } let parsedArguments = UserDefaults._parseArguments(arguments) userDefaults.setVolatileDomain(parsedArguments, forName: "NSArgumentDomain") @@ -488,549 +487,555 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable // this function transforms it to a [defaultsKey: (stringValue, expectedParsedValue)] func transformToProperKeyValue() -> [String: (String, Serializable?)] { return enumeratedArguments - .reduce(into: [String: (String, Serializable?)]()) { (result, enumeratedElement) in + .reduce(into: [String: (String, Serializable?)]()) { result, enumeratedElement in let (index, element) = enumeratedElement let (value, expectedParsedValue) = element let argumentKey = "\(keyPrefix)\(index)" result[argumentKey] = (value, expectedParsedValue) - } + } } } func testObserving() { #if !os(Linux) - given("key-value observing") { - var defaults: DefaultsAdapter>! - var observer: DefaultsDisposable? - - beforeEach { - let suiteName = UUID().uuidString - let userDefaults = UserDefaults(suiteName: suiteName)! - defaults = DefaultsAdapter(defaults: userDefaults, - keyStore: self.keyStore) - } - - afterEach { - observer?.dispose() - } - - when("optional key without default value") { - then("receive updates") { - let key = DefaultsKey("test") + given("key-value observing") { + var defaults: DefaultsAdapter>! + var observer: DefaultsDisposable? + + beforeEach { + let suiteName = UUID().uuidString + let userDefaults = UserDefaults(suiteName: suiteName)! + defaults = DefaultsAdapter(defaults: userDefaults, + keyStore: self.keyStore) + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - } + afterEach { + observer?.dispose() + } - defaults[key: key] = self.customValue + when("optional key without default value") { + then("receive updates") { + let key = DefaultsKey("test") - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("receive updates with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults.testOptionalValue = self.customValue + #if swift(>=5.1) + then("receive updates with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - } - #endif + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } - then("receives initial update being nil") { - let key = DefaultsKey("test1") + defaults.testOptionalValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + } + #endif - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + then("receives initial update being nil") { + let key = DefaultsKey("test1") - then("receives initial update being non-nil") { - let key = DefaultsKey("test1") - defaults[key: key] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being non-nil") { + let key = DefaultsKey("test1") + defaults[key: key] = self.customValue - then("receives initial update being nil with keyPaths") { - self.keyStore.testOptionalValue = DefaultsKey("test2") + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + then("receives initial update being nil with keyPaths") { + self.keyStore.testOptionalValue = DefaultsKey("test2") - then("receives initial update being non-nil with keyPaths") { - self.keyStore.testOptionalValue = DefaultsKey("test2") - defaults[\.testOptionalValue] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being non-nil with keyPaths") { + self.keyStore.testOptionalValue = DefaultsKey("test2") + defaults[\.testOptionalValue] = self.customValue - then("receives nil update") { - let key = DefaultsKey("test") + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults[key: key] = self.defaultValue - defaults[key: key] = nil - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(beNil()) - } + then("receives nil update") { + let key = DefaultsKey("test") - #if swift(>=5.1) - then("receives nil update with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } + defaults[key: key] = self.defaultValue + defaults[key: key] = nil - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate - } - defaults.testOptionalValue = self.defaultValue - defaults.testOptionalValue = nil - - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(beNil()) - } - #endif - - then("reference itself in the update closure without crash") { - let key = DefaultsKey("test") - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(beNil()) } - defaults[key: key] = self.customValue - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } - - then("reference itself in the update closure on custom queue without crash") { - let key = DefaultsKey("test") - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] - } - - DispatchQueue.global(qos: .utility).async { + #if swift(>=5.1) + then("receives nil update with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") + + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } + defaults.testOptionalValue = self.defaultValue + defaults.testOptionalValue = nil + + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(beNil()) + } + #endif + + then("reference itself in the update closure without crash") { + let key = DefaultsKey("test") + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } defaults[key: key] = self.customValue - } - - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } - then("remove observer on dispose") { - let key = DefaultsKey("test") + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) + } - var update: DefaultsObserver.Update? - let observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + then("reference itself in the update closure on custom queue without crash") { + let key = DefaultsKey("test") + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } + + DispatchQueue.global(qos: .utility).async { + defaults[key: key] = self.customValue + } + + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - observer.dispose() - defaults[key: key] = self.customValue + then("remove observer on dispose") { + let key = DefaultsKey("test") - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("remove observer on dispose with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") + observer.dispose() + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - let observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - observer.dispose() - defaults.testOptionalValue = self.customValue + #if swift(>=5.1) + then("remove observer on dispose with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } - #endif - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } - when("optional key with default value") { - then("receive updates") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + observer.dispose() + defaults.testOptionalValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) + } + #endif + } - defaults[key: key] = self.customValue + when("optional key with default value") { + then("receive updates") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("receive updates with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults.testOptionalValue = self.customValue + #if swift(>=5.1) + then("receive updates with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } - #endif + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } - then("receives initial update being default value") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + defaults.testOptionalValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + } + #endif - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives initial update being default value") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - then("receives initial update being custom value") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - defaults[key: key] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being custom value") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[key: key] = self.customValue - then("receives initial update being default value with keyPaths") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives initial update being default value with keyPaths") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - then("receives initial update being custom value with keyPaths") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - defaults[\.testOptionalValue] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being custom value with keyPaths") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[\.testOptionalValue] = self.customValue - then("receives nil update") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults[key: key] = self.defaultValue - defaults[key: key] = nil - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives nil update") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - #if swift(>=5.1) - then("receives nil update with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } + defaults[key: key] = self.defaultValue + defaults[key: key] = nil - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - defaults.testOptionalValue = self.customValue - defaults.testOptionalValue = nil - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } - #endif - - then("reference itself in the update closure without crash") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] - } - defaults[key: key] = self.customValue + #if swift(>=5.1) + then("receives nil update with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } + defaults.testOptionalValue = self.customValue + defaults.testOptionalValue = nil + + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) + } + #endif + + then("reference itself in the update closure without crash") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } + defaults[key: key] = self.customValue - expect(update).toEventuallyNot(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } - - then("reference itself in the update closure on custom queue without crash") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - DispatchQueue.global(qos: .utility).async { - defaults[key: key] = self.customValue - } + then("reference itself in the update closure on custom queue without crash") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update).toEventuallyNot(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } - then("remove observer on dispose") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + DispatchQueue.global(qos: .utility).async { + defaults[key: key] = self.customValue + } - var update: DefaultsObserver.Update? - let observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - observer.dispose() - defaults[key: key] = self.customValue + then("remove observer on dispose") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("remove observer on dispose with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + observer.dispose() + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - let observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - observer.dispose() - defaults.testOptionalValue = self.customValue + #if swift(>=5.1) + then("remove observer on dispose with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } - #endif - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } - when("non-optional key") { - then("receive updates") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + observer.dispose() + defaults.testOptionalValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) + } + #endif + } - defaults[key: key] = self.customValue + when("non-optional key") { + then("receive updates") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("receive updates with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults.testValue = self.customValue + #if swift(>=5.1) + then("receive updates with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } - #endif + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testValue) { receivedUpdate in + update.value = receivedUpdate + } - then("receives initial update being default value") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + defaults.testValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + } + #endif - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives initial update being default value") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - then("receives initial update being custom value") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - defaults[key: key] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being custom value") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[key: key] = self.customValue - then("receives initial update being default value with keyPaths") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives initial update being default value with keyPaths") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - then("receives initial update being custom value with keyPaths") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - defaults[\.testValue] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } - - then("reference itself in the update closure without crash") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] - } - defaults[key: key] = self.customValue + then("receives initial update being custom value with keyPaths") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[\.testValue] = self.customValue - expect(update).toEventuallyNot(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } - - then("reference itself in the update closure on custom queue without crash") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } + + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - DispatchQueue.global(qos: .utility).async { + then("reference itself in the update closure without crash") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } defaults[key: key] = self.customValue + + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - expect(update).toEventuallyNot(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } + then("reference itself in the update closure on custom queue without crash") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } - then("remove observer on dispose") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + DispatchQueue.global(qos: .utility).async { + defaults[key: key] = self.customValue + } - var update: DefaultsObserver.Update? - let observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - observer.dispose() - defaults[key: key] = self.customValue + then("remove observer on dispose") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("remove observer on dispose with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + observer.dispose() + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - let observer = defaults.observe(\.testValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - observer.dispose() - defaults.testValue = self.customValue + #if swift(>=5.1) + then("remove observer on dispose with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + let observer = defaults.observe(\.testValue) { receivedUpdate in + update.value = receivedUpdate + } - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) + observer.dispose() + defaults.testValue = self.customValue + + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) + } + #endif } - #endif } - } #endif } } diff --git a/Tests/SwiftyUserDefaultsTests/TestHelpers/TestHelper.swift b/Tests/SwiftyUserDefaultsTests/TestHelpers/TestHelper.swift index 7d92bcd9..8a5edf6c 100644 --- a/Tests/SwiftyUserDefaultsTests/TestHelpers/TestHelper.swift +++ b/Tests/SwiftyUserDefaultsTests/TestHelpers/TestHelper.swift @@ -15,7 +15,6 @@ func then(_ description: String, closure: @escaping () -> Void) { } extension UserDefaults { - func cleanObjects() { for (key, _) in dictionaryRepresentation() { removeObject(forKey: key) @@ -24,7 +23,6 @@ extension UserDefaults { } struct FrogCodable: Codable, Equatable, DefaultsSerializable { - let name: String init(name: String = "Froggy") { @@ -32,8 +30,7 @@ struct FrogCodable: Codable, Equatable, DefaultsSerializable { } } -final class FrogSerializable: NSObject, DefaultsSerializable, NSCoding { - +final class FrogSerializable: NSObject, DefaultsSerializable, NSCoding, @unchecked Sendable { typealias T = FrogSerializable let name: String @@ -60,7 +57,6 @@ final class FrogSerializable: NSObject, DefaultsSerializable, NSCoding { } enum BestFroggiesEnum: String, DefaultsSerializable { - case Andy case Dandy } @@ -102,17 +98,39 @@ final class DefaultsFrogArrayBridge: DefaultsBridge { } struct FrogCustomSerializable: DefaultsSerializable, Equatable { - static var _defaults: DefaultsFrogBridge { return DefaultsFrogBridge() } static var _defaultsArray: DefaultsFrogArrayBridge { return DefaultsFrogArrayBridge() } let name: String } -final class FrogKeyStore: DefaultsKeyStore { - +final class FrogKeyStore: DefaultsKeyStore, @unchecked Sendable where Serializable.T: Sendable, Serializable.ArrayBridge.T: Sendable { lazy var testValue: DefaultsKey = { fatalError("not initialized yet") }() lazy var testArray: DefaultsKey<[Serializable]> = { fatalError("not initialized yet") }() lazy var testOptionalValue: DefaultsKey = { fatalError("not initialized yet") }() lazy var testOptionalArray: DefaultsKey<[Serializable]?> = { fatalError("not initialized yet") }() } + +/// Thread-safe holder used in observer tests so handler closures can update a +/// captured value across actor boundaries. +final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var _value: Value + + init(_ initial: Value) { + _value = initial + } + + var value: Value { + get { + lock.lock() + defer { lock.unlock() } + return _value + } + set { + lock.lock() + defer { lock.unlock() } + _value = newValue + } + } +}