Skip to content

Commit 872997c

Browse files
committed
feat: add iCloud sync for connections, tags, settings, and templates
Implement opt-in iCloud sync via NSUbiquitousKeyValueStore with protocol-based architecture (SyncEngine/ICloudSyncEngine), three-way conflict detection, and a SwiftUI conflict resolution UI. Passwords remain in local Keychain and are never synced. Synced connections show a key badge when local password entry is needed.
1 parent e16e21b commit 872997c

16 files changed

Lines changed: 955 additions & 8 deletions

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- iCloud sync for connections, tags, settings, and table templates across Macs
13+
- Opt-in toggle in Settings > General (off by default)
14+
- Conflict resolution UI when local and remote data differ
15+
- Password badge indicator on synced connections that need local password entry
16+
- Protocol-based sync engine architecture (SyncEngine, ICloudSyncEngine)
17+
1018
## [0.1.1] - 2026-02-09
1119

1220
### Added

TablePro.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@
280280
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
281281
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
282282
AUTOMATION_APPLE_EVENTS = NO;
283+
CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements;
283284
CODE_SIGN_IDENTITY = "Apple Development";
284285
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
285286
CODE_SIGN_STYLE = Automatic;
@@ -367,6 +368,7 @@
367368
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
368369
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
369370
AUTOMATION_APPLE_EVENTS = NO;
371+
CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements;
370372
CODE_SIGN_IDENTITY = "Apple Development";
371373
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
372374
CODE_SIGN_STYLE = Automatic;

TablePro/Core/Storage/AppSettingsManager.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ final class AppSettingsManager: ObservableObject {
1919
@Published var general: GeneralSettings {
2020
didSet {
2121
storage.saveGeneral(general)
22+
23+
// Handle iCloud sync toggle changes
24+
if oldValue.iCloudSyncEnabled != general.iCloudSyncEnabled {
25+
if general.iCloudSyncEnabled {
26+
SyncCoordinator.shared.enable()
27+
} else {
28+
SyncCoordinator.shared.disable()
29+
}
30+
}
31+
32+
SyncCoordinator.shared.didUpdateGeneralSettings(general)
2233
notifyChange(domain: "general", notification: .generalSettingsDidChange)
2334
}
2435
}
@@ -27,6 +38,7 @@ final class AppSettingsManager: ObservableObject {
2738
didSet {
2839
storage.saveAppearance(appearance)
2940
appearance.theme.apply()
41+
SyncCoordinator.shared.didUpdateAppearanceSettings(appearance)
3042
notifyChange(domain: "appearance", notification: .appearanceSettingsDidChange)
3143
}
3244
}
@@ -36,6 +48,7 @@ final class AppSettingsManager: ObservableObject {
3648
storage.saveEditor(editor)
3749
// Update cached theme values for thread-safe access
3850
SQLEditorTheme.reloadFromSettings(editor)
51+
SyncCoordinator.shared.didUpdateEditorSettings(editor)
3952
notifyChange(domain: "editor", notification: .editorSettingsDidChange)
4053
}
4154
}
@@ -50,6 +63,7 @@ final class AppSettingsManager: ObservableObject {
5063
storage.saveDataGrid(validated)
5164
// Update date formatting service with new format
5265
DateFormattingService.shared.updateFormat(validated.dateFormat)
66+
SyncCoordinator.shared.didUpdateDataGridSettings(validated)
5367
notifyChange(domain: "dataGrid", notification: .dataGridSettingsDidChange)
5468
}
5569
}
@@ -64,6 +78,7 @@ final class AppSettingsManager: ObservableObject {
6478
storage.saveHistory(validated)
6579
// Apply history settings immediately (cleanup if auto-cleanup enabled)
6680
Task { await applyHistorySettingsImmediately() }
81+
SyncCoordinator.shared.didUpdateHistorySettings(validated)
6782
notifyChange(domain: "history", notification: .historySettingsDidChange)
6883
}
6984
}

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ final class ConnectionStorage {
4242
}
4343

4444
/// Save all connections
45-
func saveConnections(_ connections: [DatabaseConnection]) {
45+
func saveConnections(_ connections: [DatabaseConnection], triggeredBySync: Bool = false) {
4646
let storedConnections = connections.map { StoredConnection(from: $0) }
4747

4848
do {
@@ -52,6 +52,13 @@ final class ConnectionStorage {
5252
} catch {
5353
print("Failed to save connections: \(error)")
5454
}
55+
56+
// Push to iCloud if sync enabled (skip when applying remote data)
57+
if !triggeredBySync {
58+
Task { @MainActor in
59+
SyncCoordinator.shared.didUpdateConnections(connections)
60+
}
61+
}
5562
}
5663

5764
/// Add a new connection
@@ -130,6 +137,13 @@ final class ConnectionStorage {
130137
return duplicate
131138
}
132139

140+
// MARK: - Password Availability
141+
142+
/// Check if a connection has a local password stored in Keychain
143+
func hasPassword(for connectionId: UUID) -> Bool {
144+
loadPassword(for: connectionId) != nil
145+
}
146+
133147
// MARK: - Keychain (Password Storage)
134148

135149
/// Save password to Keychain

TablePro/Core/Storage/TableTemplateStorage.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,21 @@ final class TableTemplateStorage {
3535
// MARK: - Save/Load
3636

3737
/// Save a table template
38-
func saveTemplate(name: String, options: TableCreationOptions) throws {
38+
func saveTemplate(name: String, options: TableCreationOptions, triggeredBySync: Bool = false) throws {
3939
var templates = try loadTemplates()
4040
templates[name] = options
4141

4242
let encoder = JSONEncoder()
4343
encoder.outputFormatting = .prettyPrinted
4444
let data = try encoder.encode(templates)
4545
try data.write(to: templatesURL)
46+
47+
// Push to iCloud if sync enabled (skip when applying remote data)
48+
if !triggeredBySync {
49+
Task { @MainActor in
50+
SyncCoordinator.shared.didUpdateTemplates(templates)
51+
}
52+
}
4653
}
4754

4855
/// Load all templates
@@ -57,14 +64,21 @@ final class TableTemplateStorage {
5764
}
5865

5966
/// Delete a template
60-
func deleteTemplate(name: String) throws {
67+
func deleteTemplate(name: String, triggeredBySync: Bool = false) throws {
6168
var templates = try loadTemplates()
6269
templates.removeValue(forKey: name)
6370

6471
let encoder = JSONEncoder()
6572
encoder.outputFormatting = .prettyPrinted
6673
let data = try encoder.encode(templates)
6774
try data.write(to: templatesURL)
75+
76+
// Push to iCloud if sync enabled (skip when applying remote data)
77+
if !triggeredBySync {
78+
Task { @MainActor in
79+
SyncCoordinator.shared.didUpdateTemplates(templates)
80+
}
81+
}
6882
}
6983

7084
/// Get template names

TablePro/Core/Storage/TagStorage.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,21 @@ final class TagStorage {
4040
}
4141

4242
/// Save all tags
43-
func saveTags(_ tags: [ConnectionTag]) {
43+
func saveTags(_ tags: [ConnectionTag], triggeredBySync: Bool = false) {
4444
do {
4545
let encoder = JSONEncoder()
4646
let data = try encoder.encode(tags)
4747
defaults.set(data, forKey: tagsKey)
4848
} catch {
4949
print("Failed to save tags: \(error)")
5050
}
51+
52+
// Push to iCloud if sync enabled (skip when applying remote data)
53+
if !triggeredBySync {
54+
Task { @MainActor in
55+
SyncCoordinator.shared.didUpdateTags(tags)
56+
}
57+
}
5158
}
5259

5360
/// Add a new custom tag
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//
2+
// ICloudSyncEngine.swift
3+
// TablePro
4+
//
5+
// NSUbiquitousKeyValueStore implementation of SyncEngine.
6+
//
7+
8+
import Foundation
9+
10+
/// iCloud sync backend using NSUbiquitousKeyValueStore
11+
final class ICloudSyncEngine: SyncEngine {
12+
private let store = NSUbiquitousKeyValueStore.default
13+
private var observer: NSObjectProtocol?
14+
15+
var isAvailable: Bool {
16+
FileManager.default.ubiquityIdentityToken != nil
17+
}
18+
19+
func startObserving(onChange: @escaping ([String]) -> Void) {
20+
// Remove any existing observer first
21+
stopObserving()
22+
23+
observer = NotificationCenter.default.addObserver(
24+
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
25+
object: store,
26+
queue: .main
27+
) { notification in
28+
guard let userInfo = notification.userInfo,
29+
let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
30+
else {
31+
return
32+
}
33+
onChange(changedKeys)
34+
}
35+
36+
// Trigger initial pull from iCloud
37+
store.synchronize()
38+
}
39+
40+
func stopObserving() {
41+
if let observer {
42+
NotificationCenter.default.removeObserver(observer)
43+
}
44+
observer = nil
45+
}
46+
47+
func write(_ data: Data, forKey key: String) {
48+
store.set(data, forKey: key)
49+
}
50+
51+
func read(forKey key: String) -> Data? {
52+
store.data(forKey: key)
53+
}
54+
55+
func remove(forKey key: String) {
56+
store.removeObject(forKey: key)
57+
}
58+
59+
@discardableResult
60+
func synchronize() -> Bool {
61+
store.synchronize()
62+
}
63+
64+
deinit {
65+
stopObserving()
66+
}
67+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//
2+
// SyncConflict.swift
3+
// TablePro
4+
//
5+
// Models for sync conflict detection and resolution.
6+
//
7+
8+
import Foundation
9+
10+
/// Represents a sync conflict between local and remote data
11+
struct SyncConflict: Identifiable {
12+
let id = UUID()
13+
let syncKey: String
14+
let dataType: SyncDataType
15+
let remoteTimestamp: Date
16+
let remoteDeviceName: String
17+
let remoteData: Data
18+
19+
/// Human-readable summary for the conflict UI
20+
var summary: String {
21+
let formatter = RelativeDateTimeFormatter()
22+
formatter.unitsStyle = .full
23+
let remoteTime = formatter.localizedString(for: remoteTimestamp, relativeTo: Date())
24+
25+
return "Local: Modified on this Mac\nRemote: Modified \(remoteTime) on \(remoteDeviceName)"
26+
}
27+
}
28+
29+
/// User's choice for resolving a conflict
30+
enum ConflictResolution {
31+
case keepLocal
32+
case keepRemote
33+
}

0 commit comments

Comments
 (0)