Skip to content

Commit f98b745

Browse files
committed
feat: add optional iCloud Keychain sync for connection passwords
1 parent 60274f7 commit f98b745

8 files changed

Lines changed: 18299 additions & 18105 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5353
func applicationDidFinishLaunching(_ notification: Notification) {
5454
NSWindow.allowsAutomaticWindowTabbing = true
5555
KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded()
56+
let syncSettings = AppSettingsStorage.shared.loadSync()
57+
let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords
58+
UserDefaults.standard.set(passwordSyncExpected, forKey: "com.TablePro.keychainPasswordSyncEnabled")
5659
PluginManager.shared.loadPlugins()
5760

5861
Task { @MainActor in

TablePro/Core/Storage/KeychainHelper.swift

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,29 @@ 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+
private static let passwordSyncEnabledKey = "com.TablePro.keychainPasswordSyncEnabled"
17+
18+
private var isPasswordSyncEnabled: Bool {
19+
UserDefaults.standard.bool(forKey: Self.passwordSyncEnabledKey)
20+
}
1621

1722
private init() {}
1823

1924
// MARK: - Core Methods
2025

2126
@discardableResult
2227
func save(key: String, data: Data) -> Bool {
23-
let addQuery: [String: Any] = [
28+
var addQuery: [String: Any] = [
2429
kSecClass as String: kSecClassGenericPassword,
2530
kSecAttrService as String: service,
2631
kSecAttrAccount as String: key,
2732
kSecValueData as String: data,
2833
kSecUseDataProtectionKeychain as String: true,
2934
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
3035
]
36+
if isPasswordSyncEnabled {
37+
addQuery[kSecAttrSynchronizable as String] = true
38+
}
3139

3240
var status = SecItemAdd(addQuery as CFDictionary, nil)
3341

@@ -36,7 +44,8 @@ final class KeychainHelper {
3644
kSecClass as String: kSecClassGenericPassword,
3745
kSecAttrService as String: service,
3846
kSecAttrAccount as String: key,
39-
kSecUseDataProtectionKeychain as String: true
47+
kSecUseDataProtectionKeychain as String: true,
48+
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
4049
]
4150
let updateAttributes: [String: Any] = [
4251
kSecValueData as String: data
@@ -57,6 +66,7 @@ final class KeychainHelper {
5766
kSecAttrService as String: service,
5867
kSecAttrAccount as String: key,
5968
kSecUseDataProtectionKeychain as String: true,
69+
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
6070
kSecReturnData as String: true,
6171
kSecMatchLimit as String: kSecMatchLimitOne
6272
]
@@ -79,7 +89,8 @@ final class KeychainHelper {
7989
kSecClass as String: kSecClassGenericPassword,
8090
kSecAttrService as String: service,
8191
kSecAttrAccount as String: key,
82-
kSecUseDataProtectionKeychain as String: true
92+
kSecUseDataProtectionKeychain as String: true,
93+
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
8394
]
8495

8596
let status = SecItemDelete(query as CFDictionary)
@@ -180,4 +191,89 @@ final class KeychainHelper {
180191
Self.logger.warning("Legacy keychain migration incomplete, will retry on next launch")
181192
}
182193
}
194+
195+
// MARK: - Password Sync Migration
196+
197+
/// Migrates all TablePro keychain items between local-only and iCloud-synchronizable.
198+
func migratePasswordSyncState(synchronizable: Bool) {
199+
Self.logger.info("Starting keychain sync migration: synchronizable=\(synchronizable)")
200+
201+
let searchQuery: [String: Any] = [
202+
kSecClass as String: kSecClassGenericPassword,
203+
kSecAttrService as String: service,
204+
kSecUseDataProtectionKeychain as String: true,
205+
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
206+
kSecMatchLimit as String: kSecMatchLimitAll,
207+
kSecReturnAttributes as String: true,
208+
kSecReturnData as String: true
209+
]
210+
211+
var result: AnyObject?
212+
let status = SecItemCopyMatching(searchQuery as CFDictionary, &result)
213+
214+
guard status == errSecSuccess, let items = result as? [[String: Any]] else {
215+
if status == errSecItemNotFound {
216+
Self.logger.info("No keychain items to migrate")
217+
} else {
218+
Self.logger.error("Failed to query items for sync migration: \(status)")
219+
}
220+
return
221+
}
222+
223+
var migratedCount = 0
224+
var skippedCount = 0
225+
226+
for item in items {
227+
guard let account = item[kSecAttrAccount as String] as? String,
228+
let data = item[kSecValueData as String] as? Data
229+
else { continue }
230+
231+
let currentlySync = item[kSecAttrSynchronizable as String] as? Bool ?? false
232+
if currentlySync == synchronizable {
233+
skippedCount += 1
234+
continue
235+
}
236+
237+
var addQuery: [String: Any] = [
238+
kSecClass as String: kSecClassGenericPassword,
239+
kSecAttrService as String: service,
240+
kSecAttrAccount as String: account,
241+
kSecValueData as String: data,
242+
kSecUseDataProtectionKeychain as String: true,
243+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
244+
]
245+
if synchronizable {
246+
addQuery[kSecAttrSynchronizable as String] = true
247+
}
248+
249+
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
250+
251+
guard addStatus == errSecSuccess || addStatus == errSecDuplicateItem else {
252+
Self.logger.error(
253+
"Failed to create migrated item '\(account, privacy: .public)': \(addStatus)"
254+
)
255+
continue
256+
}
257+
258+
let deleteQuery: [String: Any] = [
259+
kSecClass as String: kSecClassGenericPassword,
260+
kSecAttrService as String: service,
261+
kSecAttrAccount as String: account,
262+
kSecUseDataProtectionKeychain as String: true,
263+
kSecAttrSynchronizable as String: !synchronizable as CFBoolean
264+
]
265+
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
266+
if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound {
267+
Self.logger.warning(
268+
"Migrated item '\(account, privacy: .public)' but failed to delete old entry: \(deleteStatus)"
269+
)
270+
}
271+
272+
migratedCount += 1
273+
}
274+
275+
Self.logger.info(
276+
"Keychain sync migration complete: \(migratedCount) migrated, \(skippedCount) already correct"
277+
)
278+
}
183279
}

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)