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