├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md └── Sources ├── MutexBackport.swift └── iCloudKVKey.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "597d33ffa00760439a1ff80731d0674ef43f965ee916f76be2843b5c67fa354f", 3 | "pins" : [ 4 | { 5 | "identity" : "combine-schedulers", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/pointfreeco/combine-schedulers", 8 | "state" : { 9 | "revision" : "5928286acce13def418ec36d05a001a9641086f2", 10 | "version" : "1.0.3" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-clocks", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/pointfreeco/swift-clocks", 17 | "state" : { 18 | "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", 19 | "version" : "1.0.6" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-collections", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-collections", 26 | "state" : { 27 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 28 | "version" : "1.1.4" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-concurrency-extras", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 35 | "state" : { 36 | "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", 37 | "version" : "1.3.1" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-custom-dump", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 44 | "state" : { 45 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 46 | "version" : "1.3.3" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-dependencies", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/pointfreeco/swift-dependencies", 53 | "state" : { 54 | "revision" : "ec2862d1364536fc22ec56a3094e7a034bbc7da8", 55 | "version" : "1.8.1" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-identified-collections", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 62 | "state" : { 63 | "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", 64 | "version" : "1.1.1" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-perception", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/pointfreeco/swift-perception", 71 | "state" : { 72 | "revision" : "671fa54b279fd73933b4a8b34782ebf6c8869145", 73 | "version" : "1.5.1" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-sharing", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/pointfreeco/swift-sharing", 80 | "state" : { 81 | "revision" : "2c840cf2ae0526ad6090e7796c4e13d9a2339f4a", 82 | "version" : "2.3.3" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-syntax", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/swiftlang/swift-syntax", 89 | "state" : { 90 | "revision" : "0687f71944021d616d34d922343dcef086855920", 91 | "version" : "600.0.1" 92 | } 93 | }, 94 | { 95 | "identity" : "xctest-dynamic-overlay", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 98 | "state" : { 99 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 100 | "version" : "1.5.2" 101 | } 102 | } 103 | ], 104 | "version" : 3 105 | } 106 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "sharing-cloud", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13), 12 | .watchOS(.v9), 13 | ], 14 | products: [ 15 | .library( 16 | name: "SharingCloud", 17 | targets: ["SharingCloud"] 18 | ), 19 | ], 20 | dependencies: [ 21 | .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.0.0"), 22 | ], 23 | targets: [ 24 | .target( 25 | name: "SharingCloud", 26 | dependencies: [ 27 | .product(name: "Sharing", package: "swift-sharing"), 28 | ], 29 | path: "", 30 | sources: ["Sources"] 31 | ), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sharing Cloud 2 | 3 | A swift library that extends the [swift-sharing](https://github.com/pointfreeco/swift-sharing) library with support for iCloud key-value synchronization through NSUbiquitousKeyValueStore. 4 | 5 | ## Overview 6 | 7 | SharingCloud provides a simple and elegant way to sync shared data across multiple Apple devices using iCloud's key-value store. The library implements the `SharedKey` protocol from the swift-sharing library, enabling you to use the `@Shared` property wrapper with iCloud as a persistence strategy. 8 | 9 | ## Features 10 | 11 | - **Seamless iCloud Integration**: Automatically sync data between iOS, macOS, watchOS, and tvOS devices 12 | - **Simple Property Wrapper Syntax**: Use the familiar `@Shared` syntax with the `.iCloudKV` storage strategy 13 | - **Comprehensive Type Support**: 14 | - Primitives: Bool, Int, Double, String 15 | - Complex Types: URL, Data, Date 16 | - Optional variants of all supported types 17 | - RawRepresentable types using Int or String as raw values 18 | - **Automatic Change Detection**: Observes iCloud changes and updates UI automatically 19 | - **Entitlement Checking**: Checks for proper iCloud entitlements and triggers the warning by `reportIssue` 20 | - **Key Length Validation**: Checks that the key is under the 64-byte limit and triggers the warning by `reportIssue` 21 | 22 | ## Installation 23 | 24 | ### Swift Package Manager 25 | 26 | Add the following to your `Package.swift` file: 27 | 28 | ```swift 29 | dependencies: [ 30 | .package(url: "https://github.com/KeithBird/sharing-cloud", from: "1.0.0") 31 | ] 32 | ``` 33 | 34 | Then add the product to any target that needs it: 35 | 36 | ```swift 37 | .product(name: "SharingCloud", package: "sharing-cloud") 38 | ``` 39 | 40 | ## Usage 41 | 42 | ### Basic Example 43 | 44 | ```swift 45 | import SharingCloud 46 | 47 | // In an ObservableObject or @Observable class 48 | @Observable 49 | class SettingsModel { 50 | @ObservationIgnored 51 | @Shared(.iCloudKV("userPreferences")) 52 | var isDarkModeEnabled: Bool = false 53 | 54 | @ObservationIgnored 55 | @Shared(.iCloudKV("notificationsEnabled")) 56 | var notificationsEnabled: Bool = true 57 | 58 | @ObservationIgnored 59 | @Shared(.iCloudKV("fontSize")) 60 | var fontSize: Int = 14 61 | } 62 | 63 | // In a SwiftUI view 64 | struct SettingsView: View { 65 | @Shared(.iCloudKV("userPreferences")) private var isDarkModeEnabled: Bool = false 66 | @Shared(.iCloudKV("notificationsEnabled")) private var notificationsEnabled: Bool = true 67 | @Shared(.iCloudKV("fontSize")) private var fontSize: Int = 14 68 | 69 | var body: some View { 70 | Form { 71 | Toggle("Dark Mode", isOn: $isDarkModeEnabled) 72 | Toggle("Notifications", isOn: $notificationsEnabled) 73 | Stepper("Font Size: \(fontSize)", value: $fontSize, in: 10...30) 74 | } 75 | } 76 | } 77 | ``` 78 | 79 | ### Using with RawRepresentable Types 80 | 81 | ```swift 82 | enum Theme: String, Sendable { 83 | case light 84 | case dark 85 | case system 86 | } 87 | 88 | struct ThemeSettingsView: View { 89 | @Shared(.iCloudKV("appTheme")) private var theme: Theme = .system 90 | 91 | var body: some View { 92 | Picker("Theme", selection: $theme) { 93 | Text("Light").tag(Theme.light) 94 | Text("Dark").tag(Theme.dark) 95 | Text("System").tag(Theme.system) 96 | } 97 | } 98 | } 99 | ``` 100 | 101 | ### Storing Complex Types 102 | 103 | Storing a URL example: 104 | 105 | ```swift 106 | @Shared(.iCloudKV("lastVisitedURL")) var lastURL: URL? 107 | ``` 108 | 109 | ### Other Shared Use Casess 110 | 111 | More examples can be found in the [swift-sharing documentation](https://swiftpackageindex.com/pointfreeco/swift-sharing/main/documentation/sharing/). 112 | 113 | ## Setting Up iCloud in Your App 114 | 115 | 1. In Xcode, select your target and navigate to "Signing & Capabilities" 116 | 2. Add the iCloud capability 117 | 3. Check "Key-value storage" 118 | 4. Ensure your app has the proper entitlements: 119 | - `com.apple.developer.ubiquity-kvstore-identifier` 120 | 5. Replace `$(CFBundleIdenfier)` with your key-value store ID (i.e. `$(TeamIdentifierPrefix)`). 121 | 122 | For more information, please see: [Synchronizing App Preferences with iCloud](https://developer.apple.com/documentation/foundation/icloud/synchronizing_app_preferences_with_icloud) 123 | 124 | ## Limitations 125 | 126 | - NSUbiquitousKeyValueStore has a maximum total storage size of 1MB per user 127 | - Each key-value pair is limited to 1MB in size 128 | - Maximum of 1024 keys allowed 129 | - Keys can't be larger than 64 bytes using UTF-8 encoding 130 | - Synchronization is not immediate; iCloud syncs on its own schedule 131 | 132 | ## Best Practices 133 | 134 | - Use short, descriptive key names to stay under the 64-byte limit 135 | - Store only small pieces of data like preferences and settings 136 | - Do not store sensitive user information in iCloud key-value store 137 | - Check for iCloud availability before assuming data will be synced 138 | 139 | ## License 140 | 141 | This library is available under the MIT license. 142 | -------------------------------------------------------------------------------- /Sources/MutexBackport.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A synchronization primitive that protects shared mutable state via mutual exclusion. 4 | /// 5 | /// A back-port of Swift's `Mutex` type for wider platform availability. 6 | #if hasFeature(StaticExclusiveOnly) 7 | @_staticExclusiveOnly 8 | #endif 9 | struct Mutex: ~Copyable { 10 | private let _lock = NSLock() 11 | private let _box: Box 12 | 13 | /// Initializes a value of this mutex with the given initial state. 14 | /// 15 | /// - Parameter initialValue: The initial value to give to the mutex. 16 | package init(_ initialValue: consuming sending Value) { 17 | _box = Box(initialValue) 18 | } 19 | 20 | private final class Box { 21 | var value: Value 22 | init(_ initialValue: consuming sending Value) { 23 | value = initialValue 24 | } 25 | } 26 | } 27 | 28 | extension Mutex: @unchecked Sendable where Value: ~Copyable {} 29 | 30 | extension Mutex where Value: ~Copyable { 31 | /// Calls the given closure after acquiring the lock and then releases ownership. 32 | borrowing func withLock( 33 | _ body: (inout sending Value) throws(E) -> sending Result 34 | ) throws(E) -> sending Result { 35 | _lock.lock() 36 | defer { _lock.unlock() } 37 | return try body(&_box.value) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/iCloudKVKey.swift: -------------------------------------------------------------------------------- 1 | #if canImport(AppKit) || canImport(UIKit) || canImport(WatchKit) 2 | import Dependencies 3 | import Foundation 4 | import Sharing 5 | 6 | #if canImport(AppKit) 7 | import AppKit 8 | #endif 9 | #if canImport(UIKit) 10 | import UIKit 11 | #endif 12 | #if canImport(WatchKit) 13 | import WatchKit 14 | #endif 15 | 16 | public extension SharedReaderKey { 17 | /// Creates a shared key that can read and write to a boolean iCloud key-value store. 18 | /// 19 | /// - Parameters: 20 | /// - key: The key to read and write the value to in the iCloud key-value store. 21 | /// - Returns: An iCloud key-value store shared key. 22 | static func iCloudKV(_ key: String) -> Self 23 | where Self == iCloudKVKey { 24 | iCloudKVKey(key) 25 | } 26 | 27 | /// Creates a shared key that can read and write to an integer iCloud key-value store. 28 | /// 29 | /// - Parameters: 30 | /// - key: The key to read and write the value to in the iCloud key-value store. 31 | /// - Returns: An iCloud key-value store shared key. 32 | static func iCloudKV(_ key: String) -> Self 33 | where Self == iCloudKVKey { 34 | iCloudKVKey(key) 35 | } 36 | 37 | /// Creates a shared key that can read and write to a double iCloud key-value store. 38 | /// 39 | /// - Parameters: 40 | /// - key: The key to read and write the value to in the iCloud key-value store. 41 | /// - Returns: An iCloud key-value store shared key. 42 | static func iCloudKV(_ key: String) -> Self 43 | where Self == iCloudKVKey { 44 | iCloudKVKey(key) 45 | } 46 | 47 | /// Creates a shared key that can read and write to a string iCloud key-value store. 48 | /// 49 | /// - Parameters: 50 | /// - key: The key to read and write the value to in the iCloud key-value store. 51 | /// - Returns: An iCloud key-value store shared key. 52 | static func iCloudKV(_ key: String) -> Self 53 | where Self == iCloudKVKey { 54 | iCloudKVKey(key) 55 | } 56 | 57 | /// Creates a shared key that can read and write to a URL iCloud key-value store. 58 | /// 59 | /// - Parameters: 60 | /// - key: The key to read and write the value to in the iCloud key-value store. 61 | /// - Returns: An iCloud key-value store shared key. 62 | static func iCloudKV(_ key: String) -> Self 63 | where Self == iCloudKVKey { 64 | iCloudKVKey(key) 65 | } 66 | 67 | /// Creates a shared key that can read and write to an iCloud key-value store as data. 68 | /// 69 | /// - Parameters: 70 | /// - key: The key to read and write the value to in the iCloud key-value store. 71 | /// - Returns: An iCloud key-value store shared key. 72 | static func iCloudKV(_ key: String) -> Self 73 | where Self == iCloudKVKey { 74 | iCloudKVKey(key) 75 | } 76 | 77 | /// Creates a shared key that can read and write to a date iCloud key-value store. 78 | /// 79 | /// - Parameters: 80 | /// - key: The key to read and write the value to in the iCloud key-value store. 81 | /// - Returns: An iCloud key-value store shared key. 82 | static func iCloudKV(_ key: String) -> Self 83 | where Self == iCloudKVKey { 84 | iCloudKVKey(key) 85 | } 86 | 87 | /// Creates a shared key that can read and write to an integer iCloud key-value store, transforming 88 | /// that to a `RawRepresentable` data type. 89 | /// 90 | /// - Parameters: 91 | /// - key: The key to read and write the value to in the iCloud key-value store. 92 | /// - Returns: An iCloud key-value store shared key. 93 | static func iCloudKV>( 94 | _ key: String 95 | ) -> Self 96 | where Self == iCloudKVKey { 97 | iCloudKVKey(key) 98 | } 99 | 100 | /// Creates a shared key that can read and write to a string iCloud key-value store, transforming 101 | /// that to a `RawRepresentable` data type. 102 | /// 103 | /// - Parameters: 104 | /// - key: The key to read and write the value to in the iCloud key-value store. 105 | /// - Returns: An iCloud key-value store shared key. 106 | static func iCloudKV>( 107 | _ key: String 108 | ) -> Self 109 | where Self == iCloudKVKey { 110 | iCloudKVKey(key) 111 | } 112 | 113 | /// Creates a shared key that can read and write to an optional boolean iCloud key-value store. 114 | /// 115 | /// - Parameters: 116 | /// - key: The key to read and write the value to in the iCloud key-value store. 117 | /// - Returns: An iCloud key-value store shared key. 118 | static func iCloudKV(_ key: String) -> Self 119 | where Self == iCloudKVKey { 120 | iCloudKVKey(key) 121 | } 122 | 123 | /// Creates a shared key that can read and write to an optional integer iCloud key-value store. 124 | /// 125 | /// - Parameters: 126 | /// - key: The key to read and write the value to in the iCloud key-value store. 127 | /// - Returns: An iCloud key-value store shared key. 128 | static func iCloudKV(_ key: String) -> Self 129 | where Self == iCloudKVKey { 130 | iCloudKVKey(key) 131 | } 132 | 133 | /// Creates a shared key that can read and write to an optional double iCloud key-value store. 134 | /// 135 | /// - Parameters: 136 | /// - key: The key to read and write the value to in the iCloud key-value store. 137 | /// - Returns: An iCloud key-value store shared key. 138 | static func iCloudKV(_ key: String) -> Self 139 | where Self == iCloudKVKey { 140 | iCloudKVKey(key) 141 | } 142 | 143 | /// Creates a shared key that can read and write to an optional string iCloud key-value store. 144 | /// 145 | /// - Parameters: 146 | /// - key: The key to read and write the value to in the iCloud key-value store. 147 | /// - Returns: An iCloud key-value store shared key. 148 | static func iCloudKV(_ key: String) -> Self 149 | where Self == iCloudKVKey { 150 | iCloudKVKey(key) 151 | } 152 | 153 | /// Creates a shared key that can read and write to an optional URL iCloud key-value store. 154 | /// 155 | /// - Parameters: 156 | /// - key: The key to read and write the value to in the iCloud key-value store. 157 | /// - Returns: An iCloud key-value store shared key. 158 | static func iCloudKV(_ key: String) -> Self 159 | where Self == iCloudKVKey { 160 | iCloudKVKey(key) 161 | } 162 | 163 | /// Creates a shared key that can read and write to an iCloud key-value store as optional data. 164 | /// 165 | /// - Parameters: 166 | /// - key: The key to read and write the value to in the iCloud key-value store. 167 | /// - Returns: An iCloud key-value store shared key. 168 | static func iCloudKV(_ key: String) -> Self 169 | where Self == iCloudKVKey { 170 | iCloudKVKey(key) 171 | } 172 | 173 | /// Creates a shared key that can read and write to an optional date iCloud key-value store. 174 | /// 175 | /// - Parameters: 176 | /// - key: The key to read and write the value to in the iCloud key-value store. 177 | /// - Returns: An iCloud key-value store shared key. 178 | static func iCloudKV(_ key: String) -> Self 179 | where Self == iCloudKVKey { 180 | iCloudKVKey(key) 181 | } 182 | 183 | /// Creates a shared key that can read and write to an integer iCloud key-value store, transforming 184 | /// that to an optional `RawRepresentable` data type. 185 | /// 186 | /// - Parameters: 187 | /// - key: The key to read and write the value to in the iCloud key-value store. 188 | /// - Returns: An iCloud key-value store shared key. 189 | static func iCloudKV( 190 | _ key: String 191 | ) -> Self 192 | where Value.RawValue == Int, Self == iCloudKVKey { 193 | iCloudKVKey(key) 194 | } 195 | 196 | /// Creates a shared key that can read and write to a string iCloud key-value store, transforming 197 | /// that to an optional `RawRepresentable` data type. 198 | /// 199 | /// - Parameters: 200 | /// - key: The key to read and write the value to in the iCloud key-value store. 201 | /// - Returns: An iCloud key-value store shared key. 202 | static func iCloudKV( 203 | _ key: String 204 | ) -> Self 205 | where Value.RawValue == String, Self == iCloudKVKey { 206 | iCloudKVKey(key) 207 | } 208 | } 209 | 210 | /// A type defining an iCloud key-value store persistence strategy. 211 | public struct iCloudKVKey: SharedKey { 212 | private let lookup: any Lookup 213 | private let key: String 214 | private nonisolated(unsafe) let store: NSUbiquitousKeyValueStore 215 | 216 | public var id: iCloudKVKeyID { 217 | iCloudKVKeyID(key: key) 218 | } 219 | 220 | private init(lookup: some Lookup, key: String) { 221 | self.lookup = lookup 222 | self.key = key 223 | 224 | // Check key length limitation 225 | if key.lengthOfBytes(using: .utf8) > 64 { 226 | reportIssue( 227 | """ 228 | The maximum length for key strings for the iCloud key-value store is 64 bytes using UTF8 encoding. 229 | Attempting to write a value to a longer key name results in a runtime error. 230 | Consider using a shorter key name. 231 | For details, visit https://developer.apple.com/documentation/foundation/nsubiquitouskeyvaluestore. 232 | """ 233 | ) 234 | } 235 | 236 | store = .default 237 | } 238 | 239 | fileprivate init(_ key: String) where Value == Bool { 240 | self.init(lookup: CastableLookup(), key: key) 241 | } 242 | 243 | fileprivate init(_ key: String) where Value == Int { 244 | self.init(lookup: CastableLookup(), key: key) 245 | } 246 | 247 | fileprivate init(_ key: String) where Value == Double { 248 | self.init(lookup: CastableLookup(), key: key) 249 | } 250 | 251 | fileprivate init(_ key: String) where Value == String { 252 | self.init(lookup: CastableLookup(), key: key) 253 | } 254 | 255 | fileprivate init(_ key: String) where Value == URL { 256 | self.init(lookup: URLLookup(), key: key) 257 | } 258 | 259 | fileprivate init(_ key: String) where Value == Data { 260 | self.init(lookup: CastableLookup(), key: key) 261 | } 262 | 263 | fileprivate init(_ key: String) where Value == Date { 264 | self.init(lookup: CastableLookup(), key: key) 265 | } 266 | 267 | fileprivate init(_ key: String) where Value: RawRepresentable { 268 | self.init(lookup: RawRepresentableLookup(base: CastableLookup()), key: key) 269 | } 270 | 271 | fileprivate init(_ key: String) where Value: RawRepresentable { 272 | self.init(lookup: RawRepresentableLookup(base: CastableLookup()), key: key) 273 | } 274 | 275 | fileprivate init(_ key: String) where Value == Bool? { 276 | self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) 277 | } 278 | 279 | fileprivate init(_ key: String) where Value == Int? { 280 | self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) 281 | } 282 | 283 | fileprivate init(_ key: String) where Value == Double? { 284 | self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) 285 | } 286 | 287 | fileprivate init(_ key: String) where Value == String? { 288 | self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) 289 | } 290 | 291 | fileprivate init(_ key: String) where Value == URL? { 292 | self.init(lookup: OptionalLookup(base: URLLookup()), key: key) 293 | } 294 | 295 | fileprivate init(_ key: String) where Value == Data? { 296 | self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) 297 | } 298 | 299 | fileprivate init(_ key: String) where Value == Date? { 300 | self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) 301 | } 302 | 303 | fileprivate init>(_ key: String) 304 | where Value == R? { 305 | self.init( 306 | lookup: OptionalLookup(base: RawRepresentableLookup(base: CastableLookup())), 307 | key: key 308 | ) 309 | } 310 | 311 | fileprivate init>(_ key: String) 312 | where Value == R? { 313 | self.init( 314 | lookup: OptionalLookup(base: RawRepresentableLookup(base: CastableLookup())), 315 | key: key 316 | ) 317 | } 318 | 319 | public func load(context: LoadContext, continuation: LoadContinuation) { 320 | store.synchronize() 321 | continuation.resume(with: .success(lookupValue(default: context.initialValue))) 322 | } 323 | 324 | public func subscribe( 325 | context: LoadContext, subscriber: SharedSubscriber 326 | ) -> SharedSubscription { 327 | let previousValue = Mutex(context.initialValue) 328 | 329 | // Register for iCloud key-value store changes 330 | nonisolated(unsafe) let iCloudStoreDidChange = NotificationCenter.default.addObserver( 331 | forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, 332 | object: store, 333 | queue: nil 334 | ) { notification in 335 | guard 336 | !notification.hasExceededQuota 337 | else { 338 | reportIssue( 339 | """ 340 | Your app's key-value store has exceeded its space quota on the iCloud server. 341 | The total amount of space available in your app’s key-value store, for a given user, is 1 MB. There is a per-key value size limit of 1 MB, and a maximum of 1024 keys. If you attempt to write data that exceeds these quotas, the write attempt fails and no change is made to your iCloud key-value storage. 342 | For details, visit https://developer.apple.com/documentation/foundation/nsubiquitouskeyvaluestore. 343 | """ 344 | ) 345 | return 346 | } 347 | 348 | // Check if the key is in the changed keys 349 | guard 350 | notification.changedKeys.contains(key) 351 | else { 352 | return 353 | } 354 | 355 | let newValue = lookupValue(default: context.initialValue) 356 | defer { previousValue.withLock { $0 = newValue } } 357 | 358 | // Check if the value has changed 359 | func isEqual(_ lhs: T, _ rhs: T) -> Bool? { 360 | func open(_ lhs: U) -> Bool { 361 | lhs == rhs as? U 362 | } 363 | guard let lhs = lhs as? any Equatable else { return nil } 364 | return open(lhs) 365 | } 366 | 367 | guard 368 | !(isEqual(newValue, previousValue.withLock { $0 }) ?? false) 369 | || (isEqual(newValue, context.initialValue) ?? true) 370 | else { 371 | return 372 | } 373 | 374 | guard !SharediCloudKVLocals.isSetting 375 | else { return } 376 | 377 | DispatchQueue.main.async { 378 | subscriber.yield(with: .success(newValue)) 379 | } 380 | } 381 | 382 | if !store.synchronize() { 383 | reportIssue( 384 | """ 385 | Failed to synchronize iCloud key-value store. 386 | The app may not have been built with the correct entitlement requests. 387 | For details, visit https://developer.apple.com/documentation/foundation/nsubiquitouskeyvaluestore/1415989-synchronize 388 | """ 389 | ) 390 | } 391 | 392 | // Also refresh on application becoming active 393 | nonisolated(unsafe) let willEnterForeground: (any NSObjectProtocol)? 394 | if let willEnterForegroundNotificationName { 395 | willEnterForeground = NotificationCenter.default.addObserver( 396 | forName: willEnterForegroundNotificationName, 397 | object: nil, 398 | queue: .main 399 | ) { _ in 400 | store.synchronize() 401 | } 402 | } else { 403 | willEnterForeground = nil 404 | } 405 | 406 | return SharedSubscription { 407 | NotificationCenter.default.removeObserver(iCloudStoreDidChange) 408 | if let willEnterForeground { 409 | NotificationCenter.default.removeObserver(willEnterForeground) 410 | } 411 | } 412 | } 413 | 414 | public func save(_ value: Value, context _: SaveContext, continuation: SaveContinuation) { 415 | lookup.saveValue(value, to: store, at: key) 416 | continuation.resume() 417 | } 418 | 419 | private func lookupValue(default initialValue: Value?) -> Value? { 420 | lookup.loadValue(from: store, at: key, default: initialValue) 421 | } 422 | } 423 | 424 | extension iCloudKVKey: CustomStringConvertible { 425 | public var description: String { 426 | ".iCloudKV(\(String(reflecting: key)))" 427 | } 428 | } 429 | 430 | public struct iCloudKVKeyID: Hashable { 431 | fileprivate let key: String 432 | } 433 | 434 | // For local state tracking 435 | private enum SharediCloudKVLocals { 436 | @TaskLocal static var isSetting = false 437 | } 438 | 439 | // Lookup protocol for NSUbiquitousKeyValueStore 440 | private protocol Lookup: Sendable { 441 | associatedtype Value: Sendable 442 | func loadValue( 443 | from store: NSUbiquitousKeyValueStore, 444 | at key: String, 445 | default defaultValue: Value? 446 | ) -> Value? 447 | func saveValue(_ newValue: Value, to store: NSUbiquitousKeyValueStore, at key: String) 448 | } 449 | 450 | private struct CastableLookup: Lookup { 451 | func loadValue( 452 | from store: NSUbiquitousKeyValueStore, 453 | at key: String, 454 | default defaultValue: Value? 455 | ) -> Value? { 456 | guard let value = store.object(forKey: key) as? Value 457 | else { 458 | guard !SharediCloudKVLocals.isSetting 459 | else { return nil } 460 | 461 | SharediCloudKVLocals.$isSetting.withValue(true) { 462 | store.set(defaultValue, forKey: key) 463 | } 464 | return defaultValue 465 | } 466 | return value 467 | } 468 | 469 | func saveValue(_ newValue: Value, to store: NSUbiquitousKeyValueStore, at key: String) { 470 | SharediCloudKVLocals.$isSetting.withValue(true) { 471 | store.set(newValue, forKey: key) 472 | } 473 | } 474 | } 475 | 476 | private struct URLLookup: Lookup { 477 | typealias Value = URL 478 | 479 | func loadValue(from store: NSUbiquitousKeyValueStore, at key: String, default defaultValue: URL?) -> URL? { 480 | guard let stringValue = store.string(forKey: key), let url = URL(string: stringValue) 481 | else { 482 | guard !SharediCloudKVLocals.isSetting 483 | else { return nil } 484 | if let defaultValue { 485 | SharediCloudKVLocals.$isSetting.withValue(true) { 486 | store.set(defaultValue.absoluteString, forKey: key) 487 | } 488 | } 489 | return defaultValue 490 | } 491 | return url 492 | } 493 | 494 | func saveValue(_ newValue: URL, to store: NSUbiquitousKeyValueStore, at key: String) { 495 | SharediCloudKVLocals.$isSetting.withValue(true) { 496 | store.set(newValue.absoluteString, forKey: key) 497 | } 498 | } 499 | } 500 | 501 | private struct RawRepresentableLookup: Lookup 502 | where Value.RawValue == Base.Value { 503 | let base: Base 504 | func loadValue( 505 | from store: NSUbiquitousKeyValueStore, at key: String, default defaultValue: Value? 506 | ) -> Value? { 507 | base.loadValue(from: store, at: key, default: defaultValue?.rawValue) 508 | .flatMap(Value.init(rawValue:)) 509 | } 510 | 511 | func saveValue(_ newValue: Value, to store: NSUbiquitousKeyValueStore, at key: String) { 512 | base.saveValue(newValue.rawValue, to: store, at: key) 513 | } 514 | } 515 | 516 | private struct OptionalLookup: Lookup { 517 | let base: Base 518 | func loadValue( 519 | from store: NSUbiquitousKeyValueStore, at key: String, default defaultValue: Base.Value?? 520 | ) -> Base.Value?? { 521 | base.loadValue(from: store, at: key, default: defaultValue ?? nil) 522 | .flatMap(Optional.some) 523 | ?? .none 524 | } 525 | 526 | func saveValue(_ newValue: Base.Value?, to store: NSUbiquitousKeyValueStore, at key: String) { 527 | if let newValue { 528 | base.saveValue(newValue, to: store, at: key) 529 | } else { 530 | SharediCloudKVLocals.$isSetting.withValue(true) { 531 | store.removeObject(forKey: key) 532 | } 533 | } 534 | } 535 | } 536 | 537 | private let willEnterForegroundNotificationName: Notification.Name? = { 538 | #if os(macOS) 539 | return NSApplication.willBecomeActiveNotification 540 | #elseif os(iOS) || os(tvOS) || os(visionOS) 541 | return UIApplication.willEnterForegroundNotification 542 | #elseif os(watchOS) 543 | if #available(watchOS 7, *) { 544 | return WKExtension.applicationWillEnterForegroundNotification 545 | } else { 546 | return nil 547 | } 548 | #else 549 | return nil 550 | #endif 551 | }() 552 | 553 | private extension Notification { 554 | var changedKeys: [String] { 555 | guard 556 | let userInfo = userInfo, 557 | let value = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey], 558 | let keys = value as? [String] 559 | else { 560 | return [] 561 | } 562 | return keys 563 | } 564 | 565 | var hasExceededQuota: Bool { 566 | guard 567 | let userInfo = userInfo, 568 | let changeReason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey], 569 | let reason = changeReason as? Int 570 | else { 571 | return false 572 | } 573 | switch reason { 574 | case NSUbiquitousKeyValueStoreServerChange: 575 | return false 576 | case NSUbiquitousKeyValueStoreInitialSyncChange: 577 | return false 578 | case NSUbiquitousKeyValueStoreQuotaViolationChange: 579 | return true 580 | case NSUbiquitousKeyValueStoreAccountChange: 581 | return false 582 | default: 583 | return false 584 | } 585 | } 586 | } 587 | #endif 588 | --------------------------------------------------------------------------------