Skip to content

Commit d2f88c1

Browse files
authored
feat: add iCloud sync with Pro license gating (#321)
* docs: update setup and building docs for GitHub Releases lib hosting * feat: add iCloud sync with Pro license gating * fix: address PR review feedback for iCloud sync * fix: address code review issues in iCloud sync * fix: move delete tombstone after persistence, log encoding failures * docs: add iCloud sync documentation (EN/VI/ZH) * docs: fix heading case, bold UI paths, polish translations
1 parent 543430b commit d2f88c1

41 files changed

Lines changed: 3442 additions & 93 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

CLAUDE.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ TablePro is a native macOS database client (SwiftUI + AppKit) — a fast, lightw
99
- **Source**: `TablePro/``Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/`, `Theme/`
1010
- **Plugins**: `Plugins/``.tableplugin` bundles + `TableProPluginKit` shared framework. Built-in (bundled in app): MySQL, PostgreSQL, SQLite, CSV, JSON, SQL export. Separately distributed via plugin registry: ClickHouse, MSSQL, MongoDB, Redis, Oracle, DuckDB, XLSX, MQL, SQLImport
1111
- **C bridges**: Each plugin contains its own C bridge module (e.g., `Plugins/MySQLDriverPlugin/CMariaDB/`, `Plugins/PostgreSQLDriverPlugin/CLibPQ/`)
12-
- **Static libs**: `Libs/` — pre-built `libmariadb*.a`, `libpq*.a`, etc. (Git LFS tracked)
12+
- **Static libs**: `Libs/` — pre-built `libmariadb*.a`, `libpq*.a`, etc. Downloaded from GitHub Releases via `scripts/download-libs.sh` (not in git)
1313
- **SPM deps**: CodeEditSourceEditor (`main` branch, tree-sitter editor), Sparkle (2.8.1, auto-update), OracleNIO. Managed via Xcode, no `Package.swift`.
1414

1515
## Build & Development Commands
@@ -39,6 +39,25 @@ xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginV
3939

4040
# DMG
4141
scripts/create-dmg.sh
42+
43+
# Static libraries (first-time setup or after lib updates)
44+
scripts/download-libs.sh # Download from GitHub Releases (skips if already present)
45+
scripts/download-libs.sh --force # Re-download and overwrite
46+
```
47+
48+
### Updating Static Libraries
49+
50+
Static libs (`Libs/*.a`) are hosted on the `libs-v1` GitHub Release (not in git). When adding or updating a library:
51+
52+
```bash
53+
# 1. Update the .a files in Libs/
54+
# 2. Regenerate checksums
55+
shasum -a 256 Libs/*.a > Libs/checksums.sha256
56+
# 3. Recreate and upload the archive
57+
tar czf /tmp/tablepro-libs-v1.tar.gz -C Libs .
58+
gh release upload libs-v1 /tmp/tablepro-libs-v1.tar.gz --clobber --repo datlechin/TablePro
59+
# 4. Commit the updated checksums
60+
git add Libs/checksums.sha256 && git commit -m "build: update static library checksums"
4261
```
4362

4463
## Architecture

TablePro.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 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;

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: 12 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
@@ -173,6 +184,7 @@ final class AppSettingsStorage {
173184
saveTabs(.default)
174185
saveKeyboard(.default)
175186
saveAI(.default)
187+
saveSync(.default)
176188
}
177189

178190
// MARK: - Helpers

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ final class ConnectionStorage {
6767
var connections = loadConnections()
6868
connections.append(connection)
6969
saveConnections(connections)
70+
SyncChangeTracker.shared.markDirty(.connection, id: connection.id.uuidString)
7071

7172
if let password = password, !password.isEmpty {
7273
savePassword(password, for: connection.id)
@@ -79,6 +80,7 @@ final class ConnectionStorage {
7980
if let index = connections.firstIndex(where: { $0.id == connection.id }) {
8081
connections[index] = connection
8182
saveConnections(connections)
83+
SyncChangeTracker.shared.markDirty(.connection, id: connection.id.uuidString)
8284

8385
if let password = password {
8486
if password.isEmpty {
@@ -92,6 +94,7 @@ final class ConnectionStorage {
9294

9395
/// Delete a connection
9496
func deleteConnection(_ connection: DatabaseConnection) {
97+
SyncChangeTracker.shared.markDeleted(.connection, id: connection.id.uuidString)
9598
var connections = loadConnections()
9699
connections.removeAll { $0.id == connection.id }
97100
saveConnections(connections)
@@ -131,6 +134,7 @@ final class ConnectionStorage {
131134
var connections = loadConnections()
132135
connections.append(duplicate)
133136
saveConnections(connections)
137+
SyncChangeTracker.shared.markDirty(.connection, id: duplicate.id.uuidString)
134138

135139
// Copy all passwords from source to duplicate
136140
if let password = loadPassword(for: connection.id) {

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? {

0 commit comments

Comments
 (0)