-
-
Notifications
You must be signed in to change notification settings - Fork 117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support NSUbiquitousKeyValueStore
#136
Conversation
Wow. This is really awesome! 🎉 |
private func addObserver(_ key: Defaults.Keys) { | ||
backgroundQueue.sync { | ||
key.suite.addObserver(self, forKeyPath: key.name, options: [.new], context: nil) | ||
} | ||
} | ||
|
||
private func removeObserver(_ key: Defaults.Keys) { | ||
backgroundQueue.sync { | ||
key.suite.removeObserver(self, forKeyPath: key.name, context: nil) | ||
} | ||
} | ||
|
||
@_documentation(visibility: private) | ||
// swiftlint:disable:next block_based_kvo | ||
override public func observeValue( | ||
forKeyPath keyPath: String?, | ||
of object: Any?, | ||
change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection | ||
context: UnsafeMutableRawPointer? | ||
) { | ||
guard | ||
let keyPath, | ||
let object, | ||
object is UserDefaults, | ||
let key = keys.first(where: { $0.name == keyPath }), | ||
!atomicSet.contains(key) | ||
else { | ||
return | ||
} | ||
|
||
backgroundQueue.async { | ||
self.recordTimestamp(.local) | ||
await self.syncKey(key, .local) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be nicer to wrap this up into a separate utility that is decoupled from Defaults.iCloud
. And also have it return an async stream. I generally prefer to wrap legacy APIs in a nicer interface. This also helps keep code clean.
|
||
- Note: `source` should be specify if `key` has not been added to `Defaults.iCloud`. | ||
*/ | ||
public static func syncKeys(_ keys: Defaults.Keys..., source: DataSource? = nil) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the use-case for this method? Do we really need to expose it?
/** | ||
Synchronize all of the keys that have been added to Defaults.iCloud. | ||
*/ | ||
public static func syncKeys() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a bit confusing to have both .sync()
and .syncKeys()
and it's not immediately clear when you would want to use either. Can we just make .sync()
do both? Or am I missing something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
syncKeys()
will create synchronization task for all keys and push it into backgroundQueue
.
await sync()
will wait until all synchronization tasks done, maybe we can call it flush()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
flush()
or syncWithoutWaiting()
would work.
But my question still stands, do we really need to expose this to the user?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I saw some requests in ArtSabintsev/Zephyr#32.
With await flush()
, users will know that all pending synchronizations are done.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it should be syncWithoutWaiting()
and await sync()
. That's the most explicit names I can think of.
return .remote | ||
} | ||
|
||
return localTimestamp.timeIntervalSince1970 > remoteTimestamp.timeIntervalSince1970 ? .local : .remote |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return localTimestamp.timeIntervalSince1970 > remoteTimestamp.timeIntervalSince1970 ? .local : .remote | |
return localTimestamp > remoteTimestamp ? .local : .remote |
public protocol _DefaultsKeyValueStore { | ||
func object(forKey aKey: String) -> Any? | ||
func set(_ anObject: Any?, forKey aKey: String) | ||
func removeObject(forKey aKey: String) | ||
@discardableResult | ||
func synchronize() -> Bool | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public protocol _DefaultsKeyValueStore { | |
func object(forKey aKey: String) -> Any? | |
func set(_ anObject: Any?, forKey aKey: String) | |
func removeObject(forKey aKey: String) | |
@discardableResult | |
func synchronize() -> Bool | |
} | |
public protocol _DefaultsKeyValueStore { | |
func object(forKey key: String) -> Any? | |
func set(_ object: Any?, forKey key: String) | |
func removeObject(forKey key: String) | |
@discardableResult | |
func synchronize() -> Bool | |
} |
Sources/Defaults/Utilities.swift
Outdated
let semaphore = DispatchSemaphore(value: 0) | ||
|
||
queueContinuation?.yield { | ||
await task() | ||
semaphore.signal() | ||
} | ||
|
||
semaphore.wait() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will deadlock if the task
accidentally calls something that also calls .sync()
(nested calls).
According to ChatGPT, you could do something like this:
final class TaskQueue {
typealias AsyncTask = @Sendable () async -> Void
private var queueContinuation: AsyncStream<AsyncTask>.Continuation?
private let queue: DispatchQueue
init(priority: TaskPriority? = nil) {
queue = DispatchQueue(label: "com.example.taskqueue", attributes: .concurrent)
let taskStream = AsyncStream<AsyncTask> { queueContinuation = $0 }
Task.detached(priority: priority) {
for await task in taskStream {
await task()
}
}
}
deinit {
queueContinuation?.finish()
}
func async(_ task: @escaping AsyncTask) {
queue.async {
self.queueContinuation?.yield(task)
}
}
func sync(_ task: @escaping AsyncTask) {
let semaphore = DispatchSemaphore(value: 0)
queue.async {
if DispatchQueue.getSpecific(key: self.queue.key) == self.queue.id {
Task {
await task()
semaphore.signal()
}
} else {
self.queueContinuation?.yield {
await task()
semaphore.signal()
}
}
}
semaphore.wait()
}
func flush() async {
await withCheckedContinuation { continuation in
queue.async {
self.queueContinuation?.yield {
continuation.resume()
}
}
}
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's true, I think we should deprecate sync
.
Would it be acceptable to use TaskQueue.async
for updating remote key changes?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The async queue is concurrent, so it maybe cause ordering problems.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TaskQueue.async
is not concurrent; it will run similarly to DispatchQueue.async
.
I believe it should not have ordering problems.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Btw, if calling await flush
in TaskQueue.async
, it will also cause deadlock.
Maybe we should emit a Xcode runtime warning to prevent user doing this.
ex.
let queue = TaskQueue(priority: .background)
queue.async {
print("1")
queue.async {
print("2")
}
await queue.flush()
}
await queue.flush()
// => 1 "2" will never print.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Btw, if calling await flush in TaskQueue.async, it will also cause deadlock.
Maybe we should emit a Xcode runtime warning to prevent user doing this.
Users don't have access to TaskQueue
though. It's an internal type.
It is challenging to change the Key-Value Observing (KVO) mechanism of Here is a demonstration of how Defaults.iCloud works. flowchart TD
A("UserDefaults \n Changes \n ex.Defaults[key] = 0")
B("NSUbiquitousKeyValueStore \n didChangeExternallyNotification")
C("Defaults.iCloud.syncKeys()")
D[\"Push \n Synchronization \n Task \n Sequentially"/]
E(["Executing Tasks \n Task.detached"])
F(["All Tasks Complete"])
A & B & C --> D --> E -- "await Defaults.iCloud.sync()" --> F
If we change the observation to asyncstream, there is a need to create a task to consume the asyncstream. Since the user's change is not in the same task as the asyncstream, it is difficult to ensure that the synchronization is completed synchronously. ex. Task.detached {
for await change in updates {
print("\(change)")
}
}
Defaults[key] = 0
Defaults[key] = 1
await Defaults.iCloud.sync()
// => 0
// await Defaults.iCloud.sync() get call
// => 1 |
Understood. It's fine. We can use KVO then. Just add a todo to look into using async stream when Swift supports custom executors. |
I believe it might be worth considering waiting for the release of Swift 5.9, which will include the implementation of Custom Actor Executors. |
Sources/Defaults/Utilities.swift
Outdated
@@ -327,21 +338,9 @@ final class TaskQueue { | |||
Queue a new asynchronous task. | |||
*/ | |||
func async(_ task: @escaping AsyncTask) { | |||
lock.lock() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's generally better to have a .with
type method to make sure the locked resource is always unlocked. Like: https://developer.apple.com/documentation/os/osallocatedunfairlock/4061628-withlock
Is the custom actor executors feature backdeployed? If not, we would have to target macOS 14, which does not make sense. |
import Combine | ||
import Foundation | ||
|
||
/// Represent different data sources available for synchronization. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// Represent different data sources available for synchronization. | |
/** | |
Represent different data sources available for synchronization. | |
*/ |
private enum SyncStatus { | ||
case start | ||
case isSyncing | ||
case finish | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be more idiomatic Swift:
private enum SyncStatus { | |
case start | |
case isSyncing | |
case finish | |
} | |
private enum SyncStatus { | |
case idle | |
case syncing | |
case completed | |
} |
Not sure idle
is correct though.
/** | ||
The singleton for Defaults's iCloudSynchronizer. | ||
*/ | ||
static var shared = Defaults.iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The name is not correct. It makes it sound like it's the singleton of iCloud
.
static var shared = Defaults.iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) | |
static var synchronizer = Defaults.iCloudSynchronizer(remoteStorage: NSUbiquitousKeyValueStore.default) |
f7d3737
to
f38a785
Compare
|
Btw, my old MacBook can not install macOS 14+ and Xcode 15+, so I can not develop some features which related to swift 5.9 😭 . Will buy the M3 MacBook until it release in Taiwan. |
Any updates on this? It's a really awesome PR :/ |
fe9a371
to
375ae41
Compare
I want to check a behavior when initialized with |
I would prefer if users don't have to call anything manually. Can we wait until the sync (fetching remote data) is done before pushing the local data to remote? Maybe also look at how Zephyr handles this. |
Seems Zephyr also facing the same issue. ArtSabintsev/Zephyr#50 (comment) I have a thought to resolve this issue, but it might involve some trade-offs. |
Local storage is not a problem, but synced storage is a problem as it's limited to 1 Kb in total. Is there a way to use timestamps for each key but not sync them? |
Oh, this is a good idea. Will try to implement this in a few days! Thank you 🙌 ! |
I tried the method mentioned above but noticed that it may not cover all use cases. If a user has multiple For example. // Choose `local` data source, sync `name` to `remoteStorage`, record `remoteStorage` timestamp.
let name = Defaults.Key<String>("name", default: "0", iCloud: true)
// `remoteStorage` have timestamp record and local timestamp is empty so we will choose `remote` data source, sync `quality` to `localStorage`, but the data source should be `local` instead.
let quality = Defaults.Key<Double>("quality", default: 0.0, iCloud: true) Considering the scenario, it seems necessary to maintain timestamp records in both And considering the limitation of 1024 keys in remoteStorage, a potential solution could be to consolidate the value and timestamp into a single key? For example. class iCloudKey {
let value: Any?
let timestamp: Date?
} |
You are right. I don't see any way around this either. |
So it is necessary to maintain the timestamp in both storages. For Is this an acceptable solution? |
Correct For the remote storage we should try to make it take the least amount of space, because there is also a total limit of 1MB storage. Maybe we can convert the timestamp to fixed-length data, serialize the value to data too, combine them, and store them both in one Data value? To deserialize, we would just slice of the first part, which would be a fixed length data for the timestamp and then deserialize the rest as data. I'm open to other ideas. |
I believe there might be some effort required in serializing the value into data. |
👍 |
PR updated!
Please feel free to review it again! Many thanks! |
ccea22a
to
082144e
Compare
I finally had a chance to test this in a real app with syncing between Mac and iPhone and it seems to work fine 👍 |
Superb work @hank121314 🙌 |
I missed that this was not resolved: https://github.com/sindresorhus/Defaults/pull/136/files#r1544553011 |
I decided to make |
Sure, the new name looks more explicit about the behavior! |
Summary
This PR fixes: #75
Create a
Defaults.iCloud
class which facilitates the synchronization of keys between the localUserDefaults
andNSUbiquitousKeyValueStore
.This class utilizes a
TaskQueue
to execute synchronization tasks sequentially, ensuring proper order and coordination.Internally,
Defaults.iCloud
contains anAtomicSet
that indicates the synchronization status of each key. This set prevents observation triggers when synchronization is being executed.And I also create a new protocol
Defaults.KeyValueStore
which contains essential properties for synchronizing a key value store. It can be useful when mocking a test or even support another key value data source.Thanks
Please feel free to critique, and thanks for your code review 😄 .
Any feedback on the PR is welcomed and appreciated 🙇 !