Add support for syncing using NSUbiquitousKeyValueStore (#136)

Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
This commit is contained in:
hank121314 2024-04-20 12:04:24 +08:00 committed by GitHub
parent 38925e3cfa
commit e8370522ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1190 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

@ -17,6 +17,7 @@ It's used in production by [all my apps](https://sindresorhus.com/apps) (1 milli
- **Observation:** Observe changes to keys.
- **Debuggable:** The data is stored as JSON-serialized values.
- **Customizable:** You can serialize and deserialize your own type in your own way.
- **iCloud support:** Automatically synchronize data between devices.
## Benefits over `@AppStorage`