Skip to content
Open
Show file tree
Hide file tree
Changes from all 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)
}

// 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