Skip to content

Commit 6ea678e

Browse files
committed
refactor: clean up service layer, storage, and SwiftUI view patterns
Service layer: - DB-1: offload DatabaseManager driver setup to nonisolated helper - SVC-4: remove redundant NSLock from ExportService (@mainactor is sufficient) - PLG-3: audit PluginMetadataRegistry thread-safety, document contract Storage + sync: - STG-2: move CKServerChangeToken from UserDefaults to Application Support file - STG-3: delete stale iCloud Keychain copy on sync opt-out - CKS-2: change CloudKit save policy to .ifServerRecordUnchanged with conflict retry - APP-3.4: clean up transient connection Keychain entries after use SwiftUI views: - VIEW-1.3: replace NSAlert.runModal() with SwiftUI .alert in WelcomeWindowView - VIEW-1.4: extract duplicated connection-ready logic into single method - VIEW-2.2: replace NSNotificationCenter observer with async .task - VIEW-4.1: use minWidth/minHeight instead of fixed frame on Settings - VIEW-4.2: remove direct AppSettingsManager.shared from GeneralSettingsView - VIEW-5.1: remove focusConnectionFormWindow() polling loop - VIEW-7.1: deduplicate DoubleClickView into shared DoubleClickDetector
1 parent 74db7b3 commit 6ea678e

14 files changed

Lines changed: 253 additions & 271 deletions

TablePro/AppDelegate+ConnectionHandler.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ extension AppDelegate {
5151
connection = buildTransientConnection(from: parsed)
5252
}
5353

54+
let isTransient = matchedConnection == nil
55+
5456
if !parsed.password.isEmpty {
57+
// Save password to Keychain so the driver can resolve it at connect time.
58+
// For transient connections, this is cleaned up after connect completes or fails.
5559
ConnectionStorage.shared.savePassword(parsed.password, for: connection.id)
5660
}
5761

@@ -70,6 +74,14 @@ extension AppDelegate {
7074
openNewConnectionWindow(for: connection)
7175

7276
Task { @MainActor in
77+
defer {
78+
// Clean up Keychain entry for transient connections after connect.
79+
// The driver already holds the password in memory once connected,
80+
// so the Keychain entry is no longer needed.
81+
if isTransient {
82+
ConnectionStorage.shared.deletePassword(for: connection.id)
83+
}
84+
}
7385
do {
7486
try await DatabaseManager.shared.connectToSession(connection)
7587
for window in NSApp.windows where self.isWelcomeWindow(window) {

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -139,17 +139,11 @@ final class DatabaseManager {
139139
}
140140

141141
do {
142-
try await driver.connect()
143-
144-
// Apply query timeout from settings
145-
let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds
146-
if timeoutSeconds > 0 {
147-
try await driver.applyQueryTimeout(timeoutSeconds)
148-
}
149-
150-
// Run startup commands before schema init
151-
await executeStartupCommands(
152-
connection.startupCommands, on: driver, connectionName: connection.name
142+
// Perform heavy driver setup off the main actor
143+
try await performDriverSetup(
144+
driver: driver,
145+
startupCommands: connection.startupCommands,
146+
connectionName: connection.name
153147
)
154148

155149
// Initialize schema for drivers that support schema switching
@@ -605,16 +599,12 @@ final class DatabaseManager {
605599
// Use effective connection (tunneled) if available, otherwise original
606600
let connectionForDriver = session.effectiveConnection ?? session.connection
607601
let driver = try DatabaseDriverFactory.createDriver(for: connectionForDriver)
608-
try await driver.connect()
609-
610-
// Apply timeout
611-
let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds
612-
if timeoutSeconds > 0 {
613-
try await driver.applyQueryTimeout(timeoutSeconds)
614-
}
615602

616-
await executeStartupCommands(
617-
session.connection.startupCommands, on: driver, connectionName: session.connection.name
603+
// Perform heavy driver setup off the main actor
604+
try await performDriverSetup(
605+
driver: driver,
606+
startupCommands: session.connection.startupCommands,
607+
connectionName: session.connection.name
618608
)
619609

620610
if let savedSchema = session.currentSchema,
@@ -667,16 +657,12 @@ final class DatabaseManager {
667657

668658
// Create new driver and connect
669659
let driver = try DatabaseDriverFactory.createDriver(for: effectiveConnection)
670-
try await driver.connect()
671-
672-
// Apply timeout
673-
let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds
674-
if timeoutSeconds > 0 {
675-
try await driver.applyQueryTimeout(timeoutSeconds)
676-
}
677660

678-
await executeStartupCommands(
679-
session.connection.startupCommands, on: driver, connectionName: session.connection.name
661+
// Perform heavy driver setup off the main actor
662+
try await performDriverSetup(
663+
driver: driver,
664+
startupCommands: session.connection.startupCommands,
665+
connectionName: session.connection.name
680666
)
681667

682668
if let savedSchema = activeSessions[sessionId]?.currentSchema,
@@ -768,6 +754,22 @@ final class DatabaseManager {
768754

769755
nonisolated private static let startupLogger = Logger(subsystem: "com.TablePro", category: "DatabaseManager")
770756

757+
/// Connects the driver, applies query timeout, and runs startup commands off the main actor.
758+
nonisolated private func performDriverSetup(
759+
driver: DatabaseDriver,
760+
startupCommands: String?,
761+
connectionName: String
762+
) async throws {
763+
try await driver.connect()
764+
765+
let timeoutSeconds = await AppSettingsManager.shared.general.queryTimeoutSeconds
766+
if timeoutSeconds > 0 {
767+
try await driver.applyQueryTimeout(timeoutSeconds)
768+
}
769+
770+
await executeStartupCommands(startupCommands, on: driver, connectionName: connectionName)
771+
}
772+
771773
nonisolated private func executeStartupCommands(
772774
_ commands: String?, on driver: DatabaseDriver, connectionName: String
773775
) async {

TablePro/Core/Plugins/PluginMetadataRegistry.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ struct PluginMetadataSnapshot: Sendable {
135135
}
136136
}
137137

138+
/// Thread-safety contract:
139+
/// - All mutable state (`snapshots`, `schemeIndex`, `reverseTypeIndex`) is protected by `lock`.
140+
/// - `registerBuiltInDefaults()` accesses state without the lock but is only called from `init()`,
141+
/// before the singleton is published to other threads.
142+
/// - `buildMetadataSnapshot(from:isDownloadable:)` is a pure function — no shared state access.
143+
/// - Not converted to a Swift `actor` because most call sites are synchronous; making every
144+
/// lookup `async` would be too invasive.
138145
final class PluginMetadataRegistry: @unchecked Sendable {
139146
static let shared = PluginMetadataRegistry()
140147

TablePro/Core/Services/Export/ExportService.swift

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,7 @@ final class ExportService {
7272

7373
// MARK: - Cancellation
7474

75-
private let isCancelledLock = NSLock()
76-
private var _isCancelled: Bool = false
77-
var isCancelled: Bool {
78-
get {
79-
isCancelledLock.lock()
80-
defer { isCancelledLock.unlock() }
81-
return _isCancelled
82-
}
83-
set {
84-
isCancelledLock.lock()
85-
_isCancelled = newValue
86-
isCancelledLock.unlock()
87-
}
88-
}
75+
private(set) var isCancelled = false
8976

9077
func cancelExport() {
9178
isCancelled = true

TablePro/Core/Storage/KeychainHelper.swift

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,8 @@ final class KeychainHelper {
263263
continue
264264
}
265265

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.
269266
if synchronizable {
267+
// When opting IN: delete the old local-only item safely.
270268
let deleteQuery: [String: Any] = [
271269
kSecClass as String: kSecClassGenericPassword,
272270
kSecAttrService as String: service,
@@ -277,7 +275,24 @@ final class KeychainHelper {
277275
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
278276
if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound {
279277
Self.logger.warning(
280-
"Migrated item '\(account, privacy: .public)' but failed to delete old entry: \(deleteStatus)"
278+
"Migrated item '\(account, privacy: .public)' but failed to delete old local entry: \(deleteStatus)"
279+
)
280+
}
281+
} else {
282+
// When opting OUT: delete the stale synchronizable copy so it doesn't
283+
// linger in iCloud Keychain. This will remove the credential from other
284+
// devices that have iCloud Keychain enabled.
285+
let deleteSyncQuery: [String: Any] = [
286+
kSecClass as String: kSecClassGenericPassword,
287+
kSecAttrService as String: service,
288+
kSecAttrAccount as String: account,
289+
kSecUseDataProtectionKeychain as String: true,
290+
kSecAttrSynchronizable as String: true
291+
]
292+
let deleteStatus = SecItemDelete(deleteSyncQuery as CFDictionary)
293+
if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound {
294+
Self.logger.warning(
295+
"Migrated item '\(account, privacy: .public)' but failed to delete old synchronizable entry: \(deleteStatus)"
281296
)
282297
}
283298
}

TablePro/Core/Sync/CloudKitSyncEngine.swift

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,38 +79,98 @@ actor CloudKitSyncEngine {
7979
}
8080

8181
private func pushBatch(records: [CKRecord], deletions: [CKRecord.ID]) async throws {
82+
var recordsToSave = records
83+
84+
for attempt in 0..<Self.maxRetries {
85+
let conflictedRecords = try await performPushOperation(
86+
records: recordsToSave,
87+
deletions: attempt == 0 ? deletions : []
88+
)
89+
90+
if conflictedRecords.isEmpty { return }
91+
92+
Self.logger.info(
93+
"Resolving \(conflictedRecords.count) conflict(s) (attempt \(attempt + 1)/\(Self.maxRetries))"
94+
)
95+
96+
// Re-apply local changes onto the server's version of each conflicted record
97+
recordsToSave = resolveConflicts(
98+
localRecords: recordsToSave,
99+
serverRecords: conflictedRecords
100+
)
101+
}
102+
103+
throw SyncError.unknown("Push failed after \(Self.maxRetries) conflict resolution attempts")
104+
}
105+
106+
private func performPushOperation(
107+
records: [CKRecord],
108+
deletions: [CKRecord.ID]
109+
) async throws -> [CKRecord] {
82110
try await withRetry {
83111
let operation = CKModifyRecordsOperation(
84112
recordsToSave: records,
85113
recordIDsToDelete: deletions
86114
)
87-
// Use .changedKeys so we don't need to track server change tags
88-
// This overwrites only the fields we set, which is safe for our use case
89-
operation.savePolicy = .changedKeys
115+
// Use .ifServerRecordUnchanged to detect concurrent modifications.
116+
// Conflicts are resolved by re-applying local changes onto the server record.
117+
operation.savePolicy = .ifServerRecordUnchanged
90118
operation.isAtomic = false
91119

92120
return try await withCheckedThrowingContinuation { continuation in
121+
var conflicted: [CKRecord] = []
122+
93123
operation.perRecordSaveBlock = { recordID, result in
94124
if case .failure(let error) = result {
95-
Self.logger.error(
96-
"Failed to save record \(recordID.recordName): \(error.localizedDescription)"
97-
)
125+
if let ckError = error as? CKError,
126+
ckError.code == .serverRecordChanged,
127+
let serverRecord = ckError.serverRecord {
128+
conflicted.append(serverRecord)
129+
} else {
130+
Self.logger.error(
131+
"Failed to save record \(recordID.recordName): \(error.localizedDescription)"
132+
)
133+
}
98134
}
99135
}
100136

101137
operation.modifyRecordsResultBlock = { result in
102138
switch result {
103139
case .success:
104-
continuation.resume()
140+
continuation.resume(returning: conflicted)
105141
case .failure(let error):
106-
continuation.resume(throwing: error)
142+
// If the overall operation failed but we have conflicts, return them
143+
if !conflicted.isEmpty {
144+
continuation.resume(returning: conflicted)
145+
} else {
146+
continuation.resume(throwing: error)
147+
}
107148
}
108149
}
109150
self.database.add(operation)
110151
}
111152
}
112153
}
113154

155+
/// Re-applies local field values onto the server's latest version of each conflicted record.
156+
private func resolveConflicts(
157+
localRecords: [CKRecord],
158+
serverRecords: [CKRecord]
159+
) -> [CKRecord] {
160+
let localByID = Dictionary(localRecords.map { ($0.recordID, $0) }, uniquingKeysWith: { _, new in new })
161+
162+
return serverRecords.compactMap { serverRecord in
163+
guard let localRecord = localByID[serverRecord.recordID] else { return nil }
164+
165+
// Copy all locally-set fields onto the server record
166+
for key in localRecord.allKeys() {
167+
serverRecord[key] = localRecord[key]
168+
}
169+
170+
return serverRecord
171+
}
172+
}
173+
114174
// MARK: - Pull
115175

116176
func pull(since token: CKServerChangeToken?) async throws -> PullResult {

TablePro/Core/Sync/SyncMetadataStorage.swift

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,34 @@ final class SyncMetadataStorage {
1616

1717
private let defaults = UserDefaults.standard
1818

19+
/// File URL for the sync token, stored in Application Support to avoid
20+
/// accidental iCloud sync that can happen with UserDefaults.
21+
private let syncTokenFileURL: URL = {
22+
guard let appSupport = FileManager.default.urls(
23+
for: .applicationSupportDirectory,
24+
in: .userDomainMask
25+
).first else {
26+
// Fallback to temp directory; should never happen on macOS
27+
return FileManager.default.temporaryDirectory.appendingPathComponent("sync-token.data")
28+
}
29+
let directory = appSupport.appendingPathComponent("TablePro", isDirectory: true)
30+
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
31+
return directory.appendingPathComponent("sync-token.data")
32+
}()
33+
1934
private enum Keys {
20-
static let syncToken = "com.TablePro.sync.serverChangeToken"
2135
static let dirtyPrefix = "com.TablePro.sync.dirty."
2236
static let tombstonePrefix = "com.TablePro.sync.tombstones."
2337
static let lastSyncDate = "com.TablePro.sync.lastSyncDate"
2438
static let lastAccountId = "com.TablePro.sync.lastAccountId"
2539
}
2640

27-
private init() {}
41+
/// Legacy UserDefaults key, used only for one-time migration.
42+
private static let legacySyncTokenKey = "com.TablePro.sync.serverChangeToken"
43+
44+
private init() {
45+
migrateSyncTokenFromUserDefaultsIfNeeded()
46+
}
2847

2948
// MARK: - Server Change Token
3049

@@ -34,18 +53,18 @@ final class SyncMetadataStorage {
3453
withRootObject: token,
3554
requiringSecureCoding: true
3655
)
37-
defaults.set(data, forKey: Keys.syncToken)
56+
try data.write(to: syncTokenFileURL, options: .atomic)
3857
} catch {
39-
Self.logger.error("Failed to archive sync token: \(error.localizedDescription)")
58+
Self.logger.error("Failed to save sync token to file: \(error.localizedDescription)")
4059
}
4160
}
4261

4362
func clearSyncToken() {
44-
defaults.removeObject(forKey: Keys.syncToken)
63+
try? FileManager.default.removeItem(at: syncTokenFileURL)
4564
}
4665

4766
func loadSyncToken() -> CKServerChangeToken? {
48-
guard let data = defaults.data(forKey: Keys.syncToken) else { return nil }
67+
guard let data = try? Data(contentsOf: syncTokenFileURL) else { return nil }
4968

5069
do {
5170
return try NSKeyedUnarchiver.unarchivedObject(
@@ -58,6 +77,21 @@ final class SyncMetadataStorage {
5877
}
5978
}
6079

80+
// MARK: - Migration
81+
82+
/// One-time migration of sync token from UserDefaults to Application Support file.
83+
private func migrateSyncTokenFromUserDefaultsIfNeeded() {
84+
guard let data = defaults.data(forKey: Self.legacySyncTokenKey) else { return }
85+
86+
do {
87+
try data.write(to: syncTokenFileURL, options: .atomic)
88+
defaults.removeObject(forKey: Self.legacySyncTokenKey)
89+
Self.logger.info("Migrated sync token from UserDefaults to Application Support")
90+
} catch {
91+
Self.logger.error("Failed to migrate sync token to file: \(error.localizedDescription)")
92+
}
93+
}
94+
6195
// MARK: - Dirty Entity Tracking
6296

6397
func addDirty(type: SyncRecordType, id: String) {
@@ -167,7 +201,7 @@ final class SyncMetadataStorage {
167201
// MARK: - Clear All
168202

169203
func clearAll() {
170-
defaults.removeObject(forKey: Keys.syncToken)
204+
clearSyncToken()
171205
defaults.removeObject(forKey: Keys.lastSyncDate)
172206
defaults.removeObject(forKey: Keys.lastAccountId)
173207

0 commit comments

Comments
 (0)