Merge d7d4f2c71c
into bd14dae265
This commit is contained in:
commit
4d66d99465
|
@ -164,3 +164,6 @@ extension NSColor: Defaults.Serializable {}
|
|||
*/
|
||||
extension UIColor: Defaults.Serializable {}
|
||||
#endif
|
||||
|
||||
extension NSUbiquitousKeyValueStore: Defaults.KeyValueStore {}
|
||||
extension UserDefaults: Defaults.KeyValueStore {}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)")
|
||||
}
|
||||
}
|
|
@ -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`.
|
||||
*/
|
||||
|
|
|
@ -66,3 +66,7 @@ typealias Default = _Default
|
|||
|
||||
- ``Defaults/PreferRawRepresentable``
|
||||
- ``Defaults/PreferNSSecureCoding``
|
||||
|
||||
### iCloud
|
||||
|
||||
- ``Defaults/iCloud``
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
30
readme.md
30
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`
|
||||
|
||||
|
@ -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`
|
||||
|
|
Loading…
Reference in New Issue