Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Optional iCloud Keychain sync for connection passwords

## [0.20.2] - 2026-03-18

### Fixed
Expand Down
9 changes: 9 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {

func applicationDidFinishLaunching(_ notification: Notification) {
NSWindow.allowsAutomaticWindowTabbing = true
let syncSettings = AppSettingsStorage.shared.loadSync()
let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords
let previousSyncState = UserDefaults.standard.bool(forKey: KeychainHelper.passwordSyncEnabledKey)
UserDefaults.standard.set(passwordSyncExpected, forKey: KeychainHelper.passwordSyncEnabledKey)
KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded()
if passwordSyncExpected != previousSyncState {
Task.detached(priority: .background) {
KeychainHelper.shared.migratePasswordSyncState(synchronizable: passwordSyncExpected)
}
}
PluginManager.shared.loadPlugins()

Task { @MainActor in
Expand Down
117 changes: 113 additions & 4 deletions TablePro/Core/Storage/KeychainHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,46 @@ final class KeychainHelper {
private let service = "com.TablePro"
private static let logger = Logger(subsystem: "com.TablePro", category: "KeychainHelper")
private static let migrationKey = "com.TablePro.keychainMigratedToDataProtection"
static let passwordSyncEnabledKey = "com.TablePro.keychainPasswordSyncEnabled"

private let migrationLock = NSLock()

private var isPasswordSyncEnabled: Bool {
UserDefaults.standard.bool(forKey: Self.passwordSyncEnabledKey)
}

private init() {}

// MARK: - Core Methods

@discardableResult
func save(key: String, data: Data) -> Bool {
let addQuery: [String: Any] = [
var addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecUseDataProtectionKeychain as String: true,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
if isPasswordSyncEnabled {
addQuery[kSecAttrSynchronizable as String] = true
}

var status = SecItemAdd(addQuery as CFDictionary, nil)

if status == errSecDuplicateItem {
let synchronizable = isPasswordSyncEnabled
let searchQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecUseDataProtectionKeychain as String: true
kSecUseDataProtectionKeychain as String: true,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
let updateAttributes: [String: Any] = [
kSecValueData as String: data
kSecValueData as String: data,
kSecAttrSynchronizable as String: synchronizable
]
status = SecItemUpdate(searchQuery as CFDictionary, updateAttributes as CFDictionary)
}
Expand All @@ -57,6 +70,7 @@ final class KeychainHelper {
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecUseDataProtectionKeychain as String: true,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
Expand All @@ -79,7 +93,8 @@ final class KeychainHelper {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecUseDataProtectionKeychain as String: true
kSecUseDataProtectionKeychain as String: true,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]

let status = SecItemDelete(query as CFDictionary)
Expand Down Expand Up @@ -180,4 +195,98 @@ final class KeychainHelper {
Self.logger.warning("Legacy keychain migration incomplete, will retry on next launch")
}
}

// MARK: - Password Sync Migration

/// Migrates all TablePro keychain items between local-only and iCloud-synchronizable.
/// Serialized via `migrationLock` to prevent concurrent migrations from rapid toggling.
func migratePasswordSyncState(synchronizable: Bool) {
migrationLock.lock()
defer { migrationLock.unlock() }

Self.logger.info("Starting keychain sync migration: synchronizable=\(synchronizable)")

let searchQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecUseDataProtectionKeychain as String: true,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnData as String: true
]

var result: AnyObject?
let status = SecItemCopyMatching(searchQuery as CFDictionary, &result)

guard status == errSecSuccess, let items = result as? [[String: Any]] else {
if status == errSecItemNotFound {
Self.logger.info("No keychain items to migrate")
} else {
Self.logger.error("Failed to query items for sync migration: \(status)")
}
return
}

var migratedCount = 0
var skippedCount = 0

for item in items {
guard let account = item[kSecAttrAccount as String] as? String,
let data = item[kSecValueData as String] as? Data
else { continue }

let currentlySync = item[kSecAttrSynchronizable as String] as? Bool ?? false
if currentlySync == synchronizable {
skippedCount += 1
continue
}

var addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecUseDataProtectionKeychain as String: true,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
if synchronizable {
addQuery[kSecAttrSynchronizable as String] = true
}

let addStatus = SecItemAdd(addQuery as CFDictionary, nil)

guard addStatus == errSecSuccess || addStatus == errSecDuplicateItem else {
Self.logger.error(
"Failed to create migrated item '\(account, privacy: .public)': \(addStatus)"
)
continue
}

// When opting IN (synchronizable=true), delete the old local-only item safely.
// When opting OUT (synchronizable=false), keep the synchronizable item — deleting it
// would propagate via iCloud Keychain and remove it from other Macs still opted in.
if synchronizable {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
kSecAttrSynchronizable as String: false
]
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound {
Self.logger.warning(
"Migrated item '\(account, privacy: .public)' but failed to delete old entry: \(deleteStatus)"
)
}
}

migratedCount += 1
}

Self.logger.info(
"Keychain sync migration complete: \(migratedCount) migrated, \(skippedCount) already correct"
)
}
}
33 changes: 32 additions & 1 deletion TablePro/Models/Settings/SyncSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,45 @@ struct SyncSettings: Codable, Equatable {
var syncSettings: Bool
var syncQueryHistory: Bool
var historySyncLimit: HistorySyncLimit
var syncPasswords: Bool

init(
enabled: Bool,
syncConnections: Bool,
syncGroupsAndTags: Bool,
syncSettings: Bool,
syncQueryHistory: Bool,
historySyncLimit: HistorySyncLimit,
syncPasswords: Bool = false
) {
self.enabled = enabled
self.syncConnections = syncConnections
self.syncGroupsAndTags = syncGroupsAndTags
self.syncSettings = syncSettings
self.syncQueryHistory = syncQueryHistory
self.historySyncLimit = historySyncLimit
self.syncPasswords = syncPasswords
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
enabled = try container.decode(Bool.self, forKey: .enabled)
syncConnections = try container.decode(Bool.self, forKey: .syncConnections)
syncGroupsAndTags = try container.decode(Bool.self, forKey: .syncGroupsAndTags)
syncSettings = try container.decode(Bool.self, forKey: .syncSettings)
syncQueryHistory = try container.decode(Bool.self, forKey: .syncQueryHistory)
historySyncLimit = try container.decode(HistorySyncLimit.self, forKey: .historySyncLimit)
syncPasswords = try container.decodeIfPresent(Bool.self, forKey: .syncPasswords) ?? false
}

static let `default` = SyncSettings(
enabled: false,
syncConnections: true,
syncGroupsAndTags: true,
syncSettings: true,
syncQueryHistory: true,
historySyncLimit: .entries500
historySyncLimit: .entries500,
syncPasswords: false
)
}

Expand Down
Loading
Loading