Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- iCloud Sync (Pro): sync connections, groups, tags, settings, and query history across Macs via CloudKit
- Pro feature gating system with license-aware UI overlay for Pro-only features
- Sync settings tab with per-category toggles and configurable history sync limit
- Sync status indicator in welcome window showing real-time sync state
- Conflict resolution dialog for handling simultaneous edits across devices

## [0.18.1] - 2026-03-14

### Fixed
Expand Down
21 changes: 20 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ TablePro is a native macOS database client (SwiftUI + AppKit) — a fast, lightw
- **Source**: `TablePro/` — `Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/`, `Theme/`
- **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
- **C bridges**: Each plugin contains its own C bridge module (e.g., `Plugins/MySQLDriverPlugin/CMariaDB/`, `Plugins/PostgreSQLDriverPlugin/CLibPQ/`)
- **Static libs**: `Libs/` — pre-built `libmariadb*.a`, `libpq*.a`, etc. (Git LFS tracked)
- **Static libs**: `Libs/` — pre-built `libmariadb*.a`, `libpq*.a`, etc. Downloaded from GitHub Releases via `scripts/download-libs.sh` (not in git)
- **SPM deps**: CodeEditSourceEditor (`main` branch, tree-sitter editor), Sparkle (2.8.1, auto-update), OracleNIO. Managed via Xcode, no `Package.swift`.

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

# DMG
scripts/create-dmg.sh

# Static libraries (first-time setup or after lib updates)
scripts/download-libs.sh # Download from GitHub Releases (skips if already present)
scripts/download-libs.sh --force # Re-download and overwrite
```

### Updating Static Libraries

Static libs (`Libs/*.a`) are hosted on the `libs-v1` GitHub Release (not in git). When adding or updating a library:

```bash
# 1. Update the .a files in Libs/
# 2. Regenerate checksums
shasum -a 256 Libs/*.a > Libs/checksums.sha256
# 3. Recreate and upload the archive
tar czf /tmp/tablepro-libs-v1.tar.gz -C Libs .
gh release upload libs-v1 /tmp/tablepro-libs-v1.tar.gz --clobber --repo datlechin/TablePro
# 4. Commit the updated checksums
git add Libs/checksums.sha256 && git commit -m "build: update static library checksums"
```

## Architecture
Expand Down
4 changes: 2 additions & 2 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1664,11 +1664,11 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = D7HJ5TFYCU;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
Expand Down
6 changes: 6 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {

AnalyticsService.shared.startPeriodicHeartbeat()

SyncCoordinator.shared.start()

Task.detached(priority: .background) {
_ = QueryHistoryStorage.shared
}
Expand Down Expand Up @@ -96,6 +98,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
)
}

func applicationDidBecomeActive(_ notification: Notification) {
SyncCoordinator.shared.syncIfNeeded()
}

func applicationWillTerminate(_ notification: Notification) {
SSHTunnelManager.shared.terminateAllProcessesSync()
}
Expand Down
29 changes: 29 additions & 0 deletions TablePro/Core/Services/Licensing/LicenseManager+Pro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// LicenseManager+Pro.swift
// TablePro
//
// Pro feature gating methods
//

import Foundation

extension LicenseManager {
/// Check if a Pro feature is available (convenience for boolean checks)
func isFeatureAvailable(_ feature: ProFeature) -> Bool {
status.isValid
}

/// Check feature availability with detailed access result
func checkFeature(_ feature: ProFeature) -> ProFeatureAccess {
if status.isValid {
return .available
}

switch status {
case .expired:
return .expired
default:
return .unlicensed
}
}
}
8 changes: 8 additions & 0 deletions TablePro/Core/Storage/AppSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class AppSettingsManager {
didSet {
general.language.apply()
storage.saveGeneral(general)
SyncChangeTracker.shared.markDirty(.settings, id: "general")
}
}

Expand All @@ -31,6 +32,7 @@ final class AppSettingsManager {
storage.saveAppearance(appearance)
ThemeEngine.shared.activateTheme(id: appearance.activeThemeId)
ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode)
SyncChangeTracker.shared.markDirty(.settings, id: "appearance")
}
}

Expand All @@ -46,6 +48,7 @@ final class AppSettingsManager {
wordWrap: editor.wordWrap
)
notifyChange(.editorSettingsDidChange)
SyncChangeTracker.shared.markDirty(.settings, id: "editor")
}
}

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

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

var tabs: TabSettings {
didSet {
storage.saveTabs(tabs)
SyncChangeTracker.shared.markDirty(.settings, id: "tabs")
}
}

var keyboard: KeyboardSettings {
didSet {
storage.saveKeyboard(keyboard)
SyncChangeTracker.shared.markDirty(.settings, id: "keyboard")
}
}

var ai: AISettings {
didSet {
storage.saveAI(ai)
SyncChangeTracker.shared.markDirty(.settings, id: "ai")
}
}

Expand Down
12 changes: 12 additions & 0 deletions TablePro/Core/Storage/AppSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class AppSettingsStorage {
static let tabs = "com.TablePro.settings.tabs"
static let keyboard = "com.TablePro.settings.keyboard"
static let ai = "com.TablePro.settings.ai"
static let sync = "com.TablePro.settings.sync"
static let lastConnectionId = "com.TablePro.settings.lastConnectionId"
static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding"
}
Expand Down Expand Up @@ -116,6 +117,16 @@ final class AppSettingsStorage {
save(settings, key: Keys.ai)
}

// MARK: - Sync Settings

func loadSync() -> SyncSettings {
load(key: Keys.sync, default: .default)
}

func saveSync(_ settings: SyncSettings) {
save(settings, key: Keys.sync)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// MARK: - Last Connection (for Reopen Last Session)

/// Load the last used connection ID
Expand Down Expand Up @@ -173,6 +184,7 @@ final class AppSettingsStorage {
saveTabs(.default)
saveKeyboard(.default)
saveAI(.default)
saveSync(.default)
}

// MARK: - Helpers
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ final class ConnectionStorage {
var connections = loadConnections()
connections.append(connection)
saveConnections(connections)
SyncChangeTracker.shared.markDirty(.connection, id: connection.id.uuidString)

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

if let password = password {
if password.isEmpty {
Expand All @@ -92,6 +94,7 @@ final class ConnectionStorage {

/// Delete a connection
func deleteConnection(_ connection: DatabaseConnection) {
SyncChangeTracker.shared.markDeleted(.connection, id: connection.id.uuidString)
var connections = loadConnections()
connections.removeAll { $0.id == connection.id }
saveConnections(connections)
Expand Down Expand Up @@ -131,6 +134,7 @@ final class ConnectionStorage {
var connections = loadConnections()
connections.append(duplicate)
saveConnections(connections)
SyncChangeTracker.shared.markDirty(.connection, id: duplicate.id.uuidString)

// Copy all passwords from source to duplicate
if let password = loadPassword(for: connection.id) {
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Storage/GroupStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ final class GroupStorage {
do {
let data = try encoder.encode(groups)
defaults.set(data, forKey: groupsKey)
SyncChangeTracker.shared.markDirty(.group, ids: groups.map { $0.id.uuidString })
} catch {
Self.logger.error("Failed to save groups: \(error)")
}
Expand All @@ -65,6 +66,7 @@ final class GroupStorage {

/// Delete a group
func deleteGroup(_ group: ConnectionGroup) {
SyncChangeTracker.shared.markDeleted(.group, id: group.id.uuidString)
var groups = loadGroups()
groups.removeAll { $0.id == group.id }
saveGroups(groups)
Expand Down
78 changes: 77 additions & 1 deletion TablePro/Core/Storage/QueryHistoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ final class QueryHistoryStorage {
execution_time REAL NOT NULL,
row_count INTEGER NOT NULL,
was_successful INTEGER NOT NULL,
error_message TEXT
error_message TEXT,
is_synced INTEGER DEFAULT 0
);
"""

Expand Down Expand Up @@ -205,6 +206,7 @@ final class QueryHistoryStorage {

// Execute all table creation statements
execute(historyTable)
migrateAddIsSyncedColumn()
execute(ftsTable)
execute(ftsInsertTrigger)
execute(ftsDeleteTrigger)
Expand Down Expand Up @@ -548,6 +550,80 @@ final class QueryHistoryStorage {
}
}

// MARK: - Sync Support

/// Migration: add is_synced column if the table was created before sync support
private func migrateAddIsSyncedColumn() {
// Check if column already exists by querying table info
let sql = "PRAGMA table_info(history);"
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return }
defer { sqlite3_finalize(statement) }

var hasIsSynced = false
while sqlite3_step(statement) == SQLITE_ROW {
if let name = sqlite3_column_text(statement, 1).map({ String(cString: $0) }),
name == "is_synced" {
hasIsSynced = true
break
}
}

if !hasIsSynced {
execute("ALTER TABLE history ADD COLUMN is_synced INTEGER DEFAULT 0;")
Self.logger.info("Migrated history table: added is_synced column")
}
}

/// Mark history entries as synced
func markHistoryEntriesSynced(ids: [String]) async {
guard !ids.isEmpty else { return }
await performDatabaseWork { [weak self] in
guard let self else { return }

let placeholders = ids.map { _ in "?" }.joined(separator: ", ")
let sql = "UPDATE history SET is_synced = 1 WHERE id IN (\(placeholders));"

var statement: OpaquePointer?
guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { return }
defer { sqlite3_finalize(statement) }

let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
for (index, id) in ids.enumerated() {
sqlite3_bind_text(statement, Int32(index + 1), id, -1, SQLITE_TRANSIENT)
}
sqlite3_step(statement)
}
}

/// Fetch unsynced history entries
func unsyncedHistoryEntries(limit: Int) async -> [QueryHistoryEntry] {
await performDatabaseWork { [weak self] in
guard let self else { return [] }

let sql = """
SELECT id, query, connection_id, database_name, executed_at, execution_time, row_count, was_successful, error_message
FROM history WHERE is_synced = 0 ORDER BY executed_at DESC LIMIT ?;
"""

var statement: OpaquePointer?
guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else {
return []
}
defer { sqlite3_finalize(statement) }

sqlite3_bind_int(statement, 1, Int32(limit))

var entries: [QueryHistoryEntry] = []
while sqlite3_step(statement) == SQLITE_ROW {
if let entry = self.parseHistoryEntry(from: statement) {
entries.append(entry)
}
}
return entries
}
}

// MARK: - Parsing Helpers

private func parseHistoryEntry(from statement: OpaquePointer?) -> QueryHistoryEntry? {
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Storage/TagStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ final class TagStorage {
do {
let data = try encoder.encode(tags)
defaults.set(data, forKey: tagsKey)
SyncChangeTracker.shared.markDirty(.tag, ids: tags.map { $0.id.uuidString })
} catch {
Self.logger.error("Failed to save tags: \(error)")
}
Expand All @@ -66,6 +67,7 @@ final class TagStorage {
/// Delete a custom tag (presets cannot be deleted)
func deleteTag(_ tag: ConnectionTag) {
guard !tag.isPreset else { return }
SyncChangeTracker.shared.markDeleted(.tag, id: tag.id.uuidString)
var tags = loadTags()
tags.removeAll { $0.id == tag.id }
saveTags(tags)
Expand Down
Loading
Loading