From c82dd99d549e5cfb845740f0221a6a66c5dd4ab0 Mon Sep 17 00:00:00 2001 From: hank121314 Date: Mon, 22 May 2023 14:49:27 +0800 Subject: [PATCH 01/13] Support `NSUbiquitousKeyValueStore` --- Sources/Defaults/Defaults+Extensions.swift | 3 + Sources/Defaults/Defaults+Protocol.swift | 11 + Sources/Defaults/Defaults+iCloud.swift | 432 ++++++++++++++++++ Sources/Defaults/Defaults.swift | 22 +- .../Documentation.docc/Documentation.md | 4 + Sources/Defaults/Utilities.swift | 173 +++++++ .../DefaultsTests/Defaults+iCloudTests.swift | 229 ++++++++++ readme.md | 30 ++ 8 files changed, 899 insertions(+), 5 deletions(-) create mode 100644 Sources/Defaults/Defaults+iCloud.swift create mode 100644 Tests/DefaultsTests/Defaults+iCloudTests.swift diff --git a/Sources/Defaults/Defaults+Extensions.swift b/Sources/Defaults/Defaults+Extensions.swift index 7c4fd27..9ce5b86 100644 --- a/Sources/Defaults/Defaults+Extensions.swift +++ b/Sources/Defaults/Defaults+Extensions.swift @@ -164,3 +164,6 @@ extension NSColor: Defaults.Serializable {} */ extension UIColor: Defaults.Serializable {} #endif + +extension NSUbiquitousKeyValueStore: Defaults.KeyValueStore {} +extension UserDefaults: Defaults.KeyValueStore {} diff --git a/Sources/Defaults/Defaults+Protocol.swift b/Sources/Defaults/Defaults+Protocol.swift index 533019d..2246039 100644 --- a/Sources/Defaults/Defaults+Protocol.swift +++ b/Sources/Defaults/Defaults+Protocol.swift @@ -52,3 +52,14 @@ public protocol _DefaultsRange { init(uncheckedBounds: (lower: Bound, upper: Bound)) } + +/** +Essential properties for synchronizing a key value store. +*/ +public protocol _DefaultsKeyValueStore { + func object(forKey aKey: String) -> Any? + func set(_ anObject: Any?, forKey aKey: String) + func removeObject(forKey aKey: String) + @discardableResult + func synchronize() -> Bool +} diff --git a/Sources/Defaults/Defaults+iCloud.swift b/Sources/Defaults/Defaults+iCloud.swift new file mode 100644 index 0000000..1645897 --- /dev/null +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -0,0 +1,432 @@ +#if !os(macOS) +import UIKit +#endif +import Combine +import Foundation + +/// 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 +} + +private enum SyncStatus { + case start + case isSyncing + case finish +} + +extension Defaults { + /** + Automatically synchronizing ``keys`` when they are changed. + */ + public final class iCloud: NSObject { + override init() { + self.remoteStorage = NSUbiquitousKeyValueStore.default + super.init() + registerNotifications() + remoteStorage.synchronize() + } + + init(remoteStorage: KeyValueStore) { + self.remoteStorage = remoteStorage + super.init() + registerNotifications() + remoteStorage.synchronize() + } + + deinit { + removeAll() + } + + /** + Set of keys which need to sync. + */ + private var keys: Set = [] + + /** + Key for recording the synchronization between `NSUbiquitousKeyValueStore` and `UserDefaults`. + */ + private let defaultsSyncKey = "__DEFAULTS__synchronizeTimestamp" + + /** + A remote key value storage. + */ + private var remoteStorage: KeyValueStore + + /** + A local storage responsible for recording synchronization timestamp. + */ + private let localStorage: KeyValueStore = UserDefaults.standard + + /** + A FIFO queue used to serialize synchronization on keys. + */ + private let backgroundQueue = TaskQueue(priority: .background) + + /** + A thread-safe synchronization status monitor for `keys`. + */ + private var atomicSet: AtomicSet = .init() + + /** + Add new key and start to observe its changes. + */ + private func add(_ keys: [Defaults.Keys]) { + self.keys.formUnion(keys) + for key in keys { + addObserver(key) + } + } + + /** + Remove key and stop the observation. + */ + private func remove(_ keys: [Defaults.Keys]) { + self.keys.subtract(keys) + for key in keys { + removeObserver(key) + } + } + + /** + Remove all sync keys. + */ + private func removeAll() { + for key in keys { + removeObserver(key) + } + keys.removeAll() + atomicSet.removeAll() + } + + /** + Explicitly synchronizes in-memory keys and values with those stored on disk. + */ + private func synchronize() { + remoteStorage.synchronize() + } + + /** + Synchronize the specified `keys` from the given `source`. + + - 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). + */ + private func syncKeys(_ keys: [Defaults.Keys] = [], _ source: DataSource? = nil) { + let keys = keys.isEmpty ? Array(self.keys) : keys + let latest = source ?? latestDataSource() + + backgroundQueue.sync { + for key in keys { + await self.syncKey(key, latest) + } + } + } + + /** + Synchronize 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: DataSource) async { + Self.logKeySyncStatus(key, source, .start) + atomicSet.insert(key) + await withCheckedContinuation { continuation in + let completion = { + continuation.resume() + } + switch source { + case .remote: + syncFromRemote(key: key, completion) + recordTimestamp(.local) + case .local: + syncFromLocal(key: key, completion) + recordTimestamp(.remote) + } + } + Self.logKeySyncStatus(key, source, .finish) + atomicSet.remove(key) + } + + /** + Only update the value if it can be retrieved from the remote storage. + */ + private func syncFromRemote(key: Defaults.Keys, _ completion: @escaping () -> Void) { + guard let value = remoteStorage.object(forKey: key.name) else { + completion() + return + } + + Task { @MainActor in + Defaults.iCloud.logKeySyncStatus(key, .remote, .isSyncing, value) + key.suite.set(value, forKey: key.name) + completion() + } + } + + /** + Retrieve a value from local storage, and if it does not exist, remove it from the remote storage. + */ + private func syncFromLocal(key: Defaults.Keys, _ completion: @escaping () -> Void) { + guard let value = key.suite.object(forKey: key.name) else { + Defaults.iCloud.logKeySyncStatus(key, .local, .isSyncing, nil) + remoteStorage.removeObject(forKey: key.name) + syncRemoteStorageOnChange() + completion() + return + } + + Defaults.iCloud.logKeySyncStatus(key, .local, .isSyncing, value) + remoteStorage.set(value, forKey: key.name) + syncRemoteStorageOnChange() + completion() + } + + /** + Explicitly synchronizes in-memory keys and values when a value is changed. + */ + private func syncRemoteStorageOnChange() { + if Self.syncOnChange { + synchronize() + } + } + + /** + Mark the current timestamp for the specified `source`. + */ + private func recordTimestamp(_ source: DataSource) { + switch source { + case .local: + localStorage.set(Date(), forKey: defaultsSyncKey) + case .remote: + remoteStorage.set(Date(), forKey: defaultsSyncKey) + } + } + + /** + Determine which data source has the latest data available by comparing the timestamps of the local and remote sources. + */ + private func latestDataSource() -> DataSource { + // If the remote timestamp does not exist, use the local timestamp as the latest data source. + guard let remoteTimestamp = remoteStorage.object(forKey: defaultsSyncKey) as? Date else { + return .local + } + guard let localTimestamp = localStorage.object(forKey: defaultsSyncKey) as? Date else { + return .remote + } + + return localTimestamp.timeIntervalSince1970 > remoteTimestamp.timeIntervalSince1970 ? .local : .remote + } + } +} + +extension Defaults.iCloud { + /** + The singleton for Defaults's iCloud. + */ + static var `default` = Defaults.iCloud() + + /** + Lists the synced keys. + */ + public static let keys = `default`.keys + + /** + Enable this if you want to call `NSUbiquitousKeyValueStore.synchronize` when value is changed. + */ + public static var syncOnChange = false + + /** + Enable this if you want to debug the syncing status of keys. + */ + public static var debug = false + + /** + Add keys to be automatically synced. + */ + public static func add(_ keys: Defaults.Keys...) { + `default`.add(keys) + } + + /** + Remove keys to be automatically synced. + */ + public static func remove(_ keys: Defaults.Keys...) { + `default`.remove(keys) + } + + /** + Remove all keys to be automatically synced. + */ + public static func removeAll() { + `default`.removeAll() + } + + /** + Explicitly synchronizes in-memory keys and values with those stored on disk. + */ + public static func sync() { + `default`.synchronize() + } + + /** + Wait until all synchronization tasks are complete and explicitly synchronizes in-memory keys and values with those stored on disk. + */ + public static func sync() async { + await `default`.backgroundQueue.flush() + `default`.synchronize() + } + + /** + Synchronize all of the keys that have been added to Defaults.iCloud. + */ + public static func syncKeys() { + `default`.syncKeys() + } + + /** + Synchronize the specified `keys` from the given `source`, which could be 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 specify if `key` has not been added to `Defaults.iCloud`. + */ + public static func syncKeys(_ keys: Defaults.Keys..., source: DataSource? = nil) { + `default`.syncKeys(keys, source) + } +} + +/** +`Defaults.iCloud` notification related functions. +*/ +extension Defaults.iCloud { + private func registerNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(didChangeExternally(notification:)), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: nil) + #if os(iOS) || os(tvOS) + NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(notification:)), name: UIScene.willEnterForegroundNotification, object: nil) + #endif + #if os(watchOS) + NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(notification:)), name: WKExtension.applicationWillEnterForegroundNotification, object: nil) + #endif + } + + @objc + private func willEnterForeground(notification: Notification) { + remoteStorage.synchronize() + } + + @objc + private func didChangeExternally(notification: Notification) { + guard notification.name == NSUbiquitousKeyValueStore.didChangeExternallyNotification else { + return + } + + guard + let userInfo = notification.userInfo, + let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], + let remoteTimestamp = remoteStorage.object(forKey: defaultsSyncKey) as? Date + else { + return + } + + if + let localTimestamp = localStorage.object(forKey: defaultsSyncKey) as? Date, + localTimestamp > remoteTimestamp + { + return + } + + for key in self.keys where changedKeys.contains(key.name) { + backgroundQueue.sync { + await self.syncKey(key, .remote) + } + } + } +} + +/** +`Defaults.iCloud` observation related functions. +*/ +extension Defaults.iCloud { + private func addObserver(_ key: Defaults.Keys) { + backgroundQueue.sync { + key.suite.addObserver(self, forKeyPath: key.name, options: [.new], context: nil) + } + } + + private func removeObserver(_ key: Defaults.Keys) { + backgroundQueue.sync { + key.suite.removeObserver(self, forKeyPath: key.name, context: nil) + } + } + + @_documentation(visibility: private) + // swiftlint:disable:next block_based_kvo + override public func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection + context: UnsafeMutableRawPointer? + ) { + guard + let keyPath, + let object, + object is UserDefaults, + let key = keys.first(where: { $0.name == keyPath }), + !atomicSet.contains(key) + else { + return + } + + backgroundQueue.async { + self.recordTimestamp(.local) + await self.syncKey(key, .local) + } + } +} + +/** +`Defaults.iCloud` logging related functions. +*/ +extension Defaults.iCloud { + private static func logKeySyncStatus(_ key: Defaults.Keys, _ source: DataSource, _ syncStatus: SyncStatus, _ value: Any? = nil) { + guard Self.debug else { + return + } + var destination: String + switch source { + case .local: + destination = "from local" + case .remote: + destination = "from remote" + } + var status: String + var valueDescription = "" + switch syncStatus { + case .start: + status = "Start synchronization" + case .isSyncing: + status = "Synchronizing" + valueDescription = "with value '\(value ?? "nil")'" + case .finish: + status = "Finish synchronization" + } + let message = "\(status) key '\(key.name)' \(valueDescription) \(destination)" + + log(message) + } + + private static func log(_ message: String) { + guard Self.debug else { + return + } + let formatter = DateFormatter() + formatter.dateFormat = "y/MM/dd H:mm:ss.SSSS" + print("[\(formatter.string(from: Date()))] DEBUG(Defaults) - \(message)") + } +} diff --git a/Sources/Defaults/Defaults.swift b/Sources/Defaults/Defaults.swift index 2385cc9..710ab93 100644 --- a/Sources/Defaults/Defaults.swift +++ b/Sources/Defaults/Defaults.swift @@ -110,11 +110,16 @@ extension Defaults { public init( _ name: String, default defaultValue: Value, - suite: UserDefaults = .standard + suite: UserDefaults = .standard, + iCloud: Bool = false ) { self.defaultValueGetter = { defaultValue } super.init(name: name, suite: suite) + + if iCloud { + Defaults.iCloud.add(self) + } if (defaultValue as? _DefaultsOptionalProtocol)?._defaults_isNil == true { return @@ -147,11 +152,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) + } } } } @@ -163,12 +173,12 @@ extension Defaults.Key { - Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`). */ - @_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) } } @@ -297,6 +307,8 @@ extension Defaults { public typealias RangeSerializable = _DefaultsRange & _DefaultsSerializable + public typealias KeyValueStore = _DefaultsKeyValueStore + /** Convenience protocol for `Codable`. */ diff --git a/Sources/Defaults/Documentation.docc/Documentation.md b/Sources/Defaults/Documentation.docc/Documentation.md index 06ab534..5147ba0 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/Utilities.swift b/Sources/Defaults/Utilities.swift index 3b66b06..9245825 100644 --- a/Sources/Defaults/Utilities.swift +++ b/Sources/Defaults/Utilities.swift @@ -234,6 +234,179 @@ extension Defaults.Serializable { } } +/** +A reader/writer threading lock based on `libpthread`. +*/ +final class RWLock { + private let lock: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: 1) + + init() { + let err = pthread_rwlock_init(lock, nil) + precondition(err == 0, "\(#function) failed in pthread_rwlock_init with error \(err)") + } + + deinit { + let err = pthread_rwlock_destroy(lock) + precondition(err == 0, "\(#function) failed in pthread_rwlock_destroy with error \(err)") + lock.deallocate() + } + + private func lockRead() { + let err = pthread_rwlock_rdlock(lock) + precondition(err == 0, "\(#function) failed in pthread_rwlock_rdlock with error \(err)") + } + + private func lockWrite() { + let err = pthread_rwlock_wrlock(lock) + precondition(err == 0, "\(#function) failed in pthread_rwlock_wrlock with error \(err)") + } + + private func unlock() { + let err = pthread_rwlock_unlock(lock) + precondition(err == 0, "\(#function) failed in pthread_rwlock_unlock with error \(err)") + } + + @inlinable + func withReadLock(body: () -> R) -> R { + lockRead() + defer { + unlock() + } + return body() + } + + @inlinable + func withWriteLock(body: () -> R) -> R { + lockWrite() + defer { + unlock() + } + return body() + } +} + +/** +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? + + init(priority: TaskPriority? = nil) { + let taskStream = AsyncStream { queueContinuation = $0 } + + Task.detached(priority: priority) { + for await task in taskStream { + await task() + } + } + } + + deinit { + queueContinuation?.finish() + } + + /** + Queue a new asynchronous task. + */ + func async(_ task: @escaping AsyncTask) { + queueContinuation?.yield(task) + } + + /** + Queue a new asynchronous task and wait until it done. + */ + func sync(_ task: @escaping AsyncTask) { + let semaphore = DispatchSemaphore(value: 0) + + queueContinuation?.yield { + await task() + semaphore.signal() + } + + semaphore.wait() + } + + /** + 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 + queueContinuation?.yield { + continuation.resume() + } + } + } +} + +/** +An array with read-write lock protection. +Ensures that multiple threads can safely read and write to the array at the same time. +*/ +final class AtomicSet { + private let lock = RWLock() + private var set: Set = [] + + func insert(_ newMember: T) { + lock.withWriteLock { + _ = set.insert(newMember) + } + } + + func remove(_ member: T) { + lock.withWriteLock { + _ = set.remove(member) + } + } + + func contains(_ member: T) -> Bool { + lock.withReadLock { + set.contains(member) + } + } + + func removeAll() { + lock.withWriteLock { + set.removeAll() + } + } +} + #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 0000000..e1653f0 --- /dev/null +++ b/Tests/DefaultsTests/Defaults+iCloudTests.swift @@ -0,0 +1,229 @@ +@testable import Defaults +import SwiftUI +import XCTest + +final class MockStorage: Defaults.KeyValueStore { + private var pairs: [String: Any] = [:] + + func object(forKey aKey: String) -> T? { + pairs[aKey] as? T + } + + func object(forKey aKey: String) -> Any? { + pairs[aKey] + } + + func set(_ anObject: Any?, forKey aKey: String) { + pairs[aKey] = anObject + } + + func removeObject(forKey aKey: String) { + pairs.removeValue(forKey: aKey) + } + + func removeAll() { + pairs.removeAll() + } + + func synchronize() -> Bool { + NotificationCenter.default.post(Notification(name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, userInfo: [NSUbiquitousKeyValueStoreChangedKeysKey: Array(pairs.keys)])) + return true + } +} + +private let mockStorage = MockStorage() + +@available(iOS 15, tvOS 15, watchOS 8, *) +final class DefaultsICloudTests: XCTestCase { + override class func setUp() { + Defaults.iCloud.debug = true + Defaults.iCloud.syncOnChange = true + Defaults.iCloud.default = Defaults.iCloud(remoteStorage: mockStorage) + } + + override func setUp() { + super.setUp() + Defaults.iCloud.removeAll() + mockStorage.removeAll() + Defaults.removeAll() + } + + override func tearDown() { + super.tearDown() + Defaults.iCloud.removeAll() + mockStorage.removeAll() + Defaults.removeAll() + } + + private func updateMockStorage(key: String, value: T, _ date: Date? = nil) { + mockStorage.set(value, forKey: key) + mockStorage.set(date ?? Date(), forKey: "__DEFAULTS__synchronizeTimestamp") + } + + func testICloudInitialize() async { + let name = Defaults.Key("testICloudInitialize_name", default: "0", iCloud: true) + let quality = Defaults.Key("testICloudInitialize_quality", default: 0.0, iCloud: true) + await Defaults.iCloud.sync() + XCTAssertEqual(mockStorage.object(forKey: name.name), "0") + XCTAssertEqual(mockStorage.object(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) + 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) + await Defaults.iCloud.sync() + + Defaults.iCloud.remove(quality) + Defaults[name] = "1" + Defaults[quality] = 1.0 + await Defaults.iCloud.sync() + XCTAssertEqual(mockStorage.object(forKey: name.name), "1") + XCTAssertEqual(mockStorage.object(forKey: quality.name), 0.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.syncKeys() + await Defaults.iCloud.sync() + } + await task.value + XCTAssertEqual(mockStorage.object(forKey: name.name), "0") + } + + func testICloudInitializeFromDetached() async { + let task = Task.detached { + let name = Defaults.Key("testICloudInitializeFromDetached_name", default: "0", iCloud: true) + await Defaults.iCloud.sync() + XCTAssertEqual(mockStorage.object(forKey: name.name), "0") + } + await task.value + } +} diff --git a/readme.md b/readme.md index 6e326e4..25db6f6 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:** You can easily synchronize data among instances of your app. ## Benefits over `@AppStorage` @@ -333,6 +334,35 @@ print(UserDefaults.standard.bool(forKey: Defaults.Keys.isUnicornMode.name)) > **Note** > A `Defaults.Key` with a dynamic default value will not register the default value in `UserDefaults`. +### Automatically synchronize data with iCloud + +You can create an automatically synchronizing `Defaults.Key` by setting the `iCloud` parameter to true. + +```swift +extension Defaults.Keys { + static let isUnicornMode = Key("isUnicornMode", default: true, iCloud: true) +} + +Task { + await Defaults.iCloud.sync() // Using sync to make sure all synchronization tasks are done. + print(NSUbiquitousKeyValueStore.default.bool(forKey: Defaults.Keys.isUnicornMode.name)) + //=> true +} +``` + +Also you can synchronize `Defaults.Key` manually, but make sure you select correct `source`. + +```swift +extension Defaults.Keys { + static let isUnicornMode = Key("isUnicornMode", default: true) +} + +Defaults.iCloud.syncKeys(.isUnicornMode, source: .local) // This will synchronize the value of the `isUnicornMode` key from the local source. +print(NSUbiquitousKeyValueStore.default.bool(forKey: Defaults.Keys.isUnicornMode.name)) +//=> true +``` + + ## API ### `Defaults` From c30d0c2196b6dd734d51acf786459a1b3880735c Mon Sep 17 00:00:00 2001 From: hank121314 Date: Wed, 21 Jun 2023 18:18:43 +0800 Subject: [PATCH 02/13] Refactor some code and fix some race conditions --- Sources/Defaults/Defaults+Protocol.swift | 11 + Sources/Defaults/Defaults+iCloud.swift | 436 ++++++++++-------- Sources/Defaults/Defaults.swift | 4 +- Sources/Defaults/Observation.swift | 109 ++++- Sources/Defaults/Utilities.swift | 156 ++++--- .../DefaultsTests/Defaults+iCloudTests.swift | 84 ++-- 6 files changed, 484 insertions(+), 316 deletions(-) diff --git a/Sources/Defaults/Defaults+Protocol.swift b/Sources/Defaults/Defaults+Protocol.swift index 2246039..172b36b 100644 --- a/Sources/Defaults/Defaults+Protocol.swift +++ b/Sources/Defaults/Defaults+Protocol.swift @@ -58,8 +58,19 @@ Essential properties for synchronizing a key value store. */ public 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() +} diff --git a/Sources/Defaults/Defaults+iCloud.swift b/Sources/Defaults/Defaults+iCloud.swift index 1645897..2f5f15c 100644 --- a/Sources/Defaults/Defaults+iCloud.swift +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -1,5 +1,10 @@ +#if canImport(OSLog) +import OSLog +#endif #if !os(macOS) import UIKit +#else +import AppKit #endif import Combine import Foundation @@ -19,20 +24,9 @@ private enum SyncStatus { } extension Defaults { - /** - Automatically synchronizing ``keys`` when they are changed. - */ - public final class iCloud: NSObject { - override init() { - self.remoteStorage = NSUbiquitousKeyValueStore.default - super.init() - registerNotifications() - remoteStorage.synchronize() - } - + public final class iCloudSynchronizer { init(remoteStorage: KeyValueStore) { self.remoteStorage = remoteStorage - super.init() registerNotifications() remoteStorage.synchronize() } @@ -41,10 +35,7 @@ extension Defaults { removeAll() } - /** - Set of keys which need to sync. - */ - private var keys: Set = [] + private var cancellables: Set = [] /** Key for recording the synchronization between `NSUbiquitousKeyValueStore` and `UserDefaults`. @@ -66,131 +57,154 @@ extension Defaults { */ private let backgroundQueue = TaskQueue(priority: .background) + /** + 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`. */ - private var atomicSet: AtomicSet = .init() + @Atomic(value: []) private var remoteSyncingKeys: Set + + // TODO: Replace it with async stream when Swift supports custom executors. + private lazy var localKeysMonitor: 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.backgroundQueue.async { + self.recordTimestamp(.local) + await self.syncKey(key, .local) + } + } /** Add new key and start to observe its changes. */ - private func add(_ keys: [Defaults.Keys]) { + func add(_ keys: [Defaults.Keys]) { self.keys.formUnion(keys) for key in keys { - addObserver(key) + localKeysMonitor.addObserver(key) } } /** Remove key and stop the observation. */ - private func remove(_ keys: [Defaults.Keys]) { + func remove(_ keys: [Defaults.Keys]) { self.keys.subtract(keys) for key in keys { - removeObserver(key) + localKeysMonitor.removeObserver(key) } } /** Remove all sync keys. */ - private func removeAll() { - for key in keys { - removeObserver(key) - } - keys.removeAll() - atomicSet.removeAll() + func removeAll() { + localKeysMonitor.invalidate() + _keys.modify { $0.removeAll() } + _remoteSyncingKeys.modify { $0.removeAll() } } /** Explicitly synchronizes in-memory keys and values with those stored on disk. */ - private func synchronize() { + func synchronize() { remoteStorage.synchronize() } /** - Synchronize the specified `keys` from the given `source`. + 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 keys: If the keys parameter is an empty array, the method will use the keys that were added to `Defaults.iCloudSynchronizer`. - Parameter source: Sync keys from which data source(remote or local). */ - private func syncKeys(_ keys: [Defaults.Keys] = [], _ source: DataSource? = nil) { + func syncWithoutWaiting(_ keys: [Defaults.Keys] = [], _ source: DataSource? = nil) { let keys = keys.isEmpty ? Array(self.keys) : keys let latest = source ?? latestDataSource() - backgroundQueue.sync { - for key in keys { + for key in keys { + backgroundQueue.async { await self.syncKey(key, latest) } } } /** - Synchronize the specified `key` from the given `source`. + Wait until all synchronization tasks in `backgroundQueue` are complete. + */ + func sync() async { + await backgroundQueue.flush() + } + + /** + Create synchronization tasks for the specified `keys` 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: DataSource) async { - Self.logKeySyncStatus(key, source, .start) - atomicSet.insert(key) - await withCheckedContinuation { continuation in - let completion = { - continuation.resume() - } - switch source { - case .remote: - syncFromRemote(key: key, completion) - recordTimestamp(.local) - case .local: - syncFromLocal(key: key, completion) - recordTimestamp(.remote) - } + Self.logKeySyncStatus(key, source: source, syncStatus: .start) + switch source { + case .remote: + await syncFromRemote(key: key) + recordTimestamp(.local) + case .local: + syncFromLocal(key: key) + recordTimestamp(.remote) } - Self.logKeySyncStatus(key, source, .finish) - atomicSet.remove(key) + Self.logKeySyncStatus(key, source: source, syncStatus: .finish) } /** Only update the value if it can be retrieved from the remote storage. */ - private func syncFromRemote(key: Defaults.Keys, _ completion: @escaping () -> Void) { - guard let value = remoteStorage.object(forKey: key.name) else { - completion() - return - } + private func syncFromRemote(key: Defaults.Keys) async { + _remoteSyncingKeys.modify { $0.insert(key) } + await withCheckedContinuation { continuation in + guard let value = remoteStorage.object(forKey: key.name) else { + continuation.resume() + return + } - Task { @MainActor in - Defaults.iCloud.logKeySyncStatus(key, .remote, .isSyncing, value) - key.suite.set(value, forKey: key.name) - completion() + Task { @MainActor in + Self.logKeySyncStatus(key, source: .remote, syncStatus: .isSyncing, value: value) + key.suite.set(value, forKey: key.name) + 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(key: Defaults.Keys, _ completion: @escaping () -> Void) { + private func syncFromLocal(key: Defaults.Keys) { guard let value = key.suite.object(forKey: key.name) else { - Defaults.iCloud.logKeySyncStatus(key, .local, .isSyncing, nil) + Self.logKeySyncStatus(key, source: .local, syncStatus: .isSyncing, value: nil) remoteStorage.removeObject(forKey: key.name) syncRemoteStorageOnChange() - completion() return } - Defaults.iCloud.logKeySyncStatus(key, .local, .isSyncing, value) + Self.logKeySyncStatus(key, source: .local, syncStatus: .isSyncing, value: value) remoteStorage.set(value, forKey: key.name) syncRemoteStorageOnChange() - completion() } /** Explicitly synchronizes in-memory keys and values when a value is changed. */ private func syncRemoteStorageOnChange() { - if Self.syncOnChange { + if Defaults.iCloud.syncOnChange { synchronize() } } @@ -219,108 +233,52 @@ extension Defaults { return .remote } - return localTimestamp.timeIntervalSince1970 > remoteTimestamp.timeIntervalSince1970 ? .local : .remote + return localTimestamp > remoteTimestamp ? .local : .remote } } } -extension Defaults.iCloud { - /** - The singleton for Defaults's iCloud. - */ - static var `default` = Defaults.iCloud() - - /** - Lists the synced keys. - */ - public static let keys = `default`.keys - - /** - Enable this if you want to call `NSUbiquitousKeyValueStore.synchronize` when value is changed. - */ - public static var syncOnChange = false - - /** - Enable this if you want to debug the syncing status of keys. - */ - public static var debug = false - - /** - Add keys to be automatically synced. - */ - public static func add(_ keys: Defaults.Keys...) { - `default`.add(keys) - } - - /** - Remove keys to be automatically synced. - */ - public static func remove(_ keys: Defaults.Keys...) { - `default`.remove(keys) - } - - /** - Remove all keys to be automatically synced. - */ - public static func removeAll() { - `default`.removeAll() - } - - /** - Explicitly synchronizes in-memory keys and values with those stored on disk. - */ - public static func sync() { - `default`.synchronize() - } - - /** - Wait until all synchronization tasks are complete and explicitly synchronizes in-memory keys and values with those stored on disk. - */ - public static func sync() async { - await `default`.backgroundQueue.flush() - `default`.synchronize() - } - - /** - Synchronize all of the keys that have been added to Defaults.iCloud. - */ - public static func syncKeys() { - `default`.syncKeys() - } - - /** - Synchronize the specified `keys` from the given `source`, which could be 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 specify if `key` has not been added to `Defaults.iCloud`. - */ - public static func syncKeys(_ keys: Defaults.Keys..., source: DataSource? = nil) { - `default`.syncKeys(keys, source) - } -} - /** -`Defaults.iCloud` notification related functions. +`Defaults.iCloudSynchronizer` notification related functions. */ -extension Defaults.iCloud { +extension Defaults.iCloudSynchronizer { private func registerNotifications() { - NotificationCenter.default.addObserver(self, selector: #selector(didChangeExternally(notification:)), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: nil) + // 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) - NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(notification:)), name: UIScene.willEnterForegroundNotification, object: nil) + NotificationCenter.default + .publisher(for: UIScene.willEnterForegroundNotification) + #elseif os(watchOS) + NotificationCenter.default + .publisher(for: WKExtension.applicationWillEnterForegroundNotification) #endif - #if os(watchOS) - NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(notification:)), name: WKExtension.applicationWillEnterForegroundNotification, object: nil) + #if os(iOS) || os(tvOS) || os(watchOS) + .sink { [weak self] notification in + guard let self else { + return + } + + self.willEnterForeground(notification: notification) + } + .store(in: cancellables) #endif } - @objc private func willEnterForeground(notification: Notification) { remoteStorage.synchronize() } - @objc private func didChangeExternally(notification: Notification) { guard notification.name == NSUbiquitousKeyValueStore.didChangeExternallyNotification else { return @@ -342,60 +300,22 @@ extension Defaults.iCloud { } for key in self.keys where changedKeys.contains(key.name) { - backgroundQueue.sync { + backgroundQueue.async { await self.syncKey(key, .remote) } } } } -/** -`Defaults.iCloud` observation related functions. -*/ -extension Defaults.iCloud { - private func addObserver(_ key: Defaults.Keys) { - backgroundQueue.sync { - key.suite.addObserver(self, forKeyPath: key.name, options: [.new], context: nil) - } - } - - private func removeObserver(_ key: Defaults.Keys) { - backgroundQueue.sync { - key.suite.removeObserver(self, forKeyPath: key.name, context: nil) - } - } - - @_documentation(visibility: private) - // swiftlint:disable:next block_based_kvo - override public func observeValue( - forKeyPath keyPath: String?, - of object: Any?, - change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection - context: UnsafeMutableRawPointer? - ) { - guard - let keyPath, - let object, - object is UserDefaults, - let key = keys.first(where: { $0.name == keyPath }), - !atomicSet.contains(key) - else { - return - } - - backgroundQueue.async { - self.recordTimestamp(.local) - await self.syncKey(key, .local) - } - } -} - /** `Defaults.iCloud` logging related functions. */ -extension Defaults.iCloud { - private static func logKeySyncStatus(_ key: Defaults.Keys, _ source: DataSource, _ syncStatus: SyncStatus, _ value: Any? = nil) { - guard Self.debug else { +extension Defaults.iCloudSynchronizer { + @available(macOS 11, iOS 14, tvOS 14, watchOS 7, *) + private static let logger = Logger(OSLog.default) + + private static func logKeySyncStatus(_ key: Defaults.Keys, source: DataSource, syncStatus: SyncStatus, value: Any? = nil) { + guard Defaults.iCloud.isDebug else { return } var destination: String @@ -406,27 +326,137 @@ extension Defaults.iCloud { destination = "from remote" } var status: String - var valueDescription = "" + var valueDescription = " " switch syncStatus { case .start: status = "Start synchronization" case .isSyncing: status = "Synchronizing" - valueDescription = "with value '\(value ?? "nil")'" + valueDescription = " with value \(value ?? "nil") " case .finish: status = "Finish synchronization" } - let message = "\(status) key '\(key.name)' \(valueDescription) \(destination)" + let message = "\(status) key '\(key.name)'\(valueDescription)\(destination)" log(message) } private static func log(_ message: String) { - guard Self.debug else { + guard Defaults.iCloud.isDebug else { return } - let formatter = DateFormatter() - formatter.dateFormat = "y/MM/dd H:mm:ss.SSSS" - print("[\(formatter.string(from: Date()))] DEBUG(Defaults) - \(message)") + + if #available(macOS 11, iOS 14, tvOS 14, watchOS 7, *) { + 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 { + /** + Automatically create synchronization tasks when the added `keys` undergo changed. + + The synchronization task will be created in three different ways. + + 1. UserDefaults changes. + 2. Receive `NSUbiquitousKeyValueStore.didChangeExternallyNotification`. + 3. Call `syncWithoutWaiting()`. + + > Tip: Using `await sync()` to make sure all synchronization tasks are done. + + ```swift + let quality = Defaults.Key("quality", default: 0, iCloud: true) + Defaults[quality] = 1 + await Defaults.iCloud.sync() + print(NSUbiquitousKeyValueStore.default.object(forKey: quality.name)) //=> 1 + ``` + */ + public enum iCloud { + /** + The singleton for Defaults's iCloudSynchronizer. + */ + static var shared = Defaults.iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) + + /** + Lists the synced keys. + */ + public static let keys = shared.keys + + /** + Enable this if you want to call `NSUbiquitousKeyValueStore.synchronize` when a value is changed. + */ + public static var syncOnChange = false + + /** + Enable this if you want to debug the syncing status of keys. + */ + public static var isDebug = false + + /** + Add the keys to be automatically synced. + */ + public static func add(_ keys: Defaults.Keys...) { + shared.add(keys) + } + + /** + Remove the keys that are set to be automatically synced. + */ + public static func remove(_ keys: Defaults.Keys...) { + shared.remove(keys) + } + + /** + Remove all keys that are set to be automatically synced. + */ + public static func removeAll() { + shared.removeAll() + } + + /** + Explicitly synchronizes in-memory keys and values with those stored on disk. + */ + public static func synchronize() { + shared.synchronize() + } + + /** + Wait until all synchronization tasks are complete. + */ + public static func sync() async { + await shared.sync() + } + + /** + Create synchronization tasks for all the keys that have been added to the `Defaults.iCloud`. + */ + public static func syncWithoutWaiting() { + shared.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 specify if `key` has not been added to `Defaults.iCloud`. + */ + public static func syncWithoutWaiting(_ keys: Defaults.Keys..., source: DataSource? = nil) { + shared.syncWithoutWaiting(keys, source) + } } } diff --git a/Sources/Defaults/Defaults.swift b/Sources/Defaults/Defaults.swift index 710ab93..648b6bd 100644 --- a/Sources/Defaults/Defaults.swift +++ b/Sources/Defaults/Defaults.swift @@ -307,7 +307,9 @@ extension Defaults { public typealias RangeSerializable = _DefaultsRange & _DefaultsSerializable - public typealias KeyValueStore = _DefaultsKeyValueStore + typealias KeyValueStore = _DefaultsKeyValueStore + + typealias LockProtocol = _DefaultsLockProtocol /** Convenience protocol for `Codable`. diff --git a/Sources/Defaults/Observation.swift b/Sources/Defaults/Observation.swift index fff41e8..dc06578 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/Utilities.swift b/Sources/Defaults/Utilities.swift index 9245825..e7e7c42 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,55 +235,63 @@ extension Defaults.Serializable { } } -/** -A reader/writer threading lock based on `libpthread`. -*/ -final class RWLock { - private let lock: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: 1) +extension AsyncStream { + public static func makeStream( + _ elementType: Element.Type = Element.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) -> (stream: Self, continuation: Continuation?) { + var continuation: Continuation? + return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) + } +} - init() { - let err = pthread_rwlock_init(lock, nil) - precondition(err == 0, "\(#function) failed in pthread_rwlock_init with error \(err)") - } +// swiftlint:disable:next final_class +class Lock: Defaults.LockProtocol { + final class UnfairLock: Lock { + private let _lock: os_unfair_lock_t - deinit { - let err = pthread_rwlock_destroy(lock) - precondition(err == 0, "\(#function) failed in pthread_rwlock_destroy with error \(err)") - lock.deallocate() - } + override init() { + _lock = .allocate(capacity: 1) + _lock.initialize(to: os_unfair_lock()) + } - private func lockRead() { - let err = pthread_rwlock_rdlock(lock) - precondition(err == 0, "\(#function) failed in pthread_rwlock_rdlock with error \(err)") - } + override func lock() { + os_unfair_lock_lock(_lock) + } - private func lockWrite() { - let err = pthread_rwlock_wrlock(lock) - precondition(err == 0, "\(#function) failed in pthread_rwlock_wrlock with error \(err)") + override func unlock() { + os_unfair_lock_unlock(_lock) + } } - private func unlock() { - let err = pthread_rwlock_unlock(lock) - precondition(err == 0, "\(#function) failed in pthread_rwlock_unlock with error \(err)") - } + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + final class AllocatedUnfairLock: Lock { + private let _lock = OSAllocatedUnfairLock() + + override init() { + super.init() + } - @inlinable - func withReadLock(body: () -> R) -> R { - lockRead() - defer { - unlock() + override func lock() { + _lock.lock() + } + + override func unlock() { + _lock.unlock() } - return body() } - @inlinable - func withWriteLock(body: () -> R) -> R { - lockWrite() - defer { - unlock() + static func make() -> Self { + guard #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) else { + return UnfairLock() as! Self } - return body() + + return AllocatedUnfairLock() as! Self } + + private init() {} + func lock() {} + func unlock() {} } /** @@ -312,9 +321,11 @@ queue.async { final class TaskQueue { typealias AsyncTask = @Sendable () async -> Void private var queueContinuation: AsyncStream.Continuation? + private let lock: Lock = .make() init(priority: TaskPriority? = nil) { - let taskStream = AsyncStream { queueContinuation = $0 } + let (taskStream, queueContinuation) = AsyncStream.makeStream() + self.queueContinuation = queueContinuation Task.detached(priority: priority) { for await task in taskStream { @@ -331,21 +342,9 @@ final class TaskQueue { Queue a new asynchronous task. */ func async(_ task: @escaping AsyncTask) { + lock.lock() queueContinuation?.yield(task) - } - - /** - Queue a new asynchronous task and wait until it done. - */ - func sync(_ task: @escaping AsyncTask) { - let semaphore = DispatchSemaphore(value: 0) - - queueContinuation?.yield { - await task() - semaphore.signal() - } - - semaphore.wait() + lock.unlock() } /** @@ -367,42 +366,53 @@ final class TaskQueue { */ func flush() async { await withCheckedContinuation { continuation in + lock.lock() queueContinuation?.yield { continuation.resume() } + lock.unlock() } } } -/** -An array with read-write lock protection. -Ensures that multiple threads can safely read and write to the array at the same time. -*/ -final class AtomicSet { - private let lock = RWLock() - private var set: Set = [] +@propertyWrapper +final class Atomic { + private let lock: Lock = .make() + private var _value: Value - func insert(_ newMember: T) { - lock.withWriteLock { - _ = set.insert(newMember) + var wrappedValue: Value { + get { + withValue { $0 } + } + set { + swap(newValue) } } - func remove(_ member: T) { - lock.withWriteLock { - _ = set.remove(member) - } + init(value: Value) { + self._value = value } - func contains(_ member: T) -> Bool { - lock.withReadLock { - set.contains(member) - } + @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) } - func removeAll() { - lock.withWriteLock { - set.removeAll() + @discardableResult + func swap(_ newValue: Value) -> Value { + modify { (value: inout Value) in + let oldValue = value + value = newValue + return oldValue } } } diff --git a/Tests/DefaultsTests/Defaults+iCloudTests.swift b/Tests/DefaultsTests/Defaults+iCloudTests.swift index e1653f0..6e769b1 100644 --- a/Tests/DefaultsTests/Defaults+iCloudTests.swift +++ b/Tests/DefaultsTests/Defaults+iCloudTests.swift @@ -4,29 +4,44 @@ import XCTest final class MockStorage: Defaults.KeyValueStore { private var pairs: [String: Any] = [:] + private let queue = DispatchQueue(label: "a") func object(forKey aKey: String) -> T? { - pairs[aKey] as? T + queue.sync { + pairs[aKey] as? T + } } func object(forKey aKey: String) -> Any? { - pairs[aKey] + queue.sync { + pairs[aKey] + } } func set(_ anObject: Any?, forKey aKey: String) { - pairs[aKey] = anObject + queue.sync { + pairs[aKey] = anObject + } } func removeObject(forKey aKey: String) { - pairs.removeValue(forKey: aKey) + _ = queue.sync { + pairs.removeValue(forKey: aKey) + } } func removeAll() { - pairs.removeAll() + queue.sync { + pairs.removeAll() + } } + @discardableResult func synchronize() -> Bool { - NotificationCenter.default.post(Notification(name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, userInfo: [NSUbiquitousKeyValueStoreChangedKeysKey: Array(pairs.keys)])) + let pairs = queue.sync { + Array(self.pairs.keys) + } + NotificationCenter.default.post(Notification(name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, userInfo: [NSUbiquitousKeyValueStoreChangedKeysKey: pairs])) return true } } @@ -36,22 +51,21 @@ private let mockStorage = MockStorage() @available(iOS 15, tvOS 15, watchOS 8, *) final class DefaultsICloudTests: XCTestCase { override class func setUp() { - Defaults.iCloud.debug = true - Defaults.iCloud.syncOnChange = true - Defaults.iCloud.default = Defaults.iCloud(remoteStorage: mockStorage) + Defaults.iCloud.isDebug = true + Defaults.iCloud.shared = Defaults.iCloudSynchronizer(remoteStorage: mockStorage) } override func setUp() { super.setUp() - Defaults.iCloud.removeAll() mockStorage.removeAll() + Defaults.iCloud.removeAll() Defaults.removeAll() } override func tearDown() { super.tearDown() - Defaults.iCloud.removeAll() mockStorage.removeAll() + Defaults.iCloud.removeAll() Defaults.removeAll() } @@ -79,7 +93,8 @@ final class DefaultsICloudTests: XCTestCase { updateMockStorage(key: quality.name, value: 8.0) updateMockStorage(key: name.name, value: "8") - _ = mockStorage.synchronize() + mockStorage.synchronize() + await Defaults.iCloud.sync() XCTAssertEqual(Defaults[quality], 8.0) XCTAssertEqual(Defaults[name], "8") @@ -89,12 +104,10 @@ final class DefaultsICloudTests: XCTestCase { XCTAssertEqual(mockStorage.object(forKey: name.name), "9") XCTAssertEqual(mockStorage.object(forKey: quality.name), 9.0) - Defaults[name] = "10" - Defaults[quality] = 10.0 + mockStorage.set("10", forKey: name.name) + mockStorage.set(10.0, forKey: quality.name) + mockStorage.synchronize() await Defaults.iCloud.sync() - mockStorage.set("11", forKey: name.name) - mockStorage.set(11.0, forKey: quality.name) - _ = mockStorage.synchronize() XCTAssertEqual(Defaults[quality], 10.0) XCTAssertEqual(Defaults[name], "10") } @@ -108,10 +121,11 @@ final class DefaultsICloudTests: XCTestCase { 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.object(forKey: name.name), "1") + XCTAssertEqual(mockStorage.object(forKey: quality.name), 1.0) Defaults.iCloud.remove(quality) - Defaults[name] = "1" + Defaults[name] = "2" Defaults[quality] = 1.0 await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), "1") - XCTAssertEqual(mockStorage.object(forKey: quality.name), 0.0) + XCTAssertEqual(mockStorage.object(forKey: name.name), "2") + XCTAssertEqual(mockStorage.object(forKey: quality.name), 1.0) } func testSyncKeysFromLocal() async { @@ -162,18 +180,20 @@ final class DefaultsICloudTests: XCTestCase { 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] - + NSUbiquitousKeyValueStore.default.object(forKey: quality.name) for index in 0..("testInitAddFromDetached_name", default: "0") let task = Task.detached { Defaults.iCloud.add(name) - Defaults.iCloud.syncKeys() + Defaults.iCloud.syncWithoutWaiting() await Defaults.iCloud.sync() } await task.value XCTAssertEqual(mockStorage.object(forKey: name.name), "0") + Defaults[name] = "1" + await Defaults.iCloud.sync() + XCTAssertEqual(mockStorage.object(forKey: name.name), "1") } func testICloudInitializeFromDetached() async { From bdc9ab4696157ded2643a88249c59464d83247fa Mon Sep 17 00:00:00 2001 From: hank121314 Date: Tue, 14 Nov 2023 12:22:55 +0800 Subject: [PATCH 03/13] resolve comments and fix ambiguous issue when targeting swift 5.9 --- Sources/Defaults/Defaults+Extensions.swift | 11 +++++ Sources/Defaults/Defaults+Protocol.swift | 2 + Sources/Defaults/Defaults+iCloud.swift | 48 ++++++++++--------- Sources/Defaults/Utilities.swift | 27 ++++------- .../DefaultsTests/Defaults+iCloudTests.swift | 2 +- 5 files changed, 48 insertions(+), 42 deletions(-) diff --git a/Sources/Defaults/Defaults+Extensions.swift b/Sources/Defaults/Defaults+Extensions.swift index 9ce5b86..38a0e42 100644 --- a/Sources/Defaults/Defaults+Extensions.swift +++ b/Sources/Defaults/Defaults+Extensions.swift @@ -167,3 +167,14 @@ extension UIColor: Defaults.Serializable {} extension NSUbiquitousKeyValueStore: Defaults.KeyValueStore {} extension UserDefaults: Defaults.KeyValueStore {} + +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 172b36b..9a15f06 100644 --- a/Sources/Defaults/Defaults+Protocol.swift +++ b/Sources/Defaults/Defaults+Protocol.swift @@ -73,4 +73,6 @@ protocol _DefaultsLockProtocol { 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 index 2f5f15c..c83b3f5 100644 --- a/Sources/Defaults/Defaults+iCloud.swift +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -9,7 +9,9 @@ import AppKit import Combine import Foundation -/// Represent different data sources available for synchronization. +/** +Represent different data sources available for synchronization. +*/ public enum DataSource { /// Using `key.suite` as data source. case local @@ -18,9 +20,9 @@ public enum DataSource { } private enum SyncStatus { - case start - case isSyncing - case finish + case idle + case syncing + case completed } extension Defaults { @@ -152,7 +154,7 @@ extension Defaults { - Parameter source: Sync key from which data source(remote or local). */ private func syncKey(_ key: Defaults.Keys, _ source: DataSource) async { - Self.logKeySyncStatus(key, source: source, syncStatus: .start) + Self.logKeySyncStatus(key, source: source, syncStatus: .idle) switch source { case .remote: await syncFromRemote(key: key) @@ -161,7 +163,7 @@ extension Defaults { syncFromLocal(key: key) recordTimestamp(.remote) } - Self.logKeySyncStatus(key, source: source, syncStatus: .finish) + Self.logKeySyncStatus(key, source: source, syncStatus: .completed) } /** @@ -176,7 +178,7 @@ extension Defaults { } Task { @MainActor in - Self.logKeySyncStatus(key, source: .remote, syncStatus: .isSyncing, value: value) + Self.logKeySyncStatus(key, source: .remote, syncStatus: .syncing, value: value) key.suite.set(value, forKey: key.name) continuation.resume() } @@ -189,13 +191,13 @@ extension Defaults { */ private func syncFromLocal(key: Defaults.Keys) { guard let value = key.suite.object(forKey: key.name) else { - Self.logKeySyncStatus(key, source: .local, syncStatus: .isSyncing, value: nil) + Self.logKeySyncStatus(key, source: .local, syncStatus: .syncing, value: nil) remoteStorage.removeObject(forKey: key.name) syncRemoteStorageOnChange() return } - Self.logKeySyncStatus(key, source: .local, syncStatus: .isSyncing, value: value) + Self.logKeySyncStatus(key, source: .local, syncStatus: .syncing, value: value) remoteStorage.set(value, forKey: key.name) syncRemoteStorageOnChange() } @@ -328,13 +330,13 @@ extension Defaults.iCloudSynchronizer { var status: String var valueDescription = " " switch syncStatus { - case .start: - status = "Start synchronization" - case .isSyncing: + case .idle: + status = "Try synchronizing" + case .syncing: status = "Synchronizing" valueDescription = " with value \(value ?? "nil") " - case .finish: - status = "Finish synchronization" + case .completed: + status = "Complete synchronization" } let message = "\(status) key '\(key.name)'\(valueDescription)\(destination)" @@ -388,12 +390,12 @@ extension Defaults { /** The singleton for Defaults's iCloudSynchronizer. */ - static var shared = Defaults.iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) + static var synchronizer = Defaults.iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) /** Lists the synced keys. */ - public static let keys = shared.keys + public static let keys = synchronizer.keys /** Enable this if you want to call `NSUbiquitousKeyValueStore.synchronize` when a value is changed. @@ -409,42 +411,42 @@ extension Defaults { Add the keys to be automatically synced. */ public static func add(_ keys: Defaults.Keys...) { - shared.add(keys) + synchronizer.add(keys) } /** Remove the keys that are set to be automatically synced. */ public static func remove(_ keys: Defaults.Keys...) { - shared.remove(keys) + synchronizer.remove(keys) } /** Remove all keys that are set to be automatically synced. */ public static func removeAll() { - shared.removeAll() + synchronizer.removeAll() } /** Explicitly synchronizes in-memory keys and values with those stored on disk. */ public static func synchronize() { - shared.synchronize() + synchronizer.synchronize() } /** Wait until all synchronization tasks are complete. */ public static func sync() async { - await shared.sync() + await synchronizer.sync() } /** Create synchronization tasks for all the keys that have been added to the `Defaults.iCloud`. */ public static func syncWithoutWaiting() { - shared.syncWithoutWaiting() + synchronizer.syncWithoutWaiting() } /** @@ -456,7 +458,7 @@ extension Defaults { - Note: `source` should be specify if `key` has not been added to `Defaults.iCloud`. */ public static func syncWithoutWaiting(_ keys: Defaults.Keys..., source: DataSource? = nil) { - shared.syncWithoutWaiting(keys, source) + synchronizer.syncWithoutWaiting(keys, source) } } } diff --git a/Sources/Defaults/Utilities.swift b/Sources/Defaults/Utilities.swift index e7e7c42..9109cad 100644 --- a/Sources/Defaults/Utilities.swift +++ b/Sources/Defaults/Utilities.swift @@ -235,16 +235,6 @@ extension Defaults.Serializable { } } -extension AsyncStream { - public static func makeStream( - _ elementType: Element.Type = Element.self, - bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded - ) -> (stream: Self, continuation: Continuation?) { - var continuation: Continuation? - return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) - } -} - // swiftlint:disable:next final_class class Lock: Defaults.LockProtocol { final class UnfairLock: Lock { @@ -324,7 +314,7 @@ final class TaskQueue { private let lock: Lock = .make() init(priority: TaskPriority? = nil) { - let (taskStream, queueContinuation) = AsyncStream.makeStream() + let (taskStream, queueContinuation) = AsyncStream.makeStream() self.queueContinuation = queueContinuation Task.detached(priority: priority) { @@ -342,9 +332,9 @@ final class TaskQueue { Queue a new asynchronous task. */ func async(_ task: @escaping AsyncTask) { - lock.lock() - queueContinuation?.yield(task) - lock.unlock() + lock.with { + queueContinuation?.yield(task) + } } /** @@ -366,11 +356,12 @@ final class TaskQueue { */ func flush() async { await withCheckedContinuation { continuation in - lock.lock() - queueContinuation?.yield { - continuation.resume() + lock.with { + queueContinuation?.yield { + continuation.resume() + } + return } - lock.unlock() } } } diff --git a/Tests/DefaultsTests/Defaults+iCloudTests.swift b/Tests/DefaultsTests/Defaults+iCloudTests.swift index 6e769b1..f0531df 100644 --- a/Tests/DefaultsTests/Defaults+iCloudTests.swift +++ b/Tests/DefaultsTests/Defaults+iCloudTests.swift @@ -52,7 +52,7 @@ private let mockStorage = MockStorage() final class DefaultsICloudTests: XCTestCase { override class func setUp() { Defaults.iCloud.isDebug = true - Defaults.iCloud.shared = Defaults.iCloudSynchronizer(remoteStorage: mockStorage) + Defaults.iCloud.synchronizer = Defaults.iCloudSynchronizer(remoteStorage: mockStorage) } override func setUp() { From e79ec465dfd61b02e6597971e5cc36411314c34f Mon Sep 17 00:00:00 2001 From: hank121314 Date: Fri, 29 Dec 2023 21:54:20 +0800 Subject: [PATCH 04/13] fix linter warnings --- Sources/Defaults/Defaults+Extensions.swift | 2 +- Sources/Defaults/Defaults+Protocol.swift | 2 +- Sources/Defaults/Defaults+iCloud.swift | 22 +++++++++++----------- Sources/Defaults/Defaults.swift | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/Defaults/Defaults+Extensions.swift b/Sources/Defaults/Defaults+Extensions.swift index 38a0e42..7e7d734 100644 --- a/Sources/Defaults/Defaults+Extensions.swift +++ b/Sources/Defaults/Defaults+Extensions.swift @@ -170,7 +170,7 @@ extension UserDefaults: Defaults.KeyValueStore {} extension _DefaultsLockProtocol { @discardableResult - func with(_ body: @Sendable () throws -> R) rethrows -> R where R : Sendable { + func with(_ body: @Sendable () throws -> R) rethrows -> R where R: Sendable { self.lock() defer { self.unlock() diff --git a/Sources/Defaults/Defaults+Protocol.swift b/Sources/Defaults/Defaults+Protocol.swift index 9a15f06..d97fc35 100644 --- a/Sources/Defaults/Defaults+Protocol.swift +++ b/Sources/Defaults/Defaults+Protocol.swift @@ -74,5 +74,5 @@ protocol _DefaultsLockProtocol { func unlock() - func with(_ body: @Sendable () throws -> R) rethrows -> R where R : Sendable + 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 index c83b3f5..f7b27d3 100644 --- a/Sources/Defaults/Defaults+iCloud.swift +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -22,7 +22,7 @@ public enum DataSource { private enum SyncStatus { case idle case syncing - case completed + case completed } extension Defaults { @@ -154,7 +154,7 @@ extension Defaults { - Parameter source: Sync key from which data source(remote or local). */ private func syncKey(_ key: Defaults.Keys, _ source: DataSource) async { - Self.logKeySyncStatus(key, source: source, syncStatus: .idle) + Self.logKeySyncStatus(key, source: source, syncStatus: .idle) switch source { case .remote: await syncFromRemote(key: key) @@ -330,9 +330,9 @@ extension Defaults.iCloudSynchronizer { var status: String var valueDescription = " " switch syncStatus { - case .idle: - status = "Try synchronizing" - case .syncing: + case .idle: + status = "Try synchronizing" + case .syncing: status = "Synchronizing" valueDescription = " with value \(value ?? "nil") " case .completed: @@ -411,28 +411,28 @@ extension Defaults { Add the keys to be automatically synced. */ public static func add(_ keys: Defaults.Keys...) { - synchronizer.add(keys) + synchronizer.add(keys) } /** Remove the keys that are set to be automatically synced. */ public static func remove(_ keys: Defaults.Keys...) { - synchronizer.remove(keys) + synchronizer.remove(keys) } /** Remove all keys that are set to be automatically synced. */ public static func removeAll() { - synchronizer.removeAll() + synchronizer.removeAll() } /** Explicitly synchronizes in-memory keys and values with those stored on disk. */ public static func synchronize() { - synchronizer.synchronize() + synchronizer.synchronize() } /** @@ -446,7 +446,7 @@ extension Defaults { Create synchronization tasks for all the keys that have been added to the `Defaults.iCloud`. */ public static func syncWithoutWaiting() { - synchronizer.syncWithoutWaiting() + synchronizer.syncWithoutWaiting() } /** @@ -458,7 +458,7 @@ extension Defaults { - Note: `source` should be specify if `key` has not been added to `Defaults.iCloud`. */ public static func syncWithoutWaiting(_ keys: Defaults.Keys..., source: DataSource? = nil) { - synchronizer.syncWithoutWaiting(keys, source) + synchronizer.syncWithoutWaiting(keys, source) } } } diff --git a/Sources/Defaults/Defaults.swift b/Sources/Defaults/Defaults.swift index 648b6bd..3525b8c 100644 --- a/Sources/Defaults/Defaults.swift +++ b/Sources/Defaults/Defaults.swift @@ -116,7 +116,7 @@ extension Defaults { self.defaultValueGetter = { defaultValue } super.init(name: name, suite: suite) - + if iCloud { Defaults.iCloud.add(self) } From 3c27bb01afd71fe76a4ba11694fe36ac4dfdf13c Mon Sep 17 00:00:00 2001 From: hank121314 Date: Fri, 29 Dec 2023 21:54:31 +0800 Subject: [PATCH 05/13] try to fix github action failure --- Tests/DefaultsTests/Defaults+iCloudTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Tests/DefaultsTests/Defaults+iCloudTests.swift b/Tests/DefaultsTests/Defaults+iCloudTests.swift index f0531df..cbaebca 100644 --- a/Tests/DefaultsTests/Defaults+iCloudTests.swift +++ b/Tests/DefaultsTests/Defaults+iCloudTests.swift @@ -77,6 +77,8 @@ final class DefaultsICloudTests: XCTestCase { func testICloudInitialize() async { let name = Defaults.Key("testICloudInitialize_name", default: "0", iCloud: true) let quality = Defaults.Key("testICloudInitialize_quality", default: 0.0, iCloud: true) + // Not sure why github action will not trigger a observation callback after initialization, cannot reproduce in local. + Defaults.iCloud.syncWithoutWaiting() await Defaults.iCloud.sync() XCTAssertEqual(mockStorage.object(forKey: name.name), "0") XCTAssertEqual(mockStorage.object(forKey: quality.name), 0.0) @@ -180,7 +182,7 @@ final class DefaultsICloudTests: XCTestCase { 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] - NSUbiquitousKeyValueStore.default.object(forKey: quality.name) + for index in 0..("testICloudInitializeFromDetached_name", default: "0", iCloud: true) + // Not sure why github action will not trigger a observation callback after initialization, cannot reproduce in local. + Defaults.iCloud.syncWithoutWaiting() await Defaults.iCloud.sync() XCTAssertEqual(mockStorage.object(forKey: name.name), "0") } From 3bd78e26879c9e55286c9159d7f9404ae7797585 Mon Sep 17 00:00:00 2001 From: hank121314 Date: Sun, 3 Mar 2024 16:07:05 +0800 Subject: [PATCH 06/13] feat: minor improvement --- Sources/Defaults/Defaults+iCloud.swift | 163 +++++++++++++----- Sources/Defaults/Defaults.swift | 10 +- .../DefaultsTests/Defaults+iCloudTests.swift | 78 +++++---- 3 files changed, 169 insertions(+), 82 deletions(-) diff --git a/Sources/Defaults/Defaults+iCloud.swift b/Sources/Defaults/Defaults+iCloud.swift index f7b27d3..16977e4 100644 --- a/Sources/Defaults/Defaults+iCloud.swift +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -26,6 +26,15 @@ private enum SyncStatus { } extension Defaults { + /** + The supervisor for managing `Defaults.Keys` between `key.suite` and `remoteStorage`. + + Depends on the storage, `Defaults.Keys` will represent in different form due to storage limitations. + + 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. + */ public final class iCloudSynchronizer { init(remoteStorage: KeyValueStore) { self.remoteStorage = remoteStorage @@ -37,6 +46,8 @@ extension Defaults { removeAll() } + @TaskLocal static var timestamp: Date? + private var cancellables: Set = [] /** @@ -49,11 +60,6 @@ extension Defaults { */ private var remoteStorage: KeyValueStore - /** - A local storage responsible for recording synchronization timestamp. - */ - private let localStorage: KeyValueStore = UserDefaults.standard - /** A FIFO queue used to serialize synchronization on keys. */ @@ -81,9 +87,9 @@ extension Defaults { return } - self.backgroundQueue.async { - self.recordTimestamp(.local) - await self.syncKey(key, .local) + self.enqueue { + self.recordTimestamp(forKey: key, timestamp: Self.timestamp, source: .local) + await self.syncKey(forKey: key, .local) } } @@ -92,6 +98,7 @@ extension Defaults { */ func add(_ keys: [Defaults.Keys]) { self.keys.formUnion(keys) + self.syncWithoutWaiting(keys) for key in keys { localKeysMonitor.addObserver(key) } @@ -131,11 +138,11 @@ extension Defaults { */ func syncWithoutWaiting(_ keys: [Defaults.Keys] = [], _ source: DataSource? = nil) { let keys = keys.isEmpty ? Array(self.keys) : keys - let latest = source ?? latestDataSource() for key in keys { - backgroundQueue.async { - await self.syncKey(key, latest) + let latest = source ?? latestDataSource(forKey: key) + self.enqueue { + await self.syncKey(forKey: key, latest) } } } @@ -148,20 +155,29 @@ extension Defaults { } /** - Create synchronization tasks for the specified `keys` from the given source. + 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 forKey: The key to synchronize. - Parameter source: Sync key from which data source(remote or local). */ - private func syncKey(_ key: Defaults.Keys, _ source: DataSource) async { + private func syncKey(forKey key: Defaults.Keys, _ source: DataSource) async { Self.logKeySyncStatus(key, source: source, syncStatus: .idle) switch source { case .remote: - await syncFromRemote(key: key) - recordTimestamp(.local) + await syncFromRemote(forKey: key) case .local: - syncFromLocal(key: key) - recordTimestamp(.remote) + syncFromLocal(forKey: key) } Self.logKeySyncStatus(key, source: source, syncStatus: .completed) } @@ -169,10 +185,14 @@ extension Defaults { /** Only update the value if it can be retrieved from the remote storage. */ - private func syncFromRemote(key: Defaults.Keys) async { + private func syncFromRemote(forKey key: Defaults.Keys) async { _remoteSyncingKeys.modify { $0.insert(key) } await withCheckedContinuation { continuation in - guard let value = remoteStorage.object(forKey: key.name) else { + guard + let object = remoteStorage.object(forKey: key.name) as? [Any], + let date = Self.timestamp, + let value = object[safe: 1] + else { continuation.resume() return } @@ -180,6 +200,7 @@ extension Defaults { 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() } } @@ -189,8 +210,11 @@ extension Defaults { /** Retrieve a value from local storage, and if it does not exist, remove it from the remote storage. */ - private func syncFromLocal(key: Defaults.Keys) { - guard let value = key.suite.object(forKey: key.name) else { + 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() @@ -198,7 +222,7 @@ extension Defaults { } Self.logKeySyncStatus(key, source: .local, syncStatus: .syncing, value: value) - remoteStorage.set(value, forKey: key.name) + remoteStorage.set([date, value], forKey: key.name) syncRemoteStorageOnChange() } @@ -212,26 +236,64 @@ extension Defaults { } /** - Mark the current timestamp for the specified `source`. + 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 recordTimestamp(_ source: DataSource) { + private func timestamp(forKey key: Defaults.Keys, _ source: 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: - localStorage.set(Date(), forKey: defaultsSyncKey) + guard + let timestamp = key.suite.object(forKey: "\(key.name)\(defaultsSyncKey)") as? Date + else { + return nil + } + + return timestamp + } + } + + /** + Mark the current timestamp to given storage. + */ + func recordTimestamp(forKey key: Defaults.Keys, timestamp: Date?, source: DataSource) { + switch source { case .remote: - remoteStorage.set(Date(), forKey: defaultsSyncKey) + 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() -> DataSource { + private func latestDataSource(forKey key: Defaults.Keys) -> DataSource { // If the remote timestamp does not exist, use the local timestamp as the latest data source. - guard let remoteTimestamp = remoteStorage.object(forKey: defaultsSyncKey) as? Date else { + guard let remoteTimestamp = self.timestamp(forKey: key, .remote) else { return .local } - guard let localTimestamp = localStorage.object(forKey: defaultsSyncKey) as? Date else { + guard let localTimestamp = self.timestamp(forKey: key, .local) else { return .remote } @@ -289,21 +351,25 @@ extension Defaults.iCloudSynchronizer { guard let userInfo = notification.userInfo, let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], - let remoteTimestamp = remoteStorage.object(forKey: defaultsSyncKey) as? Date + // 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 } - if - let localTimestamp = localStorage.object(forKey: defaultsSyncKey) as? Date, - localTimestamp > remoteTimestamp - { - return - } - for key in self.keys where changedKeys.contains(key.name) { - backgroundQueue.async { - await self.syncKey(key, .remote) + guard let remoteTimestamp = self.timestamp(forKey: key, .remote) else { + continue + } + if + let localTimestamp = self.timestamp(forKey: key, .local), + localTimestamp >= remoteTimestamp + { + continue + } + + self.enqueue { + await self.syncKey(forKey: key, .remote) } } } @@ -369,18 +435,21 @@ extension Defaults.iCloudSynchronizer { extension Defaults { /** - Automatically create synchronization tasks when the added `keys` undergo changed. + Automatically create synchronization tasks when the added keys undergo changed. - The synchronization task will be created in three different ways. + There are four ways to initiate synchronization, each of which will create a task in `backgroundQueue`: - 1. UserDefaults changes. - 2. Receive `NSUbiquitousKeyValueStore.didChangeExternallyNotification`. - 3. Call `syncWithoutWaiting()`. + 1. Using ``add(_:)`` + 2. Utilizing ``syncWithoutWaiting(_:source:)`` + 3. Observing UserDefaults for added `Defaults.Keys` using Key-Value Observation (KVO) + 4. Monitoring `NSUbiquitousKeyValueStore.didChangeExternallyNotification` for added `Defaults.Keys`. - > Tip: Using `await sync()` to make sure all synchronization tasks are done. + > Tip: After initializing the task, we can call ``sync()`` to ensure that all tasks in the backgroundQueue are completed. ```swift let quality = Defaults.Key("quality", default: 0, iCloud: true) + await Defaults.iCloud.sync() + print(NSUbiquitousKeyValueStore.default.object(forKey: quality.name)) //=> 0 Defaults[quality] = 1 await Defaults.iCloud.sync() print(NSUbiquitousKeyValueStore.default.object(forKey: quality.name)) //=> 1 @@ -408,7 +477,7 @@ extension Defaults { public static var isDebug = false /** - Add the keys to be automatically synced. + Add the keys to be automatically synced and create a synchronization task. */ public static func add(_ keys: Defaults.Keys...) { synchronizer.add(keys) diff --git a/Sources/Defaults/Defaults.swift b/Sources/Defaults/Defaults.swift index 3525b8c..204992d 100644 --- a/Sources/Defaults/Defaults.swift +++ b/Sources/Defaults/Defaults.swift @@ -113,14 +113,16 @@ extension Defaults { suite: UserDefaults = .standard, iCloud: Bool = false ) { + defer { + if iCloud { + Defaults.iCloud.add(self) + } + } + self.defaultValueGetter = { defaultValue } super.init(name: name, suite: suite) - if iCloud { - Defaults.iCloud.add(self) - } - if (defaultValue as? _DefaultsOptionalProtocol)?._defaults_isNil == true { return } diff --git a/Tests/DefaultsTests/Defaults+iCloudTests.swift b/Tests/DefaultsTests/Defaults+iCloudTests.swift index cbaebca..69d1d59 100644 --- a/Tests/DefaultsTests/Defaults+iCloudTests.swift +++ b/Tests/DefaultsTests/Defaults+iCloudTests.swift @@ -6,6 +6,19 @@ final class MockStorage: Defaults.KeyValueStore { 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 @@ -52,6 +65,7 @@ private let mockStorage = MockStorage() final class DefaultsICloudTests: XCTestCase { override class func setUp() { Defaults.iCloud.isDebug = true + Defaults.iCloud.syncOnChange = true Defaults.iCloud.synchronizer = Defaults.iCloudSynchronizer(remoteStorage: mockStorage) } @@ -70,18 +84,16 @@ final class DefaultsICloudTests: XCTestCase { } private func updateMockStorage(key: String, value: T, _ date: Date? = nil) { - mockStorage.set(value, forKey: key) - mockStorage.set(date ?? Date(), forKey: "__DEFAULTS__synchronizeTimestamp") + mockStorage.set([date ?? Date(), value], forKey: key) } func testICloudInitialize() async { let name = Defaults.Key("testICloudInitialize_name", default: "0", iCloud: true) let quality = Defaults.Key("testICloudInitialize_quality", default: 0.0, iCloud: true) - // Not sure why github action will not trigger a observation callback after initialization, cannot reproduce in local. - Defaults.iCloud.syncWithoutWaiting() + await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), "0") - XCTAssertEqual(mockStorage.object(forKey: quality.name), 0.0) + 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] @@ -89,8 +101,8 @@ final class DefaultsICloudTests: XCTestCase { Defaults[name] = name_expected[index] Defaults[quality] = quality_expected[index] await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), name_expected[index]) - XCTAssertEqual(mockStorage.object(forKey: quality.name), quality_expected[index]) + XCTAssertEqual(mockStorage.data(forKey: name.name), name_expected[index]) + XCTAssertEqual(mockStorage.data(forKey: quality.name), quality_expected[index]) } updateMockStorage(key: quality.name, value: 8.0) @@ -103,11 +115,11 @@ final class DefaultsICloudTests: XCTestCase { Defaults[name] = "9" Defaults[quality] = 9.0 await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), "9") - XCTAssertEqual(mockStorage.object(forKey: quality.name), 9.0) + XCTAssertEqual(mockStorage.data(forKey: name.name), "9") + XCTAssertEqual(mockStorage.data(forKey: quality.name), 9.0) - mockStorage.set("10", forKey: name.name) - mockStorage.set(10.0, forKey: quality.name) + updateMockStorage(key: quality.name, value: 10) + updateMockStorage(key: name.name, value: "10") mockStorage.synchronize() await Defaults.iCloud.sync() XCTAssertEqual(Defaults[quality], 10.0) @@ -115,8 +127,13 @@ final class DefaultsICloudTests: XCTestCase { } func testDidChangeExternallyNotification() async { + updateMockStorage(key: "testDidChangeExternallyNotification_name", value: "0") + updateMockStorage(key: "testDidChangeExternallyNotification_quality", value: 0.0) let name = Defaults.Key("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] @@ -132,14 +149,14 @@ final class DefaultsICloudTests: XCTestCase { Defaults[name] = "8" Defaults[quality] = 8.0 await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), "8") - XCTAssertEqual(mockStorage.object(forKey: quality.name), 8.0) + XCTAssertEqual(mockStorage.data(forKey: name.name), "8") + XCTAssertEqual(mockStorage.data(forKey: quality.name), 8.0) Defaults[name] = nil Defaults[quality] = nil await Defaults.iCloud.sync() - XCTAssertNil(mockStorage.object(forKey: name.name)) - XCTAssertNil(mockStorage.object(forKey: quality.name)) + XCTAssertNil(mockStorage.data(forKey: name.name)) + XCTAssertNil(mockStorage.data(forKey: quality.name)) } func testICloudInitializeSyncLast() async { @@ -156,8 +173,8 @@ final class DefaultsICloudTests: XCTestCase { } await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), "7") - XCTAssertEqual(mockStorage.object(forKey: quality.name), 7.0) + XCTAssertEqual(mockStorage.data(forKey: name.name), "7") + XCTAssertEqual(mockStorage.data(forKey: quality.name), 7.0) } func testRemoveKey() async { @@ -166,15 +183,15 @@ final class DefaultsICloudTests: XCTestCase { Defaults[name] = "1" Defaults[quality] = 1.0 await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), "1") - XCTAssertEqual(mockStorage.object(forKey: quality.name), 1.0) + 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.object(forKey: name.name), "2") - XCTAssertEqual(mockStorage.object(forKey: quality.name), 1.0) + XCTAssertEqual(mockStorage.data(forKey: name.name), "2") + XCTAssertEqual(mockStorage.data(forKey: quality.name), 1.0) } func testSyncKeysFromLocal() async { @@ -188,8 +205,8 @@ final class DefaultsICloudTests: XCTestCase { Defaults[quality] = quality_expected[index] Defaults.iCloud.syncWithoutWaiting(name, quality, source: .local) await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), name_expected[index]) - XCTAssertEqual(mockStorage.object(forKey: quality.name), quality_expected[index]) + XCTAssertEqual(mockStorage.data(forKey: name.name), name_expected[index]) + XCTAssertEqual(mockStorage.data(forKey: quality.name), quality_expected[index]) } updateMockStorage(key: name.name, value: "8") @@ -219,8 +236,8 @@ final class DefaultsICloudTests: XCTestCase { Defaults[quality] = 8.0 Defaults.iCloud.syncWithoutWaiting(name, quality, source: .local) await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), "8") - XCTAssertEqual(mockStorage.object(forKey: quality.name), 8.0) + XCTAssertEqual(mockStorage.data(forKey: name.name), "8") + XCTAssertEqual(mockStorage.data(forKey: quality.name), 8.0) Defaults[name] = nil Defaults[quality] = nil @@ -238,19 +255,18 @@ final class DefaultsICloudTests: XCTestCase { await Defaults.iCloud.sync() } await task.value - XCTAssertEqual(mockStorage.object(forKey: name.name), "0") + XCTAssertEqual(mockStorage.data(forKey: name.name), "0") Defaults[name] = "1" await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), "1") + XCTAssertEqual(mockStorage.data(forKey: name.name), "1") } func testICloudInitializeFromDetached() async { let task = Task.detached { let name = Defaults.Key("testICloudInitializeFromDetached_name", default: "0", iCloud: true) - // Not sure why github action will not trigger a observation callback after initialization, cannot reproduce in local. - Defaults.iCloud.syncWithoutWaiting() + await Defaults.iCloud.sync() - XCTAssertEqual(mockStorage.object(forKey: name.name), "0") + XCTAssertEqual(mockStorage.data(forKey: name.name), "0") } await task.value } From 91e58792bf5d1e540dc79d485faaacc6116596e5 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 29 Mar 2024 01:45:08 +0900 Subject: [PATCH 07/13] Update Defaults+iCloud.swift --- Sources/Defaults/Defaults+iCloud.swift | 39 +++++++++++++++----------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/Sources/Defaults/Defaults+iCloud.swift b/Sources/Defaults/Defaults+iCloud.swift index 16977e4..06babf9 100644 --- a/Sources/Defaults/Defaults+iCloud.swift +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -10,12 +10,17 @@ import Combine import Foundation /** -Represent different data sources available for synchronization. +Represent different data sources available for synchronization. */ public enum DataSource { - /// Using `key.suite` as data source. + /** + Using `key.suite` as data source. + */ case local - /// Using `NSUbiquitousKeyValueStore` as data source. + + /** + Using `NSUbiquitousKeyValueStore` as data source. + */ case remote } @@ -27,13 +32,9 @@ private enum SyncStatus { extension Defaults { /** - The supervisor for managing `Defaults.Keys` between `key.suite` and `remoteStorage`. - - Depends on the storage, `Defaults.Keys` will represent in different form due to storage limitations. + Manages `Defaults.Keys` between the locale and remote storage. - 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. + 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. */ public final class iCloudSynchronizer { init(remoteStorage: KeyValueStore) { @@ -134,7 +135,7 @@ extension Defaults { 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.iCloudSynchronizer`. - - Parameter source: Sync keys from which data source(remote or local). + - Parameter source: Sync keys from which data source (remote or local). */ func syncWithoutWaiting(_ keys: [Defaults.Keys] = [], _ source: DataSource? = nil) { let keys = keys.isEmpty ? Array(self.keys) : keys @@ -148,7 +149,7 @@ extension Defaults { } /** - Wait until all synchronization tasks in `backgroundQueue` are complete. + Wait until all synchronization tasks are complete. */ func sync() async { await backgroundQueue.flush() @@ -169,16 +170,18 @@ extension Defaults { Create synchronization tasks for the specified `key` from the given source. - Parameter forKey: The key to synchronize. - - Parameter source: Sync key from which data source(remote or local). + - Parameter source: Sync key from which data source (remote or local). */ private func syncKey(forKey key: Defaults.Keys, _ source: 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) } @@ -187,6 +190,7 @@ extension Defaults { */ 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], @@ -204,6 +208,7 @@ extension Defaults { continuation.resume() } } + _remoteSyncingKeys.modify { $0.remove(key) } } @@ -263,7 +268,7 @@ extension Defaults { } /** - Mark the current timestamp to given storage. + Mark the current timestamp to the given storage. */ func recordTimestamp(forKey key: Defaults.Keys, timestamp: Date?, source: DataSource) { switch source { @@ -386,6 +391,7 @@ extension Defaults.iCloudSynchronizer { guard Defaults.iCloud.isDebug else { return } + var destination: String switch source { case .local: @@ -393,6 +399,7 @@ extension Defaults.iCloudSynchronizer { case .remote: destination = "from remote" } + var status: String var valueDescription = " " switch syncStatus { @@ -404,8 +411,8 @@ extension Defaults.iCloudSynchronizer { case .completed: status = "Complete synchronization" } - let message = "\(status) key '\(key.name)'\(valueDescription)\(destination)" + let message = "\(status) key '\(key.name)'\(valueDescription)\(destination)" log(message) } @@ -435,7 +442,7 @@ extension Defaults.iCloudSynchronizer { extension Defaults { /** - Automatically create synchronization tasks when the added keys undergo changed. + Automatically create synchronization tasks when the added keys changed. There are four ways to initiate synchronization, each of which will create a task in `backgroundQueue`: @@ -484,7 +491,7 @@ extension Defaults { } /** - Remove the keys that are set to be automatically synced. + Remove the keys that are set to be automatically synced. */ public static func remove(_ keys: Defaults.Keys...) { synchronizer.remove(keys) From 67e4b4d618c6a915bf4a685d48cdec0c1ade30ef Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 29 Mar 2024 02:19:02 +0900 Subject: [PATCH 08/13] Update Defaults+iCloud.swift --- Sources/Defaults/Defaults+iCloud.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Sources/Defaults/Defaults+iCloud.swift b/Sources/Defaults/Defaults+iCloud.swift index 06babf9..53d9b1a 100644 --- a/Sources/Defaults/Defaults+iCloud.swift +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -392,15 +392,14 @@ extension Defaults.iCloudSynchronizer { return } - var destination: String - switch source { + let destination = switch source { case .local: - destination = "from local" + "from local" case .remote: - destination = "from remote" + "from remote" } - var status: String + let status: String var valueDescription = " " switch syncStatus { case .idle: @@ -424,9 +423,9 @@ extension Defaults.iCloudSynchronizer { if #available(macOS 11, iOS 14, tvOS 14, watchOS 7, *) { logger.debug("[Defaults.iCloud] \(message)") } else { -#if canImport(OSLog) + #if canImport(OSLog) os_log(.debug, log: .default, "[Defaults.iCloud] %@", message) -#else + #else let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSSZZZ" let dateString = dateFormatter.string(from: Date()) @@ -435,7 +434,7 @@ extension Defaults.iCloudSynchronizer { var threadID: UInt64 = 0 pthread_threadid_np(nil, &threadID) print("\(dateString) \(processName)[\(processIdentifier):\(threadID)] [Defaults.iCloud] \(message)") -#endif + #endif } } } From d87391a517dd13962bdb27a23fead0dced2c2d9a Mon Sep 17 00:00:00 2001 From: hank121314 Date: Fri, 29 Mar 2024 13:51:59 +0800 Subject: [PATCH 09/13] feat: apply comments --- Sources/Defaults/Defaults+Extensions.swift | 6 +- Sources/Defaults/Defaults+Protocol.swift | 4 +- Sources/Defaults/Defaults+iCloud.swift | 560 +++++++++--------- Sources/Defaults/Defaults.swift | 5 +- Sources/Defaults/Utilities.swift | 2 +- .../DefaultsTests/Defaults+iCloudTests.swift | 6 +- readme.md | 30 +- 7 files changed, 308 insertions(+), 305 deletions(-) diff --git a/Sources/Defaults/Defaults+Extensions.swift b/Sources/Defaults/Defaults+Extensions.swift index 7e7d734..c14ee6b 100644 --- a/Sources/Defaults/Defaults+Extensions.swift +++ b/Sources/Defaults/Defaults+Extensions.swift @@ -165,10 +165,10 @@ extension NSColor: Defaults.Serializable {} extension UIColor: Defaults.Serializable {} #endif -extension NSUbiquitousKeyValueStore: Defaults.KeyValueStore {} -extension UserDefaults: Defaults.KeyValueStore {} +extension NSUbiquitousKeyValueStore: DefaultsKeyValueStore {} +extension UserDefaults: DefaultsKeyValueStore {} -extension _DefaultsLockProtocol { +extension DefaultsLockProtocol { @discardableResult func with(_ body: @Sendable () throws -> R) rethrows -> R where R: Sendable { self.lock() diff --git a/Sources/Defaults/Defaults+Protocol.swift b/Sources/Defaults/Defaults+Protocol.swift index d97fc35..421621c 100644 --- a/Sources/Defaults/Defaults+Protocol.swift +++ b/Sources/Defaults/Defaults+Protocol.swift @@ -56,7 +56,7 @@ public protocol _DefaultsRange { /** Essential properties for synchronizing a key value store. */ -public protocol _DefaultsKeyValueStore { +protocol DefaultsKeyValueStore { func object(forKey aKey: String) -> Any? func set(_ anObject: Any?, forKey aKey: String) @@ -67,7 +67,7 @@ public protocol _DefaultsKeyValueStore { func synchronize() -> Bool } -protocol _DefaultsLockProtocol { +protocol DefaultsLockProtocol { static func make() -> Self func lock() diff --git a/Sources/Defaults/Defaults+iCloud.swift b/Sources/Defaults/Defaults+iCloud.swift index 53d9b1a..47668a4 100644 --- a/Sources/Defaults/Defaults+iCloud.swift +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -1,316 +1,297 @@ -#if canImport(OSLog) import OSLog -#endif -#if !os(macOS) -import UIKit -#else +#if os(macOS) import AppKit +#else +import UIKit #endif import Combine import Foundation -/** -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 -} - private enum SyncStatus { case idle case syncing case completed } -extension Defaults { - /** - 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. - */ - public final class iCloudSynchronizer { - init(remoteStorage: KeyValueStore) { - self.remoteStorage = remoteStorage - registerNotifications() - remoteStorage.synchronize() - } +/** +Manages `Defaults.Keys` between the locale and remote storage. - deinit { - removeAll() - } +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() + } - @TaskLocal static var timestamp: Date? + deinit { + removeAll() + } - private var cancellables: Set = [] + @TaskLocal static var timestamp: Date? - /** - Key for recording the synchronization between `NSUbiquitousKeyValueStore` and `UserDefaults`. - */ - private let defaultsSyncKey = "__DEFAULTS__synchronizeTimestamp" + private var cancellables: Set = [] - /** - A remote key value storage. - */ - private var remoteStorage: KeyValueStore + /** + Key for recording the synchronization between `NSUbiquitousKeyValueStore` and `UserDefaults`. + */ + private let defaultsSyncKey = "__DEFAULTS__synchronizeTimestamp" - /** - A FIFO queue used to serialize synchronization on keys. - */ - private let backgroundQueue = TaskQueue(priority: .background) + /** + A remote key value storage. + */ + private let remoteStorage: DefaultsKeyValueStore - /** - A thread-safe `keys` that manage the keys to be synced. - */ - @Atomic(value: []) private(set) var keys: Set + /** + A FIFO queue used to serialize synchronization on keys. + */ + private let backgroundQueue = TaskQueue(priority: .utility) - /** - A thread-safe synchronization status monitor for `keys`. - */ - @Atomic(value: []) private var remoteSyncingKeys: Set + /** + A thread-safe `keys` that manage the keys to be synced. + */ + @Atomic(value: []) private(set) var keys: Set - // TODO: Replace it with async stream when Swift supports custom executors. - private lazy var localKeysMonitor: 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 - } + /** + A thread-safe synchronization status monitor for `keys`. + */ + @Atomic(value: []) private var remoteSyncingKeys: Set - self.enqueue { - self.recordTimestamp(forKey: key, timestamp: Self.timestamp, source: .local) - await self.syncKey(forKey: key, .local) - } + // 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 } - /** - 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) - } + self.enqueue { + self.recordTimestamp(forKey: key, timestamp: Self.timestamp, source: .local) + await self.syncKey(key: key, .local) } + } - /** - Remove key and stop the observation. - */ - func remove(_ keys: [Defaults.Keys]) { - self.keys.subtract(keys) - for key in keys { - localKeysMonitor.removeObserver(key) - } + /** + 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 all sync keys. - */ - func removeAll() { - localKeysMonitor.invalidate() - _keys.modify { $0.removeAll() } - _remoteSyncingKeys.modify { $0.removeAll() } + /** + Remove key and stop the observation. + */ + func remove(_ keys: [Defaults.Keys]) { + self.keys.subtract(keys) + for key in keys { + localKeysMonitor.removeObserver(key) } + } - /** - Explicitly synchronizes in-memory keys and values with those stored on disk. - */ - func synchronize() { - remoteStorage.synchronize() - } + /** + Remove all sync keys. + */ + func removeAll() { + localKeysMonitor.invalidate() + _keys.modify { $0.removeAll() } + _remoteSyncingKeys.modify { $0.removeAll() } + } - /** - Synchronize the specified `keys` from the given `source` without waiting. + /** + Explicitly synchronizes in-memory keys and values with those stored on disk. + */ + func synchronize() { + remoteStorage.synchronize() + } - - Parameter keys: If the keys parameter is an empty array, the method will use the keys that were added to `Defaults.iCloudSynchronizer`. - - Parameter source: Sync keys from which data source (remote or local). - */ - func syncWithoutWaiting(_ keys: [Defaults.Keys] = [], _ source: DataSource? = nil) { - let keys = keys.isEmpty ? Array(self.keys) : keys + /** + Synchronize the specified `keys` from the given `source` without waiting. - for key in keys { - let latest = source ?? latestDataSource(forKey: key) - self.enqueue { - await self.syncKey(forKey: key, latest) - } + - 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: key, latest) } } + } - /** - Wait until all synchronization tasks are complete. - */ - func sync() async { - await backgroundQueue.flush() - } + /** + 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() - } + /** + 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. + /** + Create synchronization tasks for the specified `key` from the given source. - - Parameter forKey: The key to synchronize. - - Parameter source: Sync key from which data source (remote or local). - */ - private func syncKey(forKey key: Defaults.Keys, _ source: DataSource) async { - Self.logKeySyncStatus(key, source: source, syncStatus: .idle) - - switch source { - case .remote: - await syncFromRemote(forKey: key) - case .local: - syncFromLocal(forKey: key) - } + - 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) - Self.logKeySyncStatus(key, source: source, syncStatus: .completed) + switch source { + case .remote: + await syncFromRemote(forKey: key) + case .local: + syncFromLocal(forKey: key) } - /** - 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() - } - } + Self.logKeySyncStatus(key, source: source, syncStatus: .completed) + } - _remoteSyncingKeys.modify { $0.remove(key) } - } + /** + 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) } - /** - 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) { + await withCheckedContinuation { continuation in guard - let value = key.suite.object(forKey: key.name), - let date = Self.timestamp + let object = remoteStorage.object(forKey: key.name) as? [Any], + let date = Self.timestamp, + let value = object[safe: 1] else { - Self.logKeySyncStatus(key, source: .local, syncStatus: .syncing, value: nil) - remoteStorage.removeObject(forKey: key.name) - syncRemoteStorageOnChange() + continuation.resume() 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() + 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() } } - /** - Retrieve the timestamp associated with the specified key from the source provider. + _remoteSyncingKeys.modify { $0.remove(key) } + } - The timestamp storage format varies across different source providers due to storage limitations. - */ - private func timestamp(forKey key: Defaults.Keys, _ source: 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 - } + /** + 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 + } - return timestamp - case .local: - guard - let timestamp = key.suite.object(forKey: "\(key.name)\(defaultsSyncKey)") as? Date - else { - return nil - } + Self.logKeySyncStatus(key, source: .local, syncStatus: .syncing, value: value) + remoteStorage.set([date, value], forKey: key.name) + syncRemoteStorageOnChange() + } - return timestamp - } + /** + Explicitly synchronizes in-memory keys and values when a value is changed. + */ + private func syncRemoteStorageOnChange() { + if Defaults.iCloud.syncOnChange { + synchronize() } + } - /** - Mark the current timestamp to the given storage. - */ - func recordTimestamp(forKey key: Defaults.Keys, timestamp: Date?, source: DataSource) { - switch source { - case .remote: - guard - let values = remoteStorage.object(forKey: key.name) as? [Any], - let data = values[safe: 1], - let timestamp - else { - return - } + /** + Retrieve the timestamp associated with the specified key from the source provider. - remoteStorage.set([timestamp, data], forKey: key.name) - case .local: - guard let timestamp else { - return - } - key.suite.set(timestamp, forKey: "\(key.name)\(defaultsSyncKey)") + 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 } + } - /** - 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) -> DataSource { - // If the remote timestamp does not exist, use the local timestamp as the latest data source. - guard let remoteTimestamp = self.timestamp(forKey: key, .remote) else { - return .local + /** + 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 } - guard let localTimestamp = self.timestamp(forKey: key, .local) else { - return .remote + + remoteStorage.set([timestamp, data], forKey: key.name) + case .local: + guard let timestamp else { + return } + key.suite.set(timestamp, forKey: "\(key.name)\(defaultsSyncKey)") + } + } - return localTimestamp > remoteTimestamp ? .local : .remote + /** + 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, .remote) else { + return .local + } + guard let localTimestamp = self.timestamp(forKey: key, .local) else { + return .remote } + + return localTimestamp > remoteTimestamp ? .local : .remote } } /** -`Defaults.iCloudSynchronizer` notification related functions. +`iCloudSynchronizer` notification related functions. */ -extension Defaults.iCloudSynchronizer { +extension iCloudSynchronizer { private func registerNotifications() { // TODO: Replace it with async stream when Swift supports custom executors. NotificationCenter.default @@ -374,20 +355,20 @@ extension Defaults.iCloudSynchronizer { } self.enqueue { - await self.syncKey(forKey: key, .remote) + await self.syncKey(key: key, .remote) } } } } /** -`Defaults.iCloud` logging related functions. +`iCloudSynchronizer` logging related functions. */ -extension Defaults.iCloudSynchronizer { +extension iCloudSynchronizer { @available(macOS 11, iOS 14, tvOS 14, watchOS 7, *) private static let logger = Logger(OSLog.default) - private static func logKeySyncStatus(_ key: Defaults.Keys, source: DataSource, syncStatus: SyncStatus, value: Any? = nil) { + private static func logKeySyncStatus(_ key: Defaults.Keys, source: Defaults.DataSource, syncStatus: SyncStatus, value: Any? = nil) { guard Defaults.iCloud.isDebug else { return } @@ -441,54 +422,84 @@ extension Defaults.iCloudSynchronizer { extension Defaults { /** - Automatically create synchronization tasks when the added keys changed. + 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 four ways to initiate synchronization, each of which will create a task in `backgroundQueue`: - 1. Using ``add(_:)`` - 2. Utilizing ``syncWithoutWaiting(_:source:)`` - 3. Observing UserDefaults for added `Defaults.Keys` using Key-Value Observation (KVO) - 4. Monitoring `NSUbiquitousKeyValueStore.didChangeExternallyNotification` for added `Defaults.Keys`. + 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``. - > Tip: After initializing the task, we can call ``sync()`` to ensure that all tasks in the backgroundQueue are completed. + > Tip: After initializing the task, we can call ``iCloud/sync()`` to ensure that all tasks in the backgroundQueue are completed. ```swift - let quality = Defaults.Key("quality", default: 0, iCloud: true) - await Defaults.iCloud.sync() - print(NSUbiquitousKeyValueStore.default.object(forKey: quality.name)) //=> 0 - Defaults[quality] = 1 - await Defaults.iCloud.sync() - print(NSUbiquitousKeyValueStore.default.object(forKey: quality.name)) //=> 1 + 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() // Using sync to make sure all synchronization tasks are done. + // Both `isUnicornMode` and `quality` are synced. + } ``` */ public enum iCloud { /** The singleton for Defaults's iCloudSynchronizer. */ - static var synchronizer = Defaults.iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) + static var synchronizer = iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) /** Lists the synced keys. */ - public static let keys = synchronizer.keys + public static var keys: Set { synchronizer.keys } /** - Enable this if you want to call `NSUbiquitousKeyValueStore.synchronize` when a value is changed. + 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. + + - 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 and create a synchronization task. + 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. */ @@ -496,6 +507,13 @@ extension Defaults { 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. */ @@ -505,20 +523,22 @@ extension Defaults { /** 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 all synchronization tasks are complete. + 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.iCloud`. + Create synchronization tasks for all the keys that have been added to the ``Defaults/Defaults/iCloud``. */ public static func syncWithoutWaiting() { synchronizer.syncWithoutWaiting() @@ -530,10 +550,22 @@ extension Defaults { - Parameter keys: The keys that should be synced. - Parameter source: Sync keys from which data source(remote or local) - - Note: `source` should be specify if `key` has not been added to `Defaults.iCloud`. + - 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 204992d..3740545 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: Set `true` if you want automatic synchronization to iCloud. The `default` parameter should not be used if the `Value` type is an optional. */ @@ -309,10 +310,6 @@ extension Defaults { public typealias RangeSerializable = _DefaultsRange & _DefaultsSerializable - typealias KeyValueStore = _DefaultsKeyValueStore - - typealias LockProtocol = _DefaultsLockProtocol - /** Convenience protocol for `Codable`. */ diff --git a/Sources/Defaults/Utilities.swift b/Sources/Defaults/Utilities.swift index 9109cad..ef55a79 100644 --- a/Sources/Defaults/Utilities.swift +++ b/Sources/Defaults/Utilities.swift @@ -236,7 +236,7 @@ extension Defaults.Serializable { } // swiftlint:disable:next final_class -class Lock: Defaults.LockProtocol { +class Lock: DefaultsLockProtocol { final class UnfairLock: Lock { private let _lock: os_unfair_lock_t diff --git a/Tests/DefaultsTests/Defaults+iCloudTests.swift b/Tests/DefaultsTests/Defaults+iCloudTests.swift index 69d1d59..4704776 100644 --- a/Tests/DefaultsTests/Defaults+iCloudTests.swift +++ b/Tests/DefaultsTests/Defaults+iCloudTests.swift @@ -2,7 +2,7 @@ import SwiftUI import XCTest -final class MockStorage: Defaults.KeyValueStore { +final class MockStorage: DefaultsKeyValueStore { private var pairs: [String: Any] = [:] private let queue = DispatchQueue(label: "a") @@ -66,7 +66,7 @@ final class DefaultsICloudTests: XCTestCase { override class func setUp() { Defaults.iCloud.isDebug = true Defaults.iCloud.syncOnChange = true - Defaults.iCloud.synchronizer = Defaults.iCloudSynchronizer(remoteStorage: mockStorage) + Defaults.iCloud.synchronizer = iCloudSynchronizer(remoteStorage: mockStorage) } override func setUp() { @@ -88,9 +88,11 @@ final class DefaultsICloudTests: XCTestCase { } 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) diff --git a/readme.md b/readme.md index 25db6f6..7c1da3f 100644 --- a/readme.md +++ b/readme.md @@ -17,7 +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:** You can easily synchronize data among instances of your app. +- **iCloud support:** Automatically synchronize data between devices. ## Benefits over `@AppStorage` @@ -334,34 +334,6 @@ print(UserDefaults.standard.bool(forKey: Defaults.Keys.isUnicornMode.name)) > **Note** > A `Defaults.Key` with a dynamic default value will not register the default value in `UserDefaults`. -### Automatically synchronize data with iCloud - -You can create an automatically synchronizing `Defaults.Key` by setting the `iCloud` parameter to true. - -```swift -extension Defaults.Keys { - static let isUnicornMode = Key("isUnicornMode", default: true, iCloud: true) -} - -Task { - await Defaults.iCloud.sync() // Using sync to make sure all synchronization tasks are done. - print(NSUbiquitousKeyValueStore.default.bool(forKey: Defaults.Keys.isUnicornMode.name)) - //=> true -} -``` - -Also you can synchronize `Defaults.Key` manually, but make sure you select correct `source`. - -```swift -extension Defaults.Keys { - static let isUnicornMode = Key("isUnicornMode", default: true) -} - -Defaults.iCloud.syncKeys(.isUnicornMode, source: .local) // This will synchronize the value of the `isUnicornMode` key from the local source. -print(NSUbiquitousKeyValueStore.default.bool(forKey: Defaults.Keys.isUnicornMode.name)) -//=> true -``` - ## API From bb6588894cc0fe55ad5d640dfcef858364581795 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 29 Mar 2024 23:14:32 +0900 Subject: [PATCH 10/13] Update readme.md --- readme.md | 1 - 1 file changed, 1 deletion(-) diff --git a/readme.md b/readme.md index 7c1da3f..3b463ff 100644 --- a/readme.md +++ b/readme.md @@ -334,7 +334,6 @@ print(UserDefaults.standard.bool(forKey: Defaults.Keys.isUnicornMode.name)) > **Note** > A `Defaults.Key` with a dynamic default value will not register the default value in `UserDefaults`. - ## API ### `Defaults` From 90e30609eacbe97036648aa72b19ead4f8e706cd Mon Sep 17 00:00:00 2001 From: hank121314 Date: Thu, 4 Apr 2024 23:52:10 +0800 Subject: [PATCH 11/13] feat: apply comments --- Sources/Defaults/Defaults+iCloud.swift | 32 ++++++++++++++------------ Sources/Defaults/Defaults.swift | 4 +++- Sources/Defaults/Utilities.swift | 1 + 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/Sources/Defaults/Defaults+iCloud.swift b/Sources/Defaults/Defaults+iCloud.swift index 47668a4..400bc24 100644 --- a/Sources/Defaults/Defaults+iCloud.swift +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -72,7 +72,7 @@ final class iCloudSynchronizer { self.enqueue { self.recordTimestamp(forKey: key, timestamp: Self.timestamp, source: .local) - await self.syncKey(key: key, .local) + await self.syncKey(key, source: .local) } } @@ -125,7 +125,7 @@ final class iCloudSynchronizer { for key in keys { let latest = source ?? latestDataSource(forKey: key) self.enqueue { - await self.syncKey(key: key, latest) + await self.syncKey(key, source: latest) } } } @@ -154,7 +154,7 @@ final class iCloudSynchronizer { - 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 { + private func syncKey(_ key: Defaults.Keys, source: Defaults.DataSource) async { Self.logKeySyncStatus(key, source: source, syncStatus: .idle) switch source { @@ -227,7 +227,7 @@ final class iCloudSynchronizer { The timestamp storage format varies across different source providers due to storage limitations. */ - private func timestamp(forKey key: Defaults.Keys, _ source: Defaults.DataSource) -> Date? { + private func timestamp(forKey key: Defaults.Keys, source: Defaults.DataSource) -> Date? { switch source { case .remote: guard @@ -277,10 +277,10 @@ final class iCloudSynchronizer { */ 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, .remote) else { + guard let remoteTimestamp = self.timestamp(forKey: key, source: .remote) else { return .local } - guard let localTimestamp = self.timestamp(forKey: key, .local) else { + guard let localTimestamp = self.timestamp(forKey: key, source: .local) else { return .remote } @@ -306,14 +306,14 @@ extension iCloudSynchronizer { .store(in: &cancellables) // TODO: Replace it with async stream when Swift supports custom executors. - #if os(iOS) || os(tvOS) + #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) + #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) .sink { [weak self] notification in guard let self else { return @@ -344,18 +344,18 @@ extension iCloudSynchronizer { } for key in self.keys where changedKeys.contains(key.name) { - guard let remoteTimestamp = self.timestamp(forKey: key, .remote) else { + guard let remoteTimestamp = self.timestamp(forKey: key, source: .remote) else { continue } if - let localTimestamp = self.timestamp(forKey: key, .local), + let localTimestamp = self.timestamp(forKey: key, source: .local), localTimestamp >= remoteTimestamp { continue } self.enqueue { - await self.syncKey(key: key, .remote) + await self.syncKey(key, source: .remote) } } } @@ -365,7 +365,7 @@ extension iCloudSynchronizer { `iCloudSynchronizer` logging related functions. */ extension iCloudSynchronizer { - @available(macOS 11, iOS 14, tvOS 14, watchOS 7, *) + @available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1, *) private static let logger = Logger(OSLog.default) private static func logKeySyncStatus(_ key: Defaults.Keys, source: Defaults.DataSource, syncStatus: SyncStatus, value: Any? = nil) { @@ -439,12 +439,13 @@ extension Defaults { /** Synchronize values with different devices over iCloud. - There are four ways to initiate synchronization, each of which will create a task in `backgroundQueue`: + 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. @@ -458,7 +459,7 @@ extension Defaults { Task { let quality = Defaults.Key("quality", default: 0) Defaults.iCloud.add(quality) - await Defaults.iCloud.sync() // Using sync to make sure all synchronization tasks are done. + await Defaults.iCloud.sync() // Optional step: only needed if you require everything to be synced before continuing. // Both `isUnicornMode` and `quality` are synced. } ``` @@ -470,7 +471,7 @@ extension Defaults { static var synchronizer = iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) /** - Lists the synced keys. + The synced keys. */ public static var keys: Set { synchronizer.keys } @@ -481,6 +482,7 @@ extension Defaults { /** 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. */ diff --git a/Sources/Defaults/Defaults.swift b/Sources/Defaults/Defaults.swift index 3740545..2899e56 100644 --- a/Sources/Defaults/Defaults.swift +++ b/Sources/Defaults/Defaults.swift @@ -103,7 +103,7 @@ extension Defaults { Create a key. - Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`). - - Parameter iCloud: Set `true` if you want automatic synchronization to iCloud. + - Parameter iCloud: Automatically synchronize the value with ``Defaults/Defaults/iCloud``. The `default` parameter should not be used if the `Value` type is an optional. */ @@ -148,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. */ @@ -175,6 +176,7 @@ 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``. */ public convenience init( _ name: String, diff --git a/Sources/Defaults/Utilities.swift b/Sources/Defaults/Utilities.swift index ef55a79..8792330 100644 --- a/Sources/Defaults/Utilities.swift +++ b/Sources/Defaults/Utilities.swift @@ -366,6 +366,7 @@ final class TaskQueue { } } +// TODO: replace with Swift 6 native Atomics support. @propertyWrapper final class Atomic { private let lock: Lock = .make() From b4f92e098718d6a9bc6e0abb28e74d71d87d6f64 Mon Sep 17 00:00:00 2001 From: hank121314 Date: Fri, 5 Apr 2024 00:19:25 +0800 Subject: [PATCH 12/13] feat: add visionOS availability --- Sources/Defaults/Defaults+Bridge.swift | 2 +- Sources/Defaults/Defaults+iCloud.swift | 4 ++-- Sources/Defaults/SwiftUI.swift | 2 +- Sources/Defaults/Utilities.swift | 4 ++-- Tests/DefaultsTests/Defaults+iCloudTests.swift | 2 +- Tests/DefaultsTests/DefaultsColorTests.swift | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/Defaults/Defaults+Bridge.swift b/Sources/Defaults/Defaults+Bridge.swift index 7bcb25f..a283d5e 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+iCloud.swift b/Sources/Defaults/Defaults+iCloud.swift index 400bc24..cb624c9 100644 --- a/Sources/Defaults/Defaults+iCloud.swift +++ b/Sources/Defaults/Defaults+iCloud.swift @@ -365,7 +365,7 @@ extension iCloudSynchronizer { `iCloudSynchronizer` logging related functions. */ extension iCloudSynchronizer { - @available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1, *) + @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) { @@ -401,7 +401,7 @@ extension iCloudSynchronizer { return } - if #available(macOS 11, iOS 14, tvOS 14, watchOS 7, *) { + if #available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1.0, *) { logger.debug("[Defaults.iCloud] \(message)") } else { #if canImport(OSLog) diff --git a/Sources/Defaults/SwiftUI.swift b/Sources/Defaults/SwiftUI.swift index 936c68f..cf3e76c 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 8792330..d952c3f 100644 --- a/Sources/Defaults/Utilities.swift +++ b/Sources/Defaults/Utilities.swift @@ -254,7 +254,7 @@ class Lock: DefaultsLockProtocol { } } - @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) final class AllocatedUnfairLock: Lock { private let _lock = OSAllocatedUnfairLock() @@ -272,7 +272,7 @@ class Lock: DefaultsLockProtocol { } static func make() -> Self { - guard #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) else { + guard #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) else { return UnfairLock() as! Self } diff --git a/Tests/DefaultsTests/Defaults+iCloudTests.swift b/Tests/DefaultsTests/Defaults+iCloudTests.swift index 4704776..a7d61a0 100644 --- a/Tests/DefaultsTests/Defaults+iCloudTests.swift +++ b/Tests/DefaultsTests/Defaults+iCloudTests.swift @@ -61,7 +61,7 @@ final class MockStorage: DefaultsKeyValueStore { private let mockStorage = MockStorage() -@available(iOS 15, tvOS 15, watchOS 8, *) +@available(iOS 15, tvOS 15, watchOS 8, visionOS 1.0, *) final class DefaultsICloudTests: XCTestCase { override class func setUp() { Defaults.iCloud.isDebug = true diff --git a/Tests/DefaultsTests/DefaultsColorTests.swift b/Tests/DefaultsTests/DefaultsColorTests.swift index 6852932..83a9502 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() From cd6153c6b994633e9762dc61f97143d225c92122 Mon Sep 17 00:00:00 2001 From: hank121314 Date: Fri, 5 Apr 2024 00:24:28 +0800 Subject: [PATCH 13/13] fix: lintint violation --- Tests/DefaultsTests/Defaults+iCloudTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/DefaultsTests/Defaults+iCloudTests.swift b/Tests/DefaultsTests/Defaults+iCloudTests.swift index a7d61a0..8becd44 100644 --- a/Tests/DefaultsTests/Defaults+iCloudTests.swift +++ b/Tests/DefaultsTests/Defaults+iCloudTests.swift @@ -63,7 +63,7 @@ private let mockStorage = MockStorage() @available(iOS 15, tvOS 15, watchOS 8, visionOS 1.0, *) final class DefaultsICloudTests: XCTestCase { - override class func setUp() { + override final class func setUp() { Defaults.iCloud.isDebug = true Defaults.iCloud.syncOnChange = true Defaults.iCloud.synchronizer = iCloudSynchronizer(remoteStorage: mockStorage)