This commit is contained in:
hank121314 2023-06-03 00:19:18 -07:00 committed by GitHub
commit 4d66d99465
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 899 additions and 5 deletions

View File

@ -164,3 +164,6 @@ extension NSColor: Defaults.Serializable {}
*/
extension UIColor: Defaults.Serializable {}
#endif
extension NSUbiquitousKeyValueStore: Defaults.KeyValueStore {}
extension UserDefaults: Defaults.KeyValueStore {}

View File

@ -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
}

View File

@ -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<Defaults.Keys> = []
/**
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<Defaults.Keys> = .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)")
}
}

View File

@ -110,12 +110,17 @@ 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)?.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<T>(
_ 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`.
*/

View File

@ -66,3 +66,7 @@ typealias Default = _Default
- ``Defaults/PreferRawRepresentable``
- ``Defaults/PreferNSSecureCoding``
### iCloud
- ``Defaults/iCloud``

View File

@ -230,6 +230,179 @@ extension Defaults.Serializable {
}
}
/**
A reader/writer threading lock based on `libpthread`.
*/
final class RWLock {
private let lock: UnsafeMutablePointer<pthread_rwlock_t> = 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<R>(body: () -> R) -> R {
lockRead()
defer {
unlock()
}
return body()
}
@inlinable
func withWriteLock<R>(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<AsyncTask>.Continuation?
init(priority: TaskPriority? = nil) {
let taskStream = AsyncStream<AsyncTask> { 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<T: Hashable> {
private let lock = RWLock()
private var set: Set<T> = []
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.

View File

@ -0,0 +1,229 @@
@testable import Defaults
import SwiftUI
import XCTest
final class MockStorage: Defaults.KeyValueStore {
private var pairs: [String: Any] = [:]
func object<T>(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<T>(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<String>("testICloudInitialize_name", default: "0", iCloud: true)
let quality = Defaults.Key<Double>("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..<name_expected.count {
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])
}
updateMockStorage(key: quality.name, value: 8.0)
updateMockStorage(key: name.name, value: "8")
_ = mockStorage.synchronize()
XCTAssertEqual(Defaults[quality], 8.0)
XCTAssertEqual(Defaults[name], "8")
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)
Defaults[name] = "10"
Defaults[quality] = 10.0
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")
}
func testDidChangeExternallyNotification() async {
let name = Defaults.Key<String?>("testDidChangeExternallyNotification_name", iCloud: true)
let quality = Defaults.Key<Double?>("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..<name_expected.count {
updateMockStorage(key: name.name, value: name_expected[index])
updateMockStorage(key: quality.name, value: quality_expected[index])
_ = mockStorage.synchronize()
XCTAssertEqual(Defaults[name], name_expected[index])
XCTAssertEqual(Defaults[quality], quality_expected[index])
}
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)
Defaults[name] = nil
Defaults[quality] = nil
await Defaults.iCloud.sync()
XCTAssertNil(mockStorage.object(forKey: name.name))
XCTAssertNil(mockStorage.object(forKey: quality.name))
}
func testICloudInitializeSyncLast() async {
let name = Defaults.Key<String>("testICloudInitializeSyncLast_name", default: "0", iCloud: true)
let quality = Defaults.Key<Double>("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..<name_expected.count {
Defaults[name] = name_expected[index]
Defaults[quality] = quality_expected[index]
XCTAssertEqual(Defaults[name], name_expected[index])
XCTAssertEqual(Defaults[quality], quality_expected[index])
}
await Defaults.iCloud.sync()
XCTAssertEqual(mockStorage.object(forKey: name.name), "7")
XCTAssertEqual(mockStorage.object(forKey: quality.name), 7.0)
}
func testRemoveKey() async {
let name = Defaults.Key<String>("testRemoveKey_name", default: "0", iCloud: true)
let quality = Defaults.Key<Double>("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<String>("testSyncKeysFromLocal_name", default: "0")
let quality = Defaults.Key<Double>("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..<name_expected.count {
Defaults[name] = name_expected[index]
Defaults[quality] = quality_expected[index]
Defaults.iCloud.syncKeys(name, quality, source: .local)
XCTAssertEqual(mockStorage.object(forKey: name.name), name_expected[index])
XCTAssertEqual(mockStorage.object(forKey: quality.name), quality_expected[index])
}
updateMockStorage(key: name.name, value: "8")
updateMockStorage(key: quality.name, value: 8)
Defaults.iCloud.syncKeys(name, quality, source: .remote)
XCTAssertEqual(Defaults[quality], 8.0)
XCTAssertEqual(Defaults[name], "8")
}
func testSyncKeysFromRemote() async {
let name = Defaults.Key<String?>("testSyncKeysFromRemote_name")
let quality = Defaults.Key<Double?>("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..<name_expected.count {
updateMockStorage(key: name.name, value: name_expected[index])
updateMockStorage(key: quality.name, value: quality_expected[index])
Defaults.iCloud.syncKeys(name, quality, source: .remote)
XCTAssertEqual(Defaults[name], name_expected[index])
XCTAssertEqual(Defaults[quality], quality_expected[index])
}
Defaults[name] = "8"
Defaults[quality] = 8.0
Defaults.iCloud.syncKeys(name, quality, source: .local)
await Defaults.iCloud.sync()
XCTAssertEqual(mockStorage.object(forKey: name.name), "8")
XCTAssertEqual(mockStorage.object(forKey: quality.name), 8.0)
Defaults[name] = nil
Defaults[quality] = nil
Defaults.iCloud.syncKeys(name, quality, source: .local)
await Defaults.iCloud.sync()
XCTAssertNil(mockStorage.object(forKey: name.name))
XCTAssertNil(mockStorage.object(forKey: quality.name))
}
func testAddFromDetached() async {
let name = Defaults.Key<String>("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<String>("testICloudInitializeFromDetached_name", default: "0", iCloud: true)
await Defaults.iCloud.sync()
XCTAssertEqual(mockStorage.object(forKey: name.name), "0")
}
await task.value
}
}

View File

@ -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`
@ -336,6 +337,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<Bool>("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<Bool>("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`