Skip to content

Commit ddcd83c

Browse files
authored
Merge pull request #364 from datlechin/feat/icloud-keychain-password-sync
feat: add optional iCloud Keychain sync for connection passwords
2 parents 4718ff2 + e6c630c commit ddcd83c

8 files changed

Lines changed: 18331 additions & 18106 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Optional iCloud Keychain sync for connection passwords
13+
1014
## [0.20.2] - 2026-03-18
1115

1216
### Fixed

TablePro/AppDelegate.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5252

5353
func applicationDidFinishLaunching(_ notification: Notification) {
5454
NSWindow.allowsAutomaticWindowTabbing = true
55+
let syncSettings = AppSettingsStorage.shared.loadSync()
56+
let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords
57+
let previousSyncState = UserDefaults.standard.bool(forKey: KeychainHelper.passwordSyncEnabledKey)
58+
UserDefaults.standard.set(passwordSyncExpected, forKey: KeychainHelper.passwordSyncEnabledKey)
5559
KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded()
60+
if passwordSyncExpected != previousSyncState {
61+
Task.detached(priority: .background) {
62+
KeychainHelper.shared.migratePasswordSyncState(synchronizable: passwordSyncExpected)
63+
}
64+
}
5665
PluginManager.shared.loadPlugins()
5766

5867
Task { @MainActor in

TablePro/Core/Storage/KeychainHelper.swift

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,46 @@ final class KeychainHelper {
1313
private let service = "com.TablePro"
1414
private static let logger = Logger(subsystem: "com.TablePro", category: "KeychainHelper")
1515
private static let migrationKey = "com.TablePro.keychainMigratedToDataProtection"
16+
static let passwordSyncEnabledKey = "com.TablePro.keychainPasswordSyncEnabled"
17+
18+
private let migrationLock = NSLock()
19+
20+
private var isPasswordSyncEnabled: Bool {
21+
UserDefaults.standard.bool(forKey: Self.passwordSyncEnabledKey)
22+
}
1623

1724
private init() {}
1825

1926
// MARK: - Core Methods
2027

2128
@discardableResult
2229
func save(key: String, data: Data) -> Bool {
23-
let addQuery: [String: Any] = [
30+
var addQuery: [String: Any] = [
2431
kSecClass as String: kSecClassGenericPassword,
2532
kSecAttrService as String: service,
2633
kSecAttrAccount as String: key,
2734
kSecValueData as String: data,
2835
kSecUseDataProtectionKeychain as String: true,
2936
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
3037
]
38+
if isPasswordSyncEnabled {
39+
addQuery[kSecAttrSynchronizable as String] = true
40+
}
3141

3242
var status = SecItemAdd(addQuery as CFDictionary, nil)
3343

3444
if status == errSecDuplicateItem {
45+
let synchronizable = isPasswordSyncEnabled
3546
let searchQuery: [String: Any] = [
3647
kSecClass as String: kSecClassGenericPassword,
3748
kSecAttrService as String: service,
3849
kSecAttrAccount as String: key,
39-
kSecUseDataProtectionKeychain as String: true
50+
kSecUseDataProtectionKeychain as String: true,
51+
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
4052
]
4153
let updateAttributes: [String: Any] = [
42-
kSecValueData as String: data
54+
kSecValueData as String: data,
55+
kSecAttrSynchronizable as String: synchronizable
4356
]
4457
status = SecItemUpdate(searchQuery as CFDictionary, updateAttributes as CFDictionary)
4558
}
@@ -57,6 +70,7 @@ final class KeychainHelper {
5770
kSecAttrService as String: service,
5871
kSecAttrAccount as String: key,
5972
kSecUseDataProtectionKeychain as String: true,
73+
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
6074
kSecReturnData as String: true,
6175
kSecMatchLimit as String: kSecMatchLimitOne
6276
]
@@ -79,7 +93,8 @@ final class KeychainHelper {
7993
kSecClass as String: kSecClassGenericPassword,
8094
kSecAttrService as String: service,
8195
kSecAttrAccount as String: key,
82-
kSecUseDataProtectionKeychain as String: true
96+
kSecUseDataProtectionKeychain as String: true,
97+
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
8398
]
8499

85100
let status = SecItemDelete(query as CFDictionary)
@@ -180,4 +195,98 @@ final class KeychainHelper {
180195
Self.logger.warning("Legacy keychain migration incomplete, will retry on next launch")
181196
}
182197
}
198+
199+
// MARK: - Password Sync Migration
200+
201+
/// Migrates all TablePro keychain items between local-only and iCloud-synchronizable.
202+
/// Serialized via `migrationLock` to prevent concurrent migrations from rapid toggling.
203+
func migratePasswordSyncState(synchronizable: Bool) {
204+
migrationLock.lock()
205+
defer { migrationLock.unlock() }
206+
207+
Self.logger.info("Starting keychain sync migration: synchronizable=\(synchronizable)")
208+
209+
let searchQuery: [String: Any] = [
210+
kSecClass as String: kSecClassGenericPassword,
211+
kSecAttrService as String: service,
212+
kSecUseDataProtectionKeychain as String: true,
213+
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
214+
kSecMatchLimit as String: kSecMatchLimitAll,
215+
kSecReturnAttributes as String: true,
216+
kSecReturnData as String: true
217+
]
218+
219+
var result: AnyObject?
220+
let status = SecItemCopyMatching(searchQuery as CFDictionary, &result)
221+
222+
guard status == errSecSuccess, let items = result as? [[String: Any]] else {
223+
if status == errSecItemNotFound {
224+
Self.logger.info("No keychain items to migrate")
225+
} else {
226+
Self.logger.error("Failed to query items for sync migration: \(status)")
227+
}
228+
return
229+
}
230+
231+
var migratedCount = 0
232+
var skippedCount = 0
233+
234+
for item in items {
235+
guard let account = item[kSecAttrAccount as String] as? String,
236+
let data = item[kSecValueData as String] as? Data
237+
else { continue }
238+
239+
let currentlySync = item[kSecAttrSynchronizable as String] as? Bool ?? false
240+
if currentlySync == synchronizable {
241+
skippedCount += 1
242+
continue
243+
}
244+
245+
var addQuery: [String: Any] = [
246+
kSecClass as String: kSecClassGenericPassword,
247+
kSecAttrService as String: service,
248+
kSecAttrAccount as String: account,
249+
kSecValueData as String: data,
250+
kSecUseDataProtectionKeychain as String: true,
251+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
252+
]
253+
if synchronizable {
254+
addQuery[kSecAttrSynchronizable as String] = true
255+
}
256+
257+
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
258+
259+
guard addStatus == errSecSuccess || addStatus == errSecDuplicateItem else {
260+
Self.logger.error(
261+
"Failed to create migrated item '\(account, privacy: .public)': \(addStatus)"
262+
)
263+
continue
264+
}
265+
266+
// When opting IN (synchronizable=true), delete the old local-only item safely.
267+
// When opting OUT (synchronizable=false), keep the synchronizable item — deleting it
268+
// would propagate via iCloud Keychain and remove it from other Macs still opted in.
269+
if synchronizable {
270+
let deleteQuery: [String: Any] = [
271+
kSecClass as String: kSecClassGenericPassword,
272+
kSecAttrService as String: service,
273+
kSecAttrAccount as String: account,
274+
kSecUseDataProtectionKeychain as String: true,
275+
kSecAttrSynchronizable as String: false
276+
]
277+
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
278+
if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound {
279+
Self.logger.warning(
280+
"Migrated item '\(account, privacy: .public)' but failed to delete old entry: \(deleteStatus)"
281+
)
282+
}
283+
}
284+
285+
migratedCount += 1
286+
}
287+
288+
Self.logger.info(
289+
"Keychain sync migration complete: \(migratedCount) migrated, \(skippedCount) already correct"
290+
)
291+
}
183292
}

TablePro/Models/Settings/SyncSettings.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,45 @@ struct SyncSettings: Codable, Equatable {
1515
var syncSettings: Bool
1616
var syncQueryHistory: Bool
1717
var historySyncLimit: HistorySyncLimit
18+
var syncPasswords: Bool
19+
20+
init(
21+
enabled: Bool,
22+
syncConnections: Bool,
23+
syncGroupsAndTags: Bool,
24+
syncSettings: Bool,
25+
syncQueryHistory: Bool,
26+
historySyncLimit: HistorySyncLimit,
27+
syncPasswords: Bool = false
28+
) {
29+
self.enabled = enabled
30+
self.syncConnections = syncConnections
31+
self.syncGroupsAndTags = syncGroupsAndTags
32+
self.syncSettings = syncSettings
33+
self.syncQueryHistory = syncQueryHistory
34+
self.historySyncLimit = historySyncLimit
35+
self.syncPasswords = syncPasswords
36+
}
37+
38+
init(from decoder: Decoder) throws {
39+
let container = try decoder.container(keyedBy: CodingKeys.self)
40+
enabled = try container.decode(Bool.self, forKey: .enabled)
41+
syncConnections = try container.decode(Bool.self, forKey: .syncConnections)
42+
syncGroupsAndTags = try container.decode(Bool.self, forKey: .syncGroupsAndTags)
43+
syncSettings = try container.decode(Bool.self, forKey: .syncSettings)
44+
syncQueryHistory = try container.decode(Bool.self, forKey: .syncQueryHistory)
45+
historySyncLimit = try container.decode(HistorySyncLimit.self, forKey: .historySyncLimit)
46+
syncPasswords = try container.decodeIfPresent(Bool.self, forKey: .syncPasswords) ?? false
47+
}
1848

1949
static let `default` = SyncSettings(
2050
enabled: false,
2151
syncConnections: true,
2252
syncGroupsAndTags: true,
2353
syncSettings: true,
2454
syncQueryHistory: true,
25-
historySyncLimit: .entries500
55+
historySyncLimit: .entries500,
56+
syncPasswords: false
2657
)
2758
}
2859

0 commit comments

Comments
 (0)