Feature Request: Settings Sync via iCloud
A sync feature would be great so we can keep multiple iCloud devices in sync without redoing settings on each device. It would also be beneficial if we can sync saved remote connections.
From Copilot:
To implement the iCloud settings sync feature (including saved remote connections) using NSUbiquitousKeyValueStore, you'll need to replace or augment the current local UserDefaults usage with Apple's iCloud-backed key-value store. This allows small amounts of data (up to 1 MB total per app) to sync across devices signed into the same iCloud account. Based on the app's current code (e.g., UserDefaults+Hosts.swift and related files), settings like remoteHosts, gitCredentialsLookupEntries, and other preferences are stored locally—migrating to NSUbiquitousKeyValueStore will enable cross-device syncing.
Here's a step-by-step explanation of how to implement this. Note that NSUbiquitousKeyValueStore is suitable for the simple key-value data currently stored in UserDefaults, but for larger or more complex data (e.g., if remote connections grow significantly), consider CloudKit as an alternative.
1. Enable iCloud Key-Value Storage in Xcode
- In your Xcode project, select the target (CodeApp).
- Go to Signing & Capabilities > + Capability > iCloud.
- Check Key-Value Storage (leave other options unchecked unless needed for CloudKit).
- This adds the necessary entitlements and frameworks (Foundation is already imported).
2. Update Data Storage Logic
-
NSUbiquitousKeyValueStoreworks similarly toUserDefaultsbut syncs via iCloud. Store encoded data (e.g., asData) for complex types like arrays of structs. -
Replace or wrap
UserDefaultsusage for syncable keys. Use a helper to prioritize iCloud data on first load. -
Example migration for
remoteHosts(fromUserDefaults+Hosts.swift):import Foundation extension NSUbiquitousKeyValueStore { // Use ubiquitous store for synced data var remoteHosts: [RemoteHost] { get { // Check if iCloud has data; fall back to local UserDefaults if not if let data = self.data(forKey: "remote.hosts") ?? UserDefaults.standard.data(forKey: "remote.hosts"), let array = try? PropertyListDecoder().decode([RemoteHost].self, from: data) { return array } return [] } set { if let data = try? PropertyListEncoder().encode(newValue) { self.set(data, forKey: "remote.hosts") // Optionally mirror to local UserDefaults for offline fallback UserDefaults.standard.set(data, forKey: "remote.hosts") self.synchronize() // Force sync } } } // Similarly for gitCredentialsLookupEntries var gitCredentialsLookupEntries: [GitCredentials] { get { if let data = self.data(forKey: "git.credentials.entries") ?? UserDefaults.standard.data(forKey: "git.credentials.entries"), let array = try? PropertyListDecoder().decode([GitCredentials].self, from: data) { return array } return [] } set { if let data = try? PropertyListEncoder().encode(newValue) { self.set(data, forKey: "git.credentials.entries") UserDefaults.standard.set(data, forKey: "git.credentials.entries") self.synchronize() } } } } -
In files like
RemoteContainer.swift, update calls fromUserDefaults.standard.remoteHoststoNSUbiquitousKeyValueStore.default.remoteHosts. -
Do the same for other syncable settings (e.g.,
gitCredentialsLookupEntriesinLocalGitCredentialsHelper.swift, terminal options inSettingsView.swift, etc.). Non-sensitive settings like UI preferences can be synced this way.
3. Handle Initial Sync and Conflicts
- On app launch (e.g., in
CodeApp.swift'ssetup()), check for iCloud data and migrate if needed:private func setup() { // ... existing code ... migrateToUbiquitousStore() // ... rest of setup ... } private func migrateToUbiquitousStore() { let ubiquitous = NSUbiquitousKeyValueStore.default // If no iCloud data, copy from local UserDefaults if ubiquitous.data(forKey: "remote.hosts") == nil, let localData = UserDefaults.standard.data(forKey: "remote.hosts") { ubiquitous.set(localData, forKey: "remote.hosts") } // Repeat for other keys ubiquitous.synchronize() } - For conflicts (e.g., data changed on another device),
NSUbiquitousKeyValueStoremerges automatically, but you can observe changes to resolve manually if needed.
4. Observe Changes for Real-Time Sync
- Register for notifications when iCloud data changes (e.g., on another device). Update the UI or local state accordingly.
- In a manager class (e.g., add to
MainApp.swiftor create a newSyncManager):class SyncManager { static let shared = SyncManager() private init() { NotificationCenter.default.addObserver( self, selector: #selector(ubiquitousStoreDidChange), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default ) } @objc private func ubiquitousStoreDidChange(notification: Notification) { // Reload data or notify UI to refresh // E.g., reload remote hosts list in RemoteContainer DispatchQueue.main.async { // Trigger UI updates, e.g., via @Published properties } } } - Call
SyncManager.sharedinsetup()to start observing.
5. Testing and Considerations
- Testing: Use multiple devices signed into the same iCloud account. Changes should sync within minutes (or faster with
synchronize()). - Limitations: Data is limited to ~1 MB; if remote connections or settings grow, use CloudKit. iCloud must be enabled on devices.
- Security:
NSUbiquitousKeyValueStoreis not encrypted like Keychain—avoid storing sensitive secrets here (e.g., keep passwords in Keychain, which can sync separately via iCloud Keychain). - Fallback: If iCloud is unavailable, fall back to local
UserDefaults. - Edge Cases: Handle app deletion/reinstall (iCloud data persists) and user sign-out from iCloud.
This approach directly addresses the issue by syncing settings and remote connections. For a full implementation, update all relevant files (e.g., RemoteContainer.swift, LocalGitCredentialsHelper.swift). If you need code for other settings or a pull request, let me know!