From e8370522ffe3290c01b70d1442ea5c0a4f9d6062 Mon Sep 17 00:00:00 2001 From: hank121314 Date: Sat, 20 Apr 2024 12:04:24 +0800 Subject: [PATCH] Add support for syncing using NSUbiquitousKeyValueStore (#136) Co-authored-by: Sindre Sorhus --- Sources/Defaults/Defaults+Bridge.swift | 2 +- Sources/Defaults/Defaults+Extensions.swift | 14 + Sources/Defaults/Defaults+Protocol.swift | 24 + Sources/Defaults/Defaults+iCloud.swift | 573 ++++++++++++++++++ Sources/Defaults/Defaults.swift | 25 +- .../Documentation.docc/Documentation.md | 4 + Sources/Defaults/Observation.swift | 109 +++- Sources/Defaults/SwiftUI.swift | 2 +- Sources/Defaults/Utilities.swift | 175 ++++++ .../DefaultsTests/Defaults+iCloudTests.swift | 275 +++++++++ Tests/DefaultsTests/DefaultsColorTests.swift | 2 +- readme.md | 1 + 12 files changed, 1189 insertions(+), 17 deletions(-) create mode 100644 Sources/Defaults/Defaults+iCloud.swift create mode 100644 Tests/DefaultsTests/Defaults+iCloudTests.swift diff --git a/Sources/Defaults/Defaults+Bridge.swift b/Sources/Defaults/Defaults+Bridge.swift index 7bcb25f5..a283d5e7 100644 --- a/Sources/Defaults/Defaults+Bridge.swift +++ b/Sources/Defaults/Defaults+Bridge.swift @@ -418,7 +418,7 @@ extension Defaults { return nil } - if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, iOSApplicationExtension 15.0, macOSApplicationExtension 12.0, tvOSApplicationExtension 15.0, watchOSApplicationExtension 8.0, *) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, iOSApplicationExtension 15.0, macOSApplicationExtension 12.0, tvOSApplicationExtension 15.0, watchOSApplicationExtension 8.0, visionOSApplicationExtension 1.0, *) { return Value(cgColor: cgColor) } diff --git a/Sources/Defaults/Defaults+Extensions.swift b/Sources/Defaults/Defaults+Extensions.swift index 7c4fd274..c14ee6ba 100644 --- a/Sources/Defaults/Defaults+Extensions.swift +++ b/Sources/Defaults/Defaults+Extensions.swift @@ -164,3 +164,17 @@ extension NSColor: Defaults.Serializable {} */ extension UIColor: Defaults.Serializable {} #endif + +extension NSUbiquitousKeyValueStore: DefaultsKeyValueStore {} +extension UserDefaults: DefaultsKeyValueStore {} + +extension DefaultsLockProtocol { + @discardableResult + func with(_ body: @Sendable () throws -> R) rethrows -> R where R: Sendable { + self.lock() + defer { + self.unlock() + } + return try body() + } +} diff --git a/Sources/Defaults/Defaults+Protocol.swift b/Sources/Defaults/Defaults+Protocol.swift index 533019dd..421621cb 100644 --- a/Sources/Defaults/Defaults+Protocol.swift +++ b/Sources/Defaults/Defaults+Protocol.swift @@ -52,3 +52,27 @@ public protocol _DefaultsRange { init(uncheckedBounds: (lower: Bound, upper: Bound)) } + +/** +Essential properties for synchronizing a key value store. +*/ +protocol DefaultsKeyValueStore { + func object(forKey aKey: String) -> Any? + + func set(_ anObject: Any?, forKey aKey: String) + + func removeObject(forKey aKey: String) + + @discardableResult + func synchronize() -> Bool +} + +protocol DefaultsLockProtocol { + static func make() -> Self + + func lock() + + func unlock() + + func with(_ body: @Sendable () throws -> R) rethrows -> R where R: Sendable +} diff --git a/Sources/Defaults/Defaults+iCloud.swift b/Sources/Defaults/Defaults+iCloud.swift new file mode 100644 index 00000000..cb624c93 --- /dev/null +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -0,0 +1,573 @@ +import OSLog +#if os(macOS) +import AppKit +#else +import UIKit +#endif +import Combine +import Foundation + +private enum SyncStatus { + case idle + case syncing + case completed +} + +/** +Manages `Defaults.Keys` between the locale and remote storage. + +Depending on the storage, `Defaults.Keys` will be represented in different forms due to storage limitations of the remote storage. The remote storage imposes a limitation of 1024 keys. Therefore, we combine the recorded timestamp and data into a single key. Unlike remote storage, local storage does not have this limitation. Therefore, we can create a separate key (with `defaultsSyncKey` suffix) for the timestamp record. +*/ +final class iCloudSynchronizer { + init(remoteStorage: DefaultsKeyValueStore) { + self.remoteStorage = remoteStorage + registerNotifications() + remoteStorage.synchronize() + } + + deinit { + removeAll() + } + + @TaskLocal static var timestamp: Date? + + private var cancellables: Set = [] + + /** + Key for recording the synchronization between `NSUbiquitousKeyValueStore` and `UserDefaults`. + */ + private let defaultsSyncKey = "__DEFAULTS__synchronizeTimestamp" + + /** + A remote key value storage. + */ + private let remoteStorage: DefaultsKeyValueStore + + /** + A FIFO queue used to serialize synchronization on keys. + */ + private let backgroundQueue = TaskQueue(priority: .utility) + + /** + A thread-safe `keys` that manage the keys to be synced. + */ + @Atomic(value: []) private(set) var keys: Set + + /** + A thread-safe synchronization status monitor for `keys`. + */ + @Atomic(value: []) private var remoteSyncingKeys: Set + + // TODO: Replace it with async stream when Swift supports custom executors. + private lazy var localKeysMonitor: Defaults.CompositeUserDefaultsAnyKeyObservation = .init { [weak self] observable in + guard + let self, + let suite = observable.suite, + let key = self.keys.first(where: { $0.name == observable.key && $0.suite == suite }), + // Prevent triggering local observation when syncing from remote. + !self.remoteSyncingKeys.contains(key) + else { + return + } + + self.enqueue { + self.recordTimestamp(forKey: key, timestamp: Self.timestamp, source: .local) + await self.syncKey(key, source: .local) + } + } + + /** + Add new key and start to observe its changes. + */ + func add(_ keys: [Defaults.Keys]) { + self.keys.formUnion(keys) + self.syncWithoutWaiting(keys) + for key in keys { + localKeysMonitor.addObserver(key) + } + } + + /** + Remove key and stop the observation. + */ + func remove(_ keys: [Defaults.Keys]) { + self.keys.subtract(keys) + for key in keys { + localKeysMonitor.removeObserver(key) + } + } + + /** + Remove all sync keys. + */ + func removeAll() { + localKeysMonitor.invalidate() + _keys.modify { $0.removeAll() } + _remoteSyncingKeys.modify { $0.removeAll() } + } + + /** + Explicitly synchronizes in-memory keys and values with those stored on disk. + */ + func synchronize() { + remoteStorage.synchronize() + } + + /** + Synchronize the specified `keys` from the given `source` without waiting. + + - Parameter keys: If the keys parameter is an empty array, the method will use the keys that were added to `Defaults.iCloud`. + - Parameter source: Sync keys from which data source (remote or local). + */ + func syncWithoutWaiting(_ keys: [Defaults.Keys] = [], _ source: Defaults.DataSource? = nil) { + let keys = keys.isEmpty ? Array(self.keys) : keys + + for key in keys { + let latest = source ?? latestDataSource(forKey: key) + self.enqueue { + await self.syncKey(key, source: latest) + } + } + } + + /** + Wait until all synchronization tasks are complete. + */ + func sync() async { + await backgroundQueue.flush() + } + + /** + Enqueue the synchronization task into `backgroundQueue` with the current timestamp. + */ + private func enqueue(_ task: @escaping TaskQueue.AsyncTask) { + self.backgroundQueue.async { + await Self.$timestamp.withValue(Date()) { + await task() + } + } + } + + /** + Create synchronization tasks for the specified `key` from the given source. + + - Parameter key: The key to synchronize. + - Parameter source: Sync key from which data source (remote or local). + */ + private func syncKey(_ key: Defaults.Keys, source: Defaults.DataSource) async { + Self.logKeySyncStatus(key, source: source, syncStatus: .idle) + + switch source { + case .remote: + await syncFromRemote(forKey: key) + case .local: + syncFromLocal(forKey: key) + } + + Self.logKeySyncStatus(key, source: source, syncStatus: .completed) + } + + /** + Only update the value if it can be retrieved from the remote storage. + */ + private func syncFromRemote(forKey key: Defaults.Keys) async { + _remoteSyncingKeys.modify { $0.insert(key) } + + await withCheckedContinuation { continuation in + guard + let object = remoteStorage.object(forKey: key.name) as? [Any], + let date = Self.timestamp, + let value = object[safe: 1] + else { + continuation.resume() + return + } + + Task { @MainActor in + Self.logKeySyncStatus(key, source: .remote, syncStatus: .syncing, value: value) + key.suite.set(value, forKey: key.name) + key.suite.set(date, forKey: "\(key.name)\(defaultsSyncKey)") + continuation.resume() + } + } + + _remoteSyncingKeys.modify { $0.remove(key) } + } + + /** + Retrieve a value from local storage, and if it does not exist, remove it from the remote storage. + */ + private func syncFromLocal(forKey key: Defaults.Keys) { + guard + let value = key.suite.object(forKey: key.name), + let date = Self.timestamp + else { + Self.logKeySyncStatus(key, source: .local, syncStatus: .syncing, value: nil) + remoteStorage.removeObject(forKey: key.name) + syncRemoteStorageOnChange() + return + } + + Self.logKeySyncStatus(key, source: .local, syncStatus: .syncing, value: value) + remoteStorage.set([date, value], forKey: key.name) + syncRemoteStorageOnChange() + } + + /** + Explicitly synchronizes in-memory keys and values when a value is changed. + */ + private func syncRemoteStorageOnChange() { + if Defaults.iCloud.syncOnChange { + synchronize() + } + } + + /** + Retrieve the timestamp associated with the specified key from the source provider. + + The timestamp storage format varies across different source providers due to storage limitations. + */ + private func timestamp(forKey key: Defaults.Keys, source: Defaults.DataSource) -> Date? { + switch source { + case .remote: + guard + let values = remoteStorage.object(forKey: key.name) as? [Any], + let timestamp = values[safe: 0] as? Date + else { + return nil + } + + return timestamp + case .local: + guard + let timestamp = key.suite.object(forKey: "\(key.name)\(defaultsSyncKey)") as? Date + else { + return nil + } + + return timestamp + } + } + + /** + Mark the current timestamp to the given storage. + */ + func recordTimestamp(forKey key: Defaults.Keys, timestamp: Date?, source: Defaults.DataSource) { + switch source { + case .remote: + guard + let values = remoteStorage.object(forKey: key.name) as? [Any], + let data = values[safe: 1], + let timestamp + else { + return + } + + remoteStorage.set([timestamp, data], forKey: key.name) + case .local: + guard let timestamp else { + return + } + key.suite.set(timestamp, forKey: "\(key.name)\(defaultsSyncKey)") + } + } + + /** + Determine which data source has the latest data available by comparing the timestamps of the local and remote sources. + */ + private func latestDataSource(forKey key: Defaults.Keys) -> Defaults.DataSource { + // If the remote timestamp does not exist, use the local timestamp as the latest data source. + guard let remoteTimestamp = self.timestamp(forKey: key, source: .remote) else { + return .local + } + guard let localTimestamp = self.timestamp(forKey: key, source: .local) else { + return .remote + } + + return localTimestamp > remoteTimestamp ? .local : .remote + } +} + +/** +`iCloudSynchronizer` notification related functions. +*/ +extension iCloudSynchronizer { + private func registerNotifications() { + // TODO: Replace it with async stream when Swift supports custom executors. + NotificationCenter.default + .publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification) + .sink { [weak self] notification in + guard let self else { + return + } + + self.didChangeExternally(notification: notification) + } + .store(in: &cancellables) + + // TODO: Replace it with async stream when Swift supports custom executors. + #if os(iOS) || os(tvOS) || os(visionOS) + NotificationCenter.default + .publisher(for: UIScene.willEnterForegroundNotification) + #elseif os(watchOS) + NotificationCenter.default + .publisher(for: WKExtension.applicationWillEnterForegroundNotification) + #endif + #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + .sink { [weak self] notification in + guard let self else { + return + } + + self.willEnterForeground(notification: notification) + } + .store(in: cancellables) + #endif + } + + private func willEnterForeground(notification: Notification) { + remoteStorage.synchronize() + } + + private func didChangeExternally(notification: Notification) { + guard notification.name == NSUbiquitousKeyValueStore.didChangeExternallyNotification else { + return + } + + guard + let userInfo = notification.userInfo, + let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], + // If `@TaskLocal timestamp` is not nil, it indicates that this notification is triggered by `syncRemoteStorageOnChange`, and therefore, we can skip updating the local storage. + Self.timestamp._defaults_isNil + else { + return + } + + for key in self.keys where changedKeys.contains(key.name) { + guard let remoteTimestamp = self.timestamp(forKey: key, source: .remote) else { + continue + } + if + let localTimestamp = self.timestamp(forKey: key, source: .local), + localTimestamp >= remoteTimestamp + { + continue + } + + self.enqueue { + await self.syncKey(key, source: .remote) + } + } + } +} + +/** +`iCloudSynchronizer` logging related functions. +*/ +extension iCloudSynchronizer { + @available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1.0, *) + private static let logger = Logger(OSLog.default) + + private static func logKeySyncStatus(_ key: Defaults.Keys, source: Defaults.DataSource, syncStatus: SyncStatus, value: Any? = nil) { + guard Defaults.iCloud.isDebug else { + return + } + + let destination = switch source { + case .local: + "from local" + case .remote: + "from remote" + } + + let status: String + var valueDescription = " " + switch syncStatus { + case .idle: + status = "Try synchronizing" + case .syncing: + status = "Synchronizing" + valueDescription = " with value \(value ?? "nil") " + case .completed: + status = "Complete synchronization" + } + + let message = "\(status) key '\(key.name)'\(valueDescription)\(destination)" + log(message) + } + + private static func log(_ message: String) { + guard Defaults.iCloud.isDebug else { + return + } + + if #available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1.0, *) { + logger.debug("[Defaults.iCloud] \(message)") + } else { + #if canImport(OSLog) + os_log(.debug, log: .default, "[Defaults.iCloud] %@", message) + #else + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSSZZZ" + let dateString = dateFormatter.string(from: Date()) + let processName = ProcessInfo.processInfo.processName + let processIdentifier = ProcessInfo.processInfo.processIdentifier + var threadID: UInt64 = 0 + pthread_threadid_np(nil, &threadID) + print("\(dateString) \(processName)[\(processIdentifier):\(threadID)] [Defaults.iCloud] \(message)") + #endif + } + } +} + +extension Defaults { + /** + Represent different data sources available for synchronization. + */ + public enum DataSource { + /** + Using `key.suite` as data source. + */ + case local + + /** + Using `NSUbiquitousKeyValueStore` as data source. + */ + case remote + } + + /** + Synchronize values with different devices over iCloud. + + There are five ways to initiate synchronization, each of which will create a synchronization task in ``Defaults/iCloud/iCloud``: + + 1. Using ``iCloud/add(_:)-5gffb`` + 2. Utilizing ``iCloud/syncWithoutWaiting(_:source:)-9cpju`` + 3. Observing UserDefaults for added ``Defaults/Defaults/Key`` using Key-Value Observation (KVO) + 4. Monitoring `NSUbiquitousKeyValueStore.didChangeExternallyNotification` for added ``Defaults/Defaults/Key``. + 5. Initializing ``Defaults/Defaults/Keys`` with parameter `iCloud: true`. + + > Tip: After initializing the task, we can call ``iCloud/sync()`` to ensure that all tasks in the backgroundQueue are completed. + + ```swift + import Defaults + + extension Defaults.Keys { + static let isUnicornMode = Key("isUnicornMode", default: true, iCloud: true) + } + + Task { + let quality = Defaults.Key("quality", default: 0) + Defaults.iCloud.add(quality) + await Defaults.iCloud.sync() // Optional step: only needed if you require everything to be synced before continuing. + // Both `isUnicornMode` and `quality` are synced. + } + ``` + */ + public enum iCloud { + /** + The singleton for Defaults's iCloudSynchronizer. + */ + static var synchronizer = iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) + + /** + The synced keys. + */ + public static var keys: Set { synchronizer.keys } + + /** + Enable this if you want to call ```` when a value is changed. + */ + public static var syncOnChange = false + + /** + Enable this if you want to debug the syncing status of keys. + Logs will be printed to the console in OSLog format. + + - Note: The log information will include details such as the key being synced, its corresponding value, and the status of the synchronization. + */ + public static var isDebug = false + + /** + Add the keys to be automatically synced. + */ + public static func add(_ keys: Defaults.Keys...) { + synchronizer.add(keys) + } + + /** + Add the keys to be automatically synced. + */ + public static func add(_ keys: [Defaults.Keys]) { + synchronizer.add(keys) + } + + /** + Remove the keys that are set to be automatically synced. + */ + public static func remove(_ keys: Defaults.Keys...) { + synchronizer.remove(keys) + } + + /** + Remove the keys that are set to be automatically synced. + */ + public static func remove(_ keys: [Defaults.Keys]) { + synchronizer.remove(keys) + } + + /** + Remove all keys that are set to be automatically synced. + */ + public static func removeAll() { + synchronizer.removeAll() + } + + /** + Explicitly synchronizes in-memory keys and values with those stored on disk. + + As per apple docs, the only recommended time to call this method is upon app launch, or upon returning to the foreground, to ensure that the in-memory key-value store representation is up-to-date. + */ + public static func synchronize() { + synchronizer.synchronize() + } + + /** + Wait until synchronization is complete. + */ + public static func sync() async { + await synchronizer.sync() + } + + /** + Create synchronization tasks for all the keys that have been added to the ``Defaults/Defaults/iCloud``. + */ + public static func syncWithoutWaiting() { + synchronizer.syncWithoutWaiting() + } + + /** + Create synchronization tasks for the specified `keys` from the given source, which can be either a remote server or a local cache. + + - Parameter keys: The keys that should be synced. + - Parameter source: Sync keys from which data source(remote or local) + + - Note: `source` should be specified if `key` has not been added to ``Defaults/Defaults/iCloud``. + */ + public static func syncWithoutWaiting(_ keys: Defaults.Keys..., source: DataSource? = nil) { + synchronizer.syncWithoutWaiting(keys, source) + } + + /** + Create synchronization tasks for the specified `keys` from the given source, which can be either a remote server or a local cache. + + - Parameter keys: The keys that should be synced. + - Parameter source: Sync keys from which data source(remote or local) + + - Note: `source` should be specified if `key` has not been added to ``Defaults/Defaults/iCloud``. + */ + public static func syncWithoutWaiting(_ keys: [Defaults.Keys], source: DataSource? = nil) { + synchronizer.syncWithoutWaiting(keys, source) + } + } +} diff --git a/Sources/Defaults/Defaults.swift b/Sources/Defaults/Defaults.swift index 2385cc9e..2899e56c 100644 --- a/Sources/Defaults/Defaults.swift +++ b/Sources/Defaults/Defaults.swift @@ -103,6 +103,7 @@ extension Defaults { Create a key. - Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`). + - Parameter iCloud: Automatically synchronize the value with ``Defaults/Defaults/iCloud``. The `default` parameter should not be used if the `Value` type is an optional. */ @@ -110,8 +111,15 @@ extension Defaults { public init( _ name: String, default defaultValue: Value, - suite: UserDefaults = .standard + suite: UserDefaults = .standard, + iCloud: Bool = false ) { + defer { + if iCloud { + Defaults.iCloud.add(self) + } + } + self.defaultValueGetter = { defaultValue } super.init(name: name, suite: suite) @@ -140,6 +148,7 @@ extension Defaults { ``` - Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`). + - Parameter iCloud: Automatically synchronize the value with ``Defaults/Defaults/iCloud``. - Note: This initializer will not set the default value in the actual `UserDefaults`. This should not matter much though. It's only really useful if you use legacy KVO bindings. */ @@ -147,11 +156,16 @@ extension Defaults { public init( _ name: String, suite: UserDefaults = .standard, - default defaultValueGetter: @escaping () -> Value + default defaultValueGetter: @escaping () -> Value, + iCloud: Bool = false ) { self.defaultValueGetter = defaultValueGetter super.init(name: name, suite: suite) + + if iCloud { + Defaults.iCloud.add(self) + } } } } @@ -162,13 +176,14 @@ extension Defaults.Key { Create a key with an optional value. - Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`). + - Parameter iCloud: Automatically synchronize the value with ``Defaults/Defaults/iCloud``. */ - @_transparent public convenience init( _ name: String, - suite: UserDefaults = .standard + suite: UserDefaults = .standard, + iCloud: Bool = false ) where Value == T? { - self.init(name, default: nil, suite: suite) + self.init(name, default: nil, suite: suite, iCloud: iCloud) } } diff --git a/Sources/Defaults/Documentation.docc/Documentation.md b/Sources/Defaults/Documentation.docc/Documentation.md index 06ab5343..5147ba04 100644 --- a/Sources/Defaults/Documentation.docc/Documentation.md +++ b/Sources/Defaults/Documentation.docc/Documentation.md @@ -66,3 +66,7 @@ typealias Default = _Default - ``Defaults/PreferRawRepresentable`` - ``Defaults/PreferNSSecureCoding`` + +### iCloud + +- ``Defaults/iCloud`` diff --git a/Sources/Defaults/Observation.swift b/Sources/Defaults/Observation.swift index fff41e8a..dc06578b 100644 --- a/Sources/Defaults/Observation.swift +++ b/Sources/Defaults/Observation.swift @@ -195,19 +195,29 @@ extension Defaults { } } - private final class CompositeUserDefaultsKeyObservation: NSObject, Observation { - private static var observationContext = 0 + final class SuiteKeyPair: Hashable { + weak var suite: UserDefaults? + let key: String - private final class SuiteKeyPair { - weak var suite: UserDefaults? - let key: String + init(suite: UserDefaults, key: String) { + self.suite = suite + self.key = key + } - init(suite: UserDefaults, key: String) { - self.suite = suite - self.key = key - } + func hash(into hasher: inout Hasher) { + hasher.combine(key) + hasher.combine(suite) } + static func == (lhs: SuiteKeyPair, rhs: SuiteKeyPair) -> Bool { + lhs.key == rhs.key + && lhs.suite == rhs.suite + } + } + + private final class CompositeUserDefaultsKeyObservation: NSObject, Observation { + private static var observationContext = 0 + private var observables: [SuiteKeyPair] private var lifetimeAssociation: LifetimeAssociation? private let callback: UserDefaultsKeyObservation.Callback @@ -286,6 +296,87 @@ extension Defaults { } } + final class CompositeUserDefaultsAnyKeyObservation: NSObject, Observation { + typealias Callback = (SuiteKeyPair) -> Void + private static var observationContext = 1 + + private var observables: Set = [] + private var lifetimeAssociation: LifetimeAssociation? + private let callback: CompositeUserDefaultsAnyKeyObservation.Callback + + init(_ callback: @escaping CompositeUserDefaultsAnyKeyObservation.Callback) { + self.callback = callback + } + + func addObserver(_ key: Defaults._AnyKey, options: ObservationOptions = []) { + let keyPair: SuiteKeyPair = .init(suite: key.suite, key: key.name) + let (inserted, observable) = observables.insert(keyPair) + guard inserted else { + return + } + + observable.suite?.addObserver(self, forKeyPath: observable.key, options: options.toNSKeyValueObservingOptions, context: &Self.observationContext) + } + + func removeObserver(_ key: Defaults._AnyKey) { + let keyPair: SuiteKeyPair = .init(suite: key.suite, key: key.name) + guard let observable = observables.remove(keyPair) else { + return + } + + observable.suite?.removeObserver(self, forKeyPath: observable.key, context: &Self.observationContext) + } + + @discardableResult + func tieToLifetime(of weaklyHeldObject: AnyObject) -> Self { + // swiftlint:disable:next trailing_closure + lifetimeAssociation = LifetimeAssociation(of: self, with: weaklyHeldObject, deinitHandler: { [weak self] in + self?.invalidate() + }) + + return self + } + + func removeLifetimeTie() { + lifetimeAssociation?.cancel() + } + + func invalidate() { + for observable in observables { + observable.suite?.removeObserver(self, forKeyPath: observable.key, context: &Self.observationContext) + observable.suite = nil + } + + observables.removeAll() + lifetimeAssociation?.cancel() + } + + // swiftlint:disable:next block_based_kvo + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection + context: UnsafeMutableRawPointer? + ) { + guard + context == &Self.observationContext + else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + return + } + + guard + let object = object as? UserDefaults, + let keyPath, + let observable = observables.first(where: { $0.key == keyPath && $0.suite == object }) + else { + return + } + + callback(observable) + } + } + /** Observe a defaults key. diff --git a/Sources/Defaults/SwiftUI.swift b/Sources/Defaults/SwiftUI.swift index 936c68fd..cf3e76cf 100644 --- a/Sources/Defaults/SwiftUI.swift +++ b/Sources/Defaults/SwiftUI.swift @@ -34,7 +34,7 @@ extension Defaults { func observe() { // We only use this on the latest OSes (as of adding this) since the backdeploy library has a lot of bugs. - if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) { + if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1.0, *) { task?.cancel() // The `@MainActor` is important as the `.send()` method doesn't inherit the `@MainActor` from the class. diff --git a/Sources/Defaults/Utilities.swift b/Sources/Defaults/Utilities.swift index 3b66b06d..d952c3ff 100644 --- a/Sources/Defaults/Utilities.swift +++ b/Sources/Defaults/Utilities.swift @@ -1,4 +1,5 @@ import Foundation +import Combine #if DEBUG #if canImport(OSLog) import OSLog @@ -234,6 +235,180 @@ extension Defaults.Serializable { } } +// swiftlint:disable:next final_class +class Lock: DefaultsLockProtocol { + final class UnfairLock: Lock { + private let _lock: os_unfair_lock_t + + override init() { + _lock = .allocate(capacity: 1) + _lock.initialize(to: os_unfair_lock()) + } + + override func lock() { + os_unfair_lock_lock(_lock) + } + + override func unlock() { + os_unfair_lock_unlock(_lock) + } + } + + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) + final class AllocatedUnfairLock: Lock { + private let _lock = OSAllocatedUnfairLock() + + override init() { + super.init() + } + + override func lock() { + _lock.lock() + } + + override func unlock() { + _lock.unlock() + } + } + + static func make() -> Self { + guard #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) else { + return UnfairLock() as! Self + } + + return AllocatedUnfairLock() as! Self + } + + private init() {} + func lock() {} + func unlock() {} +} + +/** +A queue for executing asynchronous tasks in order. + +```swift +actor Counter { + var count = 0 + + func increase() { + count += 1 + } +} +let counter = Counter() +let queue = TaskQueue(priority: .background) +queue.async { + print(await counter.count) //=> 0 +} +queue.async { + await counter.increase() +} +queue.async { + print(await counter.count) //=> 1 +} +``` +*/ +final class TaskQueue { + typealias AsyncTask = @Sendable () async -> Void + private var queueContinuation: AsyncStream.Continuation? + private let lock: Lock = .make() + + init(priority: TaskPriority? = nil) { + let (taskStream, queueContinuation) = AsyncStream.makeStream() + self.queueContinuation = queueContinuation + + Task.detached(priority: priority) { + for await task in taskStream { + await task() + } + } + } + + deinit { + queueContinuation?.finish() + } + + /** + Queue a new asynchronous task. + */ + func async(_ task: @escaping AsyncTask) { + lock.with { + queueContinuation?.yield(task) + } + } + + /** + Wait until previous tasks finish. + + ```swift + Task { + queue.async { + print("1") + } + queue.async { + print("2") + } + await queue.flush() + //=> 1 + //=> 2 + } + ``` + */ + func flush() async { + await withCheckedContinuation { continuation in + lock.with { + queueContinuation?.yield { + continuation.resume() + } + return + } + } + } +} + +// TODO: replace with Swift 6 native Atomics support. +@propertyWrapper +final class Atomic { + private let lock: Lock = .make() + private var _value: Value + + var wrappedValue: Value { + get { + withValue { $0 } + } + set { + swap(newValue) + } + } + + init(value: Value) { + self._value = value + } + + @discardableResult + func withValue(_ action: (Value) -> R) -> R { + lock.lock() + defer { lock.unlock() } + return action(_value) + } + + @discardableResult + func modify(_ action: (inout Value) -> R) -> R { + lock.lock() + defer { lock.unlock() } + return action(&_value) + } + + @discardableResult + func swap(_ newValue: Value) -> Value { + modify { (value: inout Value) in + let oldValue = value + value = newValue + return oldValue + } + } +} + #if DEBUG /** Get SwiftUI dynamic shared object. diff --git a/Tests/DefaultsTests/Defaults+iCloudTests.swift b/Tests/DefaultsTests/Defaults+iCloudTests.swift new file mode 100644 index 00000000..8becd44f --- /dev/null +++ b/Tests/DefaultsTests/Defaults+iCloudTests.swift @@ -0,0 +1,275 @@ +@testable import Defaults +import SwiftUI +import XCTest + +final class MockStorage: DefaultsKeyValueStore { + private var pairs: [String: Any] = [:] + private let queue = DispatchQueue(label: "a") + + func data(forKey aKey: String) -> T? { + queue.sync { + guard + let values = pairs[aKey] as? [Any], + let data = values[safe: 1] as? T + else { + return nil + } + + return data + } + } + + func object(forKey aKey: String) -> T? { + queue.sync { + pairs[aKey] as? T + } + } + + func object(forKey aKey: String) -> Any? { + queue.sync { + pairs[aKey] + } + } + + func set(_ anObject: Any?, forKey aKey: String) { + queue.sync { + pairs[aKey] = anObject + } + } + + func removeObject(forKey aKey: String) { + _ = queue.sync { + pairs.removeValue(forKey: aKey) + } + } + + func removeAll() { + queue.sync { + pairs.removeAll() + } + } + + @discardableResult + func synchronize() -> Bool { + let pairs = queue.sync { + Array(self.pairs.keys) + } + NotificationCenter.default.post(Notification(name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, userInfo: [NSUbiquitousKeyValueStoreChangedKeysKey: pairs])) + return true + } +} + +private let mockStorage = MockStorage() + +@available(iOS 15, tvOS 15, watchOS 8, visionOS 1.0, *) +final class DefaultsICloudTests: XCTestCase { + override final class func setUp() { + Defaults.iCloud.isDebug = true + Defaults.iCloud.syncOnChange = true + Defaults.iCloud.synchronizer = iCloudSynchronizer(remoteStorage: mockStorage) + } + + override func setUp() { + super.setUp() + mockStorage.removeAll() + Defaults.iCloud.removeAll() + Defaults.removeAll() + } + + override func tearDown() { + super.tearDown() + mockStorage.removeAll() + Defaults.iCloud.removeAll() + Defaults.removeAll() + } + + private func updateMockStorage(key: String, value: T, _ date: Date? = nil) { + mockStorage.set([date ?? Date(), value], forKey: key) + } + + func testICloudInitialize() async { + print(Defaults.iCloud.keys) + let name = Defaults.Key("testICloudInitialize_name", default: "0", iCloud: true) + let quality = Defaults.Key("testICloudInitialize_quality", default: 0.0, iCloud: true) + + print(Defaults.iCloud.keys) + await Defaults.iCloud.sync() + XCTAssertEqual(mockStorage.data(forKey: name.name), "0") + XCTAssertEqual(mockStorage.data(forKey: quality.name), 0.0) + let name_expected = ["1", "2", "3", "4", "5", "6", "7"] + let quality_expected = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + + for index in 0..("testDidChangeExternallyNotification_name", iCloud: true) + let quality = Defaults.Key("testDidChangeExternallyNotification_quality", iCloud: true) + await Defaults.iCloud.sync() + XCTAssertEqual(Defaults[name], "0") + XCTAssertEqual(Defaults[quality], 0.0) + let name_expected = ["1", "2", "3", "4", "5", "6", "7"] + let quality_expected = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + + for index in 0..("testICloudInitializeSyncLast_name", default: "0", iCloud: true) + let quality = Defaults.Key("testICloudInitializeSyncLast_quality", default: 0.0, iCloud: true) + let name_expected = ["1", "2", "3", "4", "5", "6", "7"] + let quality_expected = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + + for index in 0..("testRemoveKey_name", default: "0", iCloud: true) + let quality = Defaults.Key("testRemoveKey_quality", default: 0.0, iCloud: true) + Defaults[name] = "1" + Defaults[quality] = 1.0 + await Defaults.iCloud.sync() + XCTAssertEqual(mockStorage.data(forKey: name.name), "1") + XCTAssertEqual(mockStorage.data(forKey: quality.name), 1.0) + + Defaults.iCloud.remove(quality) + Defaults[name] = "2" + Defaults[quality] = 1.0 + await Defaults.iCloud.sync() + XCTAssertEqual(mockStorage.data(forKey: name.name), "2") + XCTAssertEqual(mockStorage.data(forKey: quality.name), 1.0) + } + + func testSyncKeysFromLocal() async { + let name = Defaults.Key("testSyncKeysFromLocal_name", default: "0") + let quality = Defaults.Key("testSyncKeysFromLocal_quality", default: 0.0) + let name_expected = ["1", "2", "3", "4", "5", "6", "7"] + let quality_expected = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + + for index in 0..("testSyncKeysFromRemote_name") + let quality = Defaults.Key("testSyncKeysFromRemote_quality") + let name_expected = ["1", "2", "3", "4", "5", "6", "7"] + let quality_expected = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + + for index in 0..("testInitAddFromDetached_name", default: "0") + let task = Task.detached { + Defaults.iCloud.add(name) + Defaults.iCloud.syncWithoutWaiting() + await Defaults.iCloud.sync() + } + await task.value + XCTAssertEqual(mockStorage.data(forKey: name.name), "0") + Defaults[name] = "1" + await Defaults.iCloud.sync() + XCTAssertEqual(mockStorage.data(forKey: name.name), "1") + } + + func testICloudInitializeFromDetached() async { + let task = Task.detached { + let name = Defaults.Key("testICloudInitializeFromDetached_name", default: "0", iCloud: true) + + await Defaults.iCloud.sync() + XCTAssertEqual(mockStorage.data(forKey: name.name), "0") + } + await task.value + } +} diff --git a/Tests/DefaultsTests/DefaultsColorTests.swift b/Tests/DefaultsTests/DefaultsColorTests.swift index 6852932c..83a9502b 100644 --- a/Tests/DefaultsTests/DefaultsColorTests.swift +++ b/Tests/DefaultsTests/DefaultsColorTests.swift @@ -2,7 +2,7 @@ import SwiftUI import Defaults import XCTest -@available(iOS 15, tvOS 15, watchOS 8, *) +@available(iOS 15, tvOS 15, watchOS 8, visionOS 1.0, *) final class DefaultsColorTests: XCTestCase { override func setUp() { super.setUp() diff --git a/readme.md b/readme.md index 6e326e4e..3b463ff1 100644 --- a/readme.md +++ b/readme.md @@ -17,6 +17,7 @@ It's used in production by [all my apps](https://sindresorhus.com/apps) (1 milli - **Observation:** Observe changes to keys. - **Debuggable:** The data is stored as JSON-serialized values. - **Customizable:** You can serialize and deserialize your own type in your own way. +- **iCloud support:** Automatically synchronize data between devices. ## Benefits over `@AppStorage`