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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -164,3 +164,17 @@ extension NSColor: Defaults.Serializable {}
|
|||
*/
|
||||
extension UIColor: Defaults.Serializable {}
|
||||
#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))
|
||||
}
|
||||
|
||||
/**
|
||||
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.
|
||||
|
||||
- Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`).
|
||||
- Parameter iCloud: Automatically synchronize the value with ``Defaults/Defaults/iCloud``.
|
||||
|
||||
The `default` parameter should not be used if the `Value` type is an optional.
|
||||
*/
|
||||
|
@ -110,8 +111,15 @@ extension Defaults {
|
|||
public init(
|
||||
_ name: String,
|
||||
default defaultValue: Value,
|
||||
suite: UserDefaults = .standard
|
||||
suite: UserDefaults = .standard,
|
||||
iCloud: Bool = false
|
||||
) {
|
||||
defer {
|
||||
if iCloud {
|
||||
Defaults.iCloud.add(self)
|
||||
}
|
||||
}
|
||||
|
||||
self.defaultValueGetter = { defaultValue }
|
||||
|
||||
super.init(name: name, suite: suite)
|
||||
|
@ -140,6 +148,7 @@ extension Defaults {
|
|||
```
|
||||
|
||||
- Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`).
|
||||
- Parameter iCloud: Automatically synchronize the value with ``Defaults/Defaults/iCloud``.
|
||||
|
||||
- Note: This initializer will not set the default value in the actual `UserDefaults`. This should not matter much though. It's only really useful if you use legacy KVO bindings.
|
||||
*/
|
||||
|
@ -147,11 +156,16 @@ extension Defaults {
|
|||
public init(
|
||||
_ name: String,
|
||||
suite: UserDefaults = .standard,
|
||||
default defaultValueGetter: @escaping () -> Value
|
||||
default defaultValueGetter: @escaping () -> Value,
|
||||
iCloud: Bool = false
|
||||
) {
|
||||
self.defaultValueGetter = defaultValueGetter
|
||||
|
||||
super.init(name: name, suite: suite)
|
||||
|
||||
if iCloud {
|
||||
Defaults.iCloud.add(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -162,13 +176,14 @@ extension Defaults.Key {
|
|||
Create a key with an optional value.
|
||||
|
||||
- Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`).
|
||||
- Parameter iCloud: Automatically synchronize the value with ``Defaults/Defaults/iCloud``.
|
||||
*/
|
||||
@_transparent
|
||||
public convenience init<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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -66,3 +66,7 @@ typealias Default = _Default
|
|||
|
||||
- ``Defaults/PreferRawRepresentable``
|
||||
- ``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 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 lifetimeAssociation: LifetimeAssociation?
|
||||
private let callback: UserDefaultsKeyObservation.Callback
|
||||
|
@ -286,6 +296,87 @@ extension Defaults {
|
|||
}
|
||||
}
|
||||
|
||||
final class CompositeUserDefaultsAnyKeyObservation: NSObject, Observation {
|
||||
typealias Callback = (SuiteKeyPair) -> Void
|
||||
private static var observationContext = 1
|
||||
|
||||
private var observables: Set<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.
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ extension Defaults {
|
|||
|
||||
func observe() {
|
||||
// We only use this on the latest OSes (as of adding this) since the backdeploy library has a lot of bugs.
|
||||
if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) {
|
||||
if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1.0, *) {
|
||||
task?.cancel()
|
||||
|
||||
// The `@MainActor` is important as the `.send()` method doesn't inherit the `@MainActor` from the class.
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
#if DEBUG
|
||||
#if canImport(OSLog)
|
||||
import OSLog
|
||||
|
@ -234,6 +235,180 @@ extension Defaults.Serializable {
|
|||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next final_class
|
||||
class Lock: DefaultsLockProtocol {
|
||||
final class UnfairLock: Lock {
|
||||
private let _lock: os_unfair_lock_t
|
||||
|
||||
override init() {
|
||||
_lock = .allocate(capacity: 1)
|
||||
_lock.initialize(to: os_unfair_lock())
|
||||
}
|
||||
|
||||
override func lock() {
|
||||
os_unfair_lock_lock(_lock)
|
||||
}
|
||||
|
||||
override func unlock() {
|
||||
os_unfair_lock_unlock(_lock)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *)
|
||||
final class AllocatedUnfairLock: Lock {
|
||||
private let _lock = OSAllocatedUnfairLock()
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func lock() {
|
||||
_lock.lock()
|
||||
}
|
||||
|
||||
override func unlock() {
|
||||
_lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
static func make() -> Self {
|
||||
guard #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) else {
|
||||
return UnfairLock() as! Self
|
||||
}
|
||||
|
||||
return AllocatedUnfairLock() as! Self
|
||||
}
|
||||
|
||||
private init() {}
|
||||
func lock() {}
|
||||
func unlock() {}
|
||||
}
|
||||
|
||||
/**
|
||||
A queue for executing asynchronous tasks in order.
|
||||
|
||||
```swift
|
||||
actor Counter {
|
||||
var count = 0
|
||||
|
||||
func increase() {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
let counter = Counter()
|
||||
let queue = TaskQueue(priority: .background)
|
||||
queue.async {
|
||||
print(await counter.count) //=> 0
|
||||
}
|
||||
queue.async {
|
||||
await counter.increase()
|
||||
}
|
||||
queue.async {
|
||||
print(await counter.count) //=> 1
|
||||
}
|
||||
```
|
||||
*/
|
||||
final class TaskQueue {
|
||||
typealias AsyncTask = @Sendable () async -> Void
|
||||
private var queueContinuation: AsyncStream<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
|
||||
/**
|
||||
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 XCTest
|
||||
|
||||
@available(iOS 15, tvOS 15, watchOS 8, *)
|
||||
@available(iOS 15, tvOS 15, watchOS 8, visionOS 1.0, *)
|
||||
final class DefaultsColorTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
|
|
@ -17,6 +17,7 @@ It's used in production by [all my apps](https://sindresorhus.com/apps) (1 milli
|
|||
- **Observation:** Observe changes to keys.
|
||||
- **Debuggable:** The data is stored as JSON-serialized values.
|
||||
- **Customizable:** You can serialize and deserialize your own type in your own way.
|
||||
- **iCloud support:** Automatically synchronize data between devices.
|
||||
|
||||
## Benefits over `@AppStorage`
|
||||
|
||||
|
|
Loading…
Reference in New Issue