Skip to content

Commit 3a77c6d

Browse files
committed
feat: add iCloud sync with Pro license gating
1 parent 4f74465 commit 3a77c6d

28 files changed

Lines changed: 2617 additions & 5 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 (Pro): sync connections, groups, tags, settings, and query history across Macs via CloudKit
13+
- Pro feature gating system with license-aware UI overlay for Pro-only features
14+
- Sync settings tab with per-category toggles and configurable history sync limit
15+
- Sync status indicator in welcome window showing real-time sync state
16+
- Conflict resolution dialog for handling simultaneous edits across devices
17+
1018
## [0.18.1] - 2026-03-14
1119

1220
### Fixed

TablePro.xcodeproj/project.pbxproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,11 +1664,11 @@
16641664
AUTOMATION_APPLE_EVENTS = NO;
16651665
CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements;
16661666
CODE_SIGN_IDENTITY = "Apple Development";
1667-
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
1667+
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
16681668
CODE_SIGN_STYLE = Automatic;
16691669
CURRENT_PROJECT_VERSION = 33;
16701670
DEAD_CODE_STRIPPING = YES;
1671-
DEVELOPMENT_TEAM = "";
1671+
DEVELOPMENT_TEAM = D7HJ5TFYCU;
16721672
ENABLE_APP_SANDBOX = NO;
16731673
ENABLE_HARDENED_RUNTIME = YES;
16741674
ENABLE_PREVIEWS = YES;
@@ -1735,7 +1735,7 @@
17351735
AUTOMATION_APPLE_EVENTS = NO;
17361736
CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements;
17371737
CODE_SIGN_IDENTITY = "Apple Development";
1738-
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
1738+
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
17391739
CODE_SIGN_STYLE = Automatic;
17401740
COPY_PHASE_STRIP = YES;
17411741
CURRENT_PROJECT_VERSION = 33;

TablePro/AppDelegate.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6060

6161
AnalyticsService.shared.startPeriodicHeartbeat()
6262

63+
SyncCoordinator.shared.start()
64+
6365
Task.detached(priority: .background) {
6466
_ = QueryHistoryStorage.shared
6567
}
@@ -96,6 +98,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
9698
)
9799
}
98100

101+
func applicationDidBecomeActive(_ notification: Notification) {
102+
SyncCoordinator.shared.syncIfNeeded()
103+
}
104+
99105
func applicationWillTerminate(_ notification: Notification) {
100106
SSHTunnelManager.shared.terminateAllProcessesSync()
101107
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// LicenseManager+Pro.swift
3+
// TablePro
4+
//
5+
// Pro feature gating methods
6+
//
7+
8+
import Foundation
9+
10+
extension LicenseManager {
11+
/// Check if a Pro feature is available (convenience for boolean checks)
12+
func isFeatureAvailable(_ feature: ProFeature) -> Bool {
13+
status.isValid
14+
}
15+
16+
/// Check feature availability with detailed access result
17+
func checkFeature(_ feature: ProFeature) -> ProFeatureAccess {
18+
if status.isValid {
19+
return .available
20+
}
21+
22+
switch status {
23+
case .expired:
24+
return .expired
25+
default:
26+
return .unlicensed
27+
}
28+
}
29+
}

TablePro/Core/Storage/AppSettingsManager.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ final class AppSettingsManager {
2323
didSet {
2424
general.language.apply()
2525
storage.saveGeneral(general)
26+
SyncChangeTracker.shared.markDirty(.settings, id: "general")
2627
}
2728
}
2829

@@ -31,6 +32,7 @@ final class AppSettingsManager {
3132
storage.saveAppearance(appearance)
3233
ThemeEngine.shared.activateTheme(id: appearance.activeThemeId)
3334
ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode)
35+
SyncChangeTracker.shared.markDirty(.settings, id: "appearance")
3436
}
3537
}
3638

@@ -46,6 +48,7 @@ final class AppSettingsManager {
4648
wordWrap: editor.wordWrap
4749
)
4850
notifyChange(.editorSettingsDidChange)
51+
SyncChangeTracker.shared.markDirty(.settings, id: "editor")
4952
}
5053
}
5154

@@ -68,6 +71,7 @@ final class AppSettingsManager {
6871
// Update date formatting service with new format
6972
DateFormattingService.shared.updateFormat(validated.dateFormat)
7073
notifyChange(.dataGridSettingsDidChange)
74+
SyncChangeTracker.shared.markDirty(.settings, id: "dataGrid")
7175
}
7276
}
7377

@@ -89,24 +93,28 @@ final class AppSettingsManager {
8993
storage.saveHistory(validated)
9094
// Apply history settings immediately (cleanup if auto-cleanup enabled)
9195
Task { await applyHistorySettingsImmediately() }
96+
SyncChangeTracker.shared.markDirty(.settings, id: "history")
9297
}
9398
}
9499

95100
var tabs: TabSettings {
96101
didSet {
97102
storage.saveTabs(tabs)
103+
SyncChangeTracker.shared.markDirty(.settings, id: "tabs")
98104
}
99105
}
100106

101107
var keyboard: KeyboardSettings {
102108
didSet {
103109
storage.saveKeyboard(keyboard)
110+
SyncChangeTracker.shared.markDirty(.settings, id: "keyboard")
104111
}
105112
}
106113

107114
var ai: AISettings {
108115
didSet {
109116
storage.saveAI(ai)
117+
SyncChangeTracker.shared.markDirty(.settings, id: "ai")
110118
}
111119
}
112120

TablePro/Core/Storage/AppSettingsStorage.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ final class AppSettingsStorage {
2929
static let tabs = "com.TablePro.settings.tabs"
3030
static let keyboard = "com.TablePro.settings.keyboard"
3131
static let ai = "com.TablePro.settings.ai"
32+
static let sync = "com.TablePro.settings.sync"
3233
static let lastConnectionId = "com.TablePro.settings.lastConnectionId"
3334
static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding"
3435
}
@@ -116,6 +117,16 @@ final class AppSettingsStorage {
116117
save(settings, key: Keys.ai)
117118
}
118119

120+
// MARK: - Sync Settings
121+
122+
func loadSync() -> SyncSettings {
123+
load(key: Keys.sync, default: .default)
124+
}
125+
126+
func saveSync(_ settings: SyncSettings) {
127+
save(settings, key: Keys.sync)
128+
}
129+
119130
// MARK: - Last Connection (for Reopen Last Session)
120131

121132
/// Load the last used connection ID

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ final class ConnectionStorage {
6060
} catch {
6161
Self.logger.error("Failed to save connections: \(error)")
6262
}
63+
64+
// Mark all saved connections as dirty for sync
65+
SyncChangeTracker.shared.markDirty(.connection, ids: connections.map { $0.id.uuidString })
6366
}
6467

6568
/// Add a new connection
@@ -92,6 +95,7 @@ final class ConnectionStorage {
9295

9396
/// Delete a connection
9497
func deleteConnection(_ connection: DatabaseConnection) {
98+
SyncChangeTracker.shared.markDeleted(.connection, id: connection.id.uuidString)
9599
var connections = loadConnections()
96100
connections.removeAll { $0.id == connection.id }
97101
saveConnections(connections)

TablePro/Core/Storage/GroupStorage.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ final class GroupStorage {
3939
do {
4040
let data = try encoder.encode(groups)
4141
defaults.set(data, forKey: groupsKey)
42+
SyncChangeTracker.shared.markDirty(.group, ids: groups.map { $0.id.uuidString })
4243
} catch {
4344
Self.logger.error("Failed to save groups: \(error)")
4445
}
@@ -65,6 +66,7 @@ final class GroupStorage {
6566

6667
/// Delete a group
6768
func deleteGroup(_ group: ConnectionGroup) {
69+
SyncChangeTracker.shared.markDeleted(.group, id: group.id.uuidString)
6870
var groups = loadGroups()
6971
groups.removeAll { $0.id == group.id }
7072
saveGroups(groups)

TablePro/Core/Storage/QueryHistoryStorage.swift

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ final class QueryHistoryStorage {
164164
execution_time REAL NOT NULL,
165165
row_count INTEGER NOT NULL,
166166
was_successful INTEGER NOT NULL,
167-
error_message TEXT
167+
error_message TEXT,
168+
is_synced INTEGER DEFAULT 0
168169
);
169170
"""
170171

@@ -205,6 +206,7 @@ final class QueryHistoryStorage {
205206

206207
// Execute all table creation statements
207208
execute(historyTable)
209+
migrateAddIsSyncedColumn()
208210
execute(ftsTable)
209211
execute(ftsInsertTrigger)
210212
execute(ftsDeleteTrigger)
@@ -548,6 +550,80 @@ final class QueryHistoryStorage {
548550
}
549551
}
550552

553+
// MARK: - Sync Support
554+
555+
/// Migration: add is_synced column if the table was created before sync support
556+
private func migrateAddIsSyncedColumn() {
557+
// Check if column already exists by querying table info
558+
let sql = "PRAGMA table_info(history);"
559+
var statement: OpaquePointer?
560+
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return }
561+
defer { sqlite3_finalize(statement) }
562+
563+
var hasIsSynced = false
564+
while sqlite3_step(statement) == SQLITE_ROW {
565+
if let name = sqlite3_column_text(statement, 1).map({ String(cString: $0) }),
566+
name == "is_synced" {
567+
hasIsSynced = true
568+
break
569+
}
570+
}
571+
572+
if !hasIsSynced {
573+
execute("ALTER TABLE history ADD COLUMN is_synced INTEGER DEFAULT 0;")
574+
Self.logger.info("Migrated history table: added is_synced column")
575+
}
576+
}
577+
578+
/// Mark history entries as synced
579+
func markHistoryEntriesSynced(ids: [String]) async {
580+
guard !ids.isEmpty else { return }
581+
await performDatabaseWork { [weak self] in
582+
guard let self else { return }
583+
584+
let placeholders = ids.map { _ in "?" }.joined(separator: ", ")
585+
let sql = "UPDATE history SET is_synced = 1 WHERE id IN (\(placeholders));"
586+
587+
var statement: OpaquePointer?
588+
guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { return }
589+
defer { sqlite3_finalize(statement) }
590+
591+
let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
592+
for (index, id) in ids.enumerated() {
593+
sqlite3_bind_text(statement, Int32(index + 1), id, -1, SQLITE_TRANSIENT)
594+
}
595+
sqlite3_step(statement)
596+
}
597+
}
598+
599+
/// Fetch unsynced history entries
600+
func unsyncedHistoryEntries(limit: Int) async -> [QueryHistoryEntry] {
601+
await performDatabaseWork { [weak self] in
602+
guard let self else { return [] }
603+
604+
let sql = """
605+
SELECT id, query, connection_id, database_name, executed_at, execution_time, row_count, was_successful, error_message
606+
FROM history WHERE is_synced = 0 ORDER BY executed_at DESC LIMIT ?;
607+
"""
608+
609+
var statement: OpaquePointer?
610+
guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else {
611+
return []
612+
}
613+
defer { sqlite3_finalize(statement) }
614+
615+
sqlite3_bind_int(statement, 1, Int32(limit))
616+
617+
var entries: [QueryHistoryEntry] = []
618+
while sqlite3_step(statement) == SQLITE_ROW {
619+
if let entry = self.parseHistoryEntry(from: statement) {
620+
entries.append(entry)
621+
}
622+
}
623+
return entries
624+
}
625+
}
626+
551627
// MARK: - Parsing Helpers
552628

553629
private func parseHistoryEntry(from statement: OpaquePointer?) -> QueryHistoryEntry? {

TablePro/Core/Storage/TagStorage.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ final class TagStorage {
4747
do {
4848
let data = try encoder.encode(tags)
4949
defaults.set(data, forKey: tagsKey)
50+
SyncChangeTracker.shared.markDirty(.tag, ids: tags.map { $0.id.uuidString })
5051
} catch {
5152
Self.logger.error("Failed to save tags: \(error)")
5253
}
@@ -66,6 +67,7 @@ final class TagStorage {
6667
/// Delete a custom tag (presets cannot be deleted)
6768
func deleteTag(_ tag: ConnectionTag) {
6869
guard !tag.isPreset else { return }
70+
SyncChangeTracker.shared.markDeleted(.tag, id: tag.id.uuidString)
6971
var tags = loadTags()
7072
tags.removeAll { $0.id == tag.id }
7173
saveTags(tags)

0 commit comments

Comments
 (0)