diff --git a/CHANGELOG.md b/CHANGELOG.md index 01010514..6ef2c0be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 9850fac4..1b6c27bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 010289cb..5bfdb90d 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -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; diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index d7ebe49c..b9fe530a 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -60,6 +60,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { AnalyticsService.shared.startPeriodicHeartbeat() + SyncCoordinator.shared.start() + Task.detached(priority: .background) { _ = QueryHistoryStorage.shared } @@ -96,6 +98,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) } + func applicationDidBecomeActive(_ notification: Notification) { + SyncCoordinator.shared.syncIfNeeded() + } + func applicationWillTerminate(_ notification: Notification) { SSHTunnelManager.shared.terminateAllProcessesSync() } diff --git a/TablePro/Core/Services/Licensing/LicenseManager+Pro.swift b/TablePro/Core/Services/Licensing/LicenseManager+Pro.swift new file mode 100644 index 00000000..6be66c38 --- /dev/null +++ b/TablePro/Core/Services/Licensing/LicenseManager+Pro.swift @@ -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 + } + } +} diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 5e10e299..647fad94 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -23,6 +23,7 @@ final class AppSettingsManager { didSet { general.language.apply() storage.saveGeneral(general) + SyncChangeTracker.shared.markDirty(.settings, id: "general") } } @@ -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") } } @@ -46,6 +48,7 @@ final class AppSettingsManager { wordWrap: editor.wordWrap ) notifyChange(.editorSettingsDidChange) + SyncChangeTracker.shared.markDirty(.settings, id: "editor") } } @@ -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") } } @@ -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") } } diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index 8ce271d6..bbbd6918 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -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" } @@ -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 @@ -173,6 +184,7 @@ final class AppSettingsStorage { saveTabs(.default) saveKeyboard(.default) saveAI(.default) + saveSync(.default) } // MARK: - Helpers diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index f76bfed2..c8895305 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -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) @@ -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 { @@ -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) @@ -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) { diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index 4ef53f45..2ca9fb48 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -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)") } @@ -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) diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index 139b4260..3a40a5f4 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -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 ); """ @@ -205,6 +206,7 @@ final class QueryHistoryStorage { // Execute all table creation statements execute(historyTable) + migrateAddIsSyncedColumn() execute(ftsTable) execute(ftsInsertTrigger) execute(ftsDeleteTrigger) @@ -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? { diff --git a/TablePro/Core/Storage/TagStorage.swift b/TablePro/Core/Storage/TagStorage.swift index a28a9e40..7283d45a 100644 --- a/TablePro/Core/Storage/TagStorage.swift +++ b/TablePro/Core/Storage/TagStorage.swift @@ -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)") } @@ -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) diff --git a/TablePro/Core/Sync/CloudKitSyncEngine.swift b/TablePro/Core/Sync/CloudKitSyncEngine.swift new file mode 100644 index 00000000..5d1a7718 --- /dev/null +++ b/TablePro/Core/Sync/CloudKitSyncEngine.swift @@ -0,0 +1,192 @@ +// +// CloudKitSyncEngine.swift +// TablePro +// +// Actor wrapping all CloudKit operations: zone setup, push, pull +// + +import CloudKit +import Foundation +import os + +/// Result of a pull operation +struct PullResult: Sendable { + let changedRecords: [CKRecord] + let deletedRecordIDs: [CKRecord.ID] + let newToken: CKServerChangeToken? +} + +/// Actor that serializes all CloudKit I/O +actor CloudKitSyncEngine { + private static let logger = Logger(subsystem: "com.TablePro", category: "CloudKitSyncEngine") + + private let container: CKContainer + private let database: CKDatabase + let zoneID: CKRecordZone.ID + + private static let containerIdentifier = "iCloud.com.TablePro" + private static let zoneName = "TableProSync" + private static let maxRetries = 3 + + init() { + container = CKContainer(identifier: Self.containerIdentifier) + database = container.privateCloudDatabase + zoneID = CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName) + } + + // MARK: - Account Status + + func checkAccountStatus() async throws -> CKAccountStatus { + try await container.accountStatus() + } + + // MARK: - Zone Management + + func ensureZoneExists() async throws { + let zone = CKRecordZone(zoneID: zoneID) + _ = try await database.save(zone) + Self.logger.trace("Created or confirmed sync zone: \(Self.zoneName)") + } + + // MARK: - Push + + func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws { + guard !records.isEmpty || !deletions.isEmpty else { return } + + try await withRetry { + let operation = CKModifyRecordsOperation( + recordsToSave: records, + recordIDsToDelete: deletions + ) + // Use .changedKeys so we don't need to track server change tags + // This overwrites only the fields we set, which is safe for our use case + operation.savePolicy = .changedKeys + operation.isAtomic = false + + return try await withCheckedThrowingContinuation { continuation in + operation.perRecordSaveBlock = { recordID, result in + if case .failure(let error) = result { + Self.logger.error( + "Failed to save record \(recordID.recordName): \(error.localizedDescription)" + ) + } + } + + operation.modifyRecordsResultBlock = { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + self.database.add(operation) + } + } + + Self.logger.info("Pushed \(records.count) records, \(deletions.count) deletions") + } + + // MARK: - Pull + + func pull(since token: CKServerChangeToken?) async throws -> PullResult { + try await withRetry { + try await performPull(since: token) + } + } + + private func performPull(since token: CKServerChangeToken?) async throws -> PullResult { + let configuration = CKFetchRecordZoneChangesOperation.ZoneConfiguration() + configuration.previousServerChangeToken = token + + let operation = CKFetchRecordZoneChangesOperation( + recordZoneIDs: [zoneID], + configurationsByRecordZoneID: [zoneID: configuration] + ) + + var changedRecords: [CKRecord] = [] + var deletedRecordIDs: [CKRecord.ID] = [] + var newToken: CKServerChangeToken? + + return try await withCheckedThrowingContinuation { continuation in + operation.recordWasChangedBlock = { _, result in + if case .success(let record) = result { + changedRecords.append(record) + } + } + + operation.recordWithIDWasDeletedBlock = { recordID, _ in + deletedRecordIDs.append(recordID) + } + + operation.recordZoneChangeTokensUpdatedBlock = { _, serverToken, _ in + newToken = serverToken + } + + operation.recordZoneFetchResultBlock = { _, result in + switch result { + case .success(let (serverToken, _, _)): + newToken = serverToken + case .failure(let error): + Self.logger.warning("Zone fetch result error: \(error.localizedDescription)") + } + } + + operation.fetchRecordZoneChangesResultBlock = { result in + switch result { + case .success: + let pullResult = PullResult( + changedRecords: changedRecords, + deletedRecordIDs: deletedRecordIDs, + newToken: newToken + ) + continuation.resume(returning: pullResult) + case .failure(let error): + continuation.resume(throwing: error) + } + } + + database.add(operation) + } + } + + // MARK: - Retry Logic + + private func withRetry(_ operation: () async throws -> T) async throws -> T { + var lastError: Error? + + for attempt in 0.. Bool { + switch error.code { + case .networkUnavailable, .networkFailure, .serviceUnavailable, + .requestRateLimited, .zoneBusy: + return true + default: + return false + } + } + + private func retryDelay(for error: CKError, attempt: Int) -> Double { + if let suggestedDelay = error.retryAfterSeconds { + return suggestedDelay + } + return Double(1 << attempt) // Exponential backoff: 1, 2, 4 seconds + } +} diff --git a/TablePro/Core/Sync/ConflictResolver.swift b/TablePro/Core/Sync/ConflictResolver.swift new file mode 100644 index 00000000..3d265bf4 --- /dev/null +++ b/TablePro/Core/Sync/ConflictResolver.swift @@ -0,0 +1,87 @@ +// +// ConflictResolver.swift +// TablePro +// +// Queues and resolves sync conflicts one at a time +// + +import CloudKit +import Foundation +import Observation +import os + +/// Represents a sync conflict between local and remote versions +struct SyncConflict: Identifiable { + let id: UUID + let recordType: SyncRecordType + let entityName: String + let localRecord: CKRecord + let serverRecord: CKRecord + let localModifiedAt: Date + let serverModifiedAt: Date + + init( + recordType: SyncRecordType, + entityName: String, + localRecord: CKRecord, + serverRecord: CKRecord, + localModifiedAt: Date, + serverModifiedAt: Date + ) { + self.id = UUID() + self.recordType = recordType + self.entityName = entityName + self.localRecord = localRecord + self.serverRecord = serverRecord + self.localModifiedAt = localModifiedAt + self.serverModifiedAt = serverModifiedAt + } +} + +/// Manages a queue of sync conflicts for user resolution +@MainActor @Observable +final class ConflictResolver { + static let shared = ConflictResolver() + private static let logger = Logger(subsystem: "com.TablePro", category: "ConflictResolver") + + private(set) var pendingConflicts: [SyncConflict] = [] + + var hasConflicts: Bool { !pendingConflicts.isEmpty } + + var currentConflict: SyncConflict? { pendingConflicts.first } + + private init() {} + + func addConflict(_ conflict: SyncConflict) { + pendingConflicts.append(conflict) + let count = pendingConflicts.count + Self.logger.trace( + "Conflict queued: \(conflict.recordType.rawValue)/\(conflict.entityName) (\(count) pending)" + ) + } + + /// Resolve the current (first) conflict. + /// Returns the CKRecord to push if keeping local; nil if keeping server version. + @discardableResult + func resolveCurrentConflict(keepLocal: Bool) -> CKRecord? { + guard let conflict = pendingConflicts.first else { return nil } + + pendingConflicts.removeFirst() + let resolution = keepLocal ? "local" : "server" + let remaining = pendingConflicts.count + Self.logger.trace( + "Resolved conflict: \(conflict.recordType.rawValue)/\(conflict.entityName) — kept \(resolution) (\(remaining) remaining)" + ) + + if keepLocal { + // Copy local field values onto the server record to update its change tag + let resolved = conflict.serverRecord + for key in conflict.localRecord.allKeys() { + resolved[key] = conflict.localRecord[key] + } + return resolved + } + + return nil + } +} diff --git a/TablePro/Core/Sync/SyncChangeTracker.swift b/TablePro/Core/Sync/SyncChangeTracker.swift new file mode 100644 index 00000000..64004507 --- /dev/null +++ b/TablePro/Core/Sync/SyncChangeTracker.swift @@ -0,0 +1,81 @@ +// +// SyncChangeTracker.swift +// TablePro +// +// Tracks local changes that need to be synced to CloudKit +// + +import Foundation +import os + +extension Notification.Name { + static let syncChangeTracked = Notification.Name("com.TablePro.syncChangeTracked") +} + +/// Tracks dirty entities and deletions for sync +final class SyncChangeTracker { + static let shared = SyncChangeTracker() + private static let logger = Logger(subsystem: "com.TablePro", category: "SyncChangeTracker") + + private let metadataStorage = SyncMetadataStorage.shared + + /// When true, changes are not tracked (used during remote apply to avoid sync loops) + private let suppressionLock = OSAllocatedUnfairLock(initialState: false) + + var isSuppressed: Bool { + get { suppressionLock.withLock { $0 } } + set { suppressionLock.withLock { $0 = newValue } } + } + + private init() {} + + // MARK: - Mark Dirty + + func markDirty(_ type: SyncRecordType, id: String) { + guard !isSuppressed else { return } + metadataStorage.addDirty(type: type, id: id) + Self.logger.info("Marked dirty: \(type.rawValue)/\(id)") + postChangeNotification() + } + + func markDirty(_ type: SyncRecordType, ids: [String]) { + guard !isSuppressed, !ids.isEmpty else { return } + for id in ids { + metadataStorage.addDirty(type: type, id: id) + } + Self.logger.trace("Marked dirty: \(type.rawValue) x\(ids.count)") + postChangeNotification() + } + + // MARK: - Mark Deleted + + func markDeleted(_ type: SyncRecordType, id: String) { + guard !isSuppressed else { return } + metadataStorage.removeDirty(type: type, id: id) + metadataStorage.addTombstone(type: type, id: id) + Self.logger.trace("Marked deleted: \(type.rawValue)/\(id)") + postChangeNotification() + } + + // MARK: - Query + + func dirtyRecords(for type: SyncRecordType) -> Set { + metadataStorage.dirtyIds(for: type) + } + + // MARK: - Clear + + func clearDirty(_ type: SyncRecordType, id: String) { + metadataStorage.removeDirty(type: type, id: id) + } + + func clearAllDirty(_ type: SyncRecordType) { + metadataStorage.clearDirty(type: type) + } + + // MARK: - Private + + private func postChangeNotification() { + NotificationCenter.default.post(name: .syncChangeTracked, object: self) + } +} diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift new file mode 100644 index 00000000..94d57d48 --- /dev/null +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -0,0 +1,789 @@ +// +// SyncCoordinator.swift +// TablePro +// +// Orchestrates sync: license gating, scheduling, push/pull coordination +// + +import CloudKit +import Foundation +import Observation +import os + +/// Central coordinator for iCloud sync +@MainActor @Observable +final class SyncCoordinator { + static let shared = SyncCoordinator() + private static let logger = Logger(subsystem: "com.TablePro", category: "SyncCoordinator") + + private(set) var syncStatus: SyncStatus = .disabled(.userDisabled) + private(set) var lastSyncDate: Date? + private(set) var iCloudAccountAvailable: Bool = false + + @ObservationIgnored private let engine = CloudKitSyncEngine() + @ObservationIgnored private let changeTracker = SyncChangeTracker.shared + @ObservationIgnored private let metadataStorage = SyncMetadataStorage.shared + @ObservationIgnored private let conflictResolver = ConflictResolver.shared + @ObservationIgnored private var accountObserver: NSObjectProtocol? + @ObservationIgnored private var changeObserver: NSObjectProtocol? + @ObservationIgnored private var syncTask: Task? + + private init() { + lastSyncDate = metadataStorage.lastSyncDate + } + + deinit { + if let accountObserver { NotificationCenter.default.removeObserver(accountObserver) } + if let changeObserver { NotificationCenter.default.removeObserver(changeObserver) } + syncTask?.cancel() + } + + // MARK: - Lifecycle + + /// Call from AppDelegate at launch + func start() { + observeAccountChanges() + observeLocalChanges() + + // If local storage is empty (fresh install or wiped), clear the sync token + // to force a full fetch instead of a delta that returns nothing + if ConnectionStorage.shared.loadConnections().isEmpty { + metadataStorage.clearSyncToken() + Self.logger.info("No local connections — cleared sync token for full fetch") + } + + Task { + await checkAccountStatus() + evaluateStatus() + + if syncStatus.isEnabled { + await syncNow() + } + } + } + + /// Called when the app comes to the foreground + func syncIfNeeded() { + guard syncStatus.isEnabled, !syncStatus.isSyncing else { return } + + Task { + await syncNow() + } + } + + /// Manual full sync (push then pull) + func syncNow() async { + guard canSync() else { + Self.logger.info("syncNow: canSync() returned false, skipping") + return + } + + syncStatus = .syncing + + do { + try await engine.ensureZoneExists() + await performPush() + await performPull() + + lastSyncDate = Date() + metadataStorage.lastSyncDate = lastSyncDate + syncStatus = .idle + + Self.logger.info("Sync completed successfully") + } catch { + let syncError = SyncError.from(error) + syncStatus = .error(syncError) + Self.logger.error("Sync failed: \(error.localizedDescription)") + } + } + + /// Triggered by remote push notification + func handleRemoteNotification() { + guard syncStatus.isEnabled else { return } + + Task { + await performPull() + } + } + + /// Called when user enables sync in settings + func enableSync() { + Self.logger.info("enableSync() called") + + // Clear token to force a full fetch on first sync after enabling + metadataStorage.clearSyncToken() + + // Mark ALL existing local data as dirty so it gets pushed on first sync + markAllLocalDataDirty() + let dirtyCount = changeTracker.dirtyRecords(for: .connection).count + Self.logger.info("enableSync() dirty marking done, dirty connections: \(dirtyCount)") + + Task { + await checkAccountStatus() + evaluateStatus() + + if syncStatus.isEnabled { + await syncNow() + } + } + } + + /// Marks all existing local data as dirty so it will be pushed on the next sync. + /// Called when sync is first enabled to upload existing connections/groups/tags/settings. + private func markAllLocalDataDirty() { + let connections = ConnectionStorage.shared.loadConnections() + for connection in connections { + changeTracker.markDirty(.connection, id: connection.id.uuidString) + } + + let groups = GroupStorage.shared.loadGroups() + for group in groups { + changeTracker.markDirty(.group, id: group.id.uuidString) + } + + let tags = TagStorage.shared.loadTags() + for tag in tags { + changeTracker.markDirty(.tag, id: tag.id.uuidString) + } + + // Mark all settings categories as dirty + for category in ["general", "appearance", "editor", "dataGrid", "history", "tabs", "keyboard", "ai"] { + changeTracker.markDirty(.settings, id: category) + } + + Self.logger.info("Marked all local data dirty: \(connections.count) connections, \(groups.count) groups, \(tags.count) tags, 8 settings categories") + } + + /// Called when user disables sync in settings + func disableSync() { + syncTask?.cancel() + syncStatus = .disabled(.userDisabled) + } + + // MARK: - Status + + private func evaluateStatus() { + let licenseManager = LicenseManager.shared + + // Check license + guard licenseManager.isFeatureAvailable(.iCloudSync) else { + switch licenseManager.status { + case .expired: + syncStatus = .disabled(.licenseExpired) + default: + syncStatus = .disabled(.licenseRequired) + } + return + } + + // Check sync settings + let syncSettings = AppSettingsStorage.shared.loadSync() + guard syncSettings.enabled else { + syncStatus = .disabled(.userDisabled) + return + } + + // Check iCloud account + guard iCloudAccountAvailable else { + syncStatus = .disabled(.noAccount) + return + } + + // If we were in an error or disabled state, transition to idle + if !syncStatus.isSyncing { + syncStatus = .idle + } + } + + private func canSync() -> Bool { + let licenseManager = LicenseManager.shared + guard licenseManager.isFeatureAvailable(.iCloudSync) else { + Self.logger.trace("Sync skipped: license not available") + return false + } + + let syncSettings = AppSettingsStorage.shared.loadSync() + guard syncSettings.enabled else { + Self.logger.trace("Sync skipped: disabled by user") + return false + } + + guard iCloudAccountAvailable else { + Self.logger.trace("Sync skipped: no iCloud account") + return false + } + + return true + } + + // MARK: - Push + + private func performPush() async { + let settings = AppSettingsStorage.shared.loadSync() + var recordsToSave: [CKRecord] = [] + var recordIDsToDelete: [CKRecord.ID] = [] + let zoneID = await engine.zoneID + let dirtyConnectionCount = changeTracker.dirtyRecords(for: .connection).count + Self.logger.info("performPush: syncConnections=\(settings.syncConnections), dirty connections=\(dirtyConnectionCount)") + + // Collect dirty connections + if settings.syncConnections { + let dirtyConnectionIds = changeTracker.dirtyRecords(for: .connection) + if !dirtyConnectionIds.isEmpty { + let connections = ConnectionStorage.shared.loadConnections() + for id in dirtyConnectionIds { + if let connection = connections.first(where: { $0.id.uuidString == id }) { + recordsToSave.append( + SyncRecordMapper.toCKRecord(connection, in: zoneID) + ) + } + } + } + + // Collect deletion tombstones + for tombstone in metadataStorage.tombstones(for: .connection) { + recordIDsToDelete.append( + SyncRecordMapper.recordID(type: .connection, id: tombstone.id, in: zoneID) + ) + } + } + + // Collect dirty groups and tags + if settings.syncGroupsAndTags { + collectDirtyGroups(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) + collectDirtyTags(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) + } + + // Collect unsynced query history + if settings.syncQueryHistory { + let limit = settings.historySyncLimit.limit ?? Int.max + let unsyncedEntries = await QueryHistoryStorage.shared.unsyncedHistoryEntries(limit: limit) + for entry in unsyncedEntries { + recordsToSave.append( + SyncRecordMapper.toCKRecord( + entryId: entry.id.uuidString, + query: entry.query, + connectionId: entry.connectionId.uuidString, + databaseName: entry.databaseName, + executedAt: entry.executedAt, + executionTime: entry.executionTime, + rowCount: Int64(entry.rowCount), + wasSuccessful: entry.wasSuccessful, + errorMessage: entry.errorMessage, + in: zoneID + ) + ) + } + } + + // Collect dirty settings + if settings.syncSettings { + let dirtySettingsIds = changeTracker.dirtyRecords(for: .settings) + for category in dirtySettingsIds { + if let data = settingsData(for: category) { + recordsToSave.append( + SyncRecordMapper.toCKRecord(category: category, settingsData: data, in: zoneID) + ) + } + } + } + + guard !recordsToSave.isEmpty || !recordIDsToDelete.isEmpty else { return } + + do { + try await engine.push(records: recordsToSave, deletions: recordIDsToDelete) + + // Clear dirty flags only for types that were actually pushed + if settings.syncConnections { + changeTracker.clearAllDirty(.connection) + } + if settings.syncGroupsAndTags { + changeTracker.clearAllDirty(.group) + changeTracker.clearAllDirty(.tag) + } + if settings.syncSettings { + changeTracker.clearAllDirty(.settings) + } + if settings.syncQueryHistory { + changeTracker.clearAllDirty(.queryHistory) + } + + // Clear tombstones only for types that were actually pushed + if settings.syncConnections { + for tombstone in metadataStorage.tombstones(for: .connection) { + metadataStorage.removeTombstone(type: .connection, id: tombstone.id) + } + } + if settings.syncGroupsAndTags { + for tombstone in metadataStorage.tombstones(for: .group) { + metadataStorage.removeTombstone(type: .group, id: tombstone.id) + } + for tombstone in metadataStorage.tombstones(for: .tag) { + metadataStorage.removeTombstone(type: .tag, id: tombstone.id) + } + } + if settings.syncSettings { + for tombstone in metadataStorage.tombstones(for: .settings) { + metadataStorage.removeTombstone(type: .settings, id: tombstone.id) + } + } + if settings.syncQueryHistory { + for tombstone in metadataStorage.tombstones(for: .queryHistory) { + metadataStorage.removeTombstone(type: .queryHistory, id: tombstone.id) + } + + // Mark pushed history entries as synced in local storage + let syncedIds = recordsToSave + .filter { $0.recordType == SyncRecordType.queryHistory.rawValue } + .compactMap { $0["entryId"] as? String } + if !syncedIds.isEmpty { + await QueryHistoryStorage.shared.markHistoryEntriesSynced(ids: syncedIds) + } + } + + Self.logger.info("Push completed: \(recordsToSave.count) saved, \(recordIDsToDelete.count) deleted") + } catch let error as CKError where error.code == .serverRecordChanged { + Self.logger.warning("Server record changed during push — conflicts detected") + handlePushConflicts(error) + } catch { + Self.logger.error("Push failed: \(error.localizedDescription)") + } + } + + // MARK: - Pull + + private func performPull() async { + let token = metadataStorage.loadSyncToken() + let tokenStatus = token == nil ? "nil (full fetch)" : "present (delta)" + Self.logger.info("Pull starting, token: \(tokenStatus)") + + do { + let result = try await engine.pull(since: token) + applyPullResult(result) + } catch let error as CKError where error.code == .changeTokenExpired { + Self.logger.warning("Change token expired, clearing and retrying with full fetch") + metadataStorage.clearSyncToken() + do { + let result = try await engine.pull(since: nil) + applyPullResult(result) + } catch { + Self.logger.error("Full fetch after token expiry failed: \(error.localizedDescription)") + } + } catch { + Self.logger.error("Pull failed: \(error.localizedDescription)") + } + } + + private func applyPullResult(_ result: PullResult) { + Self.logger.info("Pull fetched: \(result.changedRecords.count) changed, \(result.deletedRecordIDs.count) deleted") + + for record in result.changedRecords { + Self.logger.info("Pulled record: \(record.recordType)/\(record.recordID.recordName)") + } + + if let newToken = result.newToken { + metadataStorage.saveSyncToken(newToken) + } + + applyRemoteChanges(result) + + Self.logger.info( + "Pull completed: \(result.changedRecords.count) changed, \(result.deletedRecordIDs.count) deleted" + ) + } + + // TODO: Move storage I/O off @MainActor for large datasets + private func applyRemoteChanges(_ result: PullResult) { + let settings = AppSettingsStorage.shared.loadSync() + + // Suppress change tracking during remote apply to avoid sync loops + changeTracker.isSuppressed = true + defer { + changeTracker.isSuppressed = false + } + + var connectionsChanged = false + var groupsOrTagsChanged = false + + for record in result.changedRecords { + switch record.recordType { + case SyncRecordType.connection.rawValue where settings.syncConnections: + applyRemoteConnection(record) + connectionsChanged = true + case SyncRecordType.group.rawValue where settings.syncGroupsAndTags: + applyRemoteGroup(record) + groupsOrTagsChanged = true + case SyncRecordType.tag.rawValue where settings.syncGroupsAndTags: + applyRemoteTag(record) + groupsOrTagsChanged = true + case SyncRecordType.settings.rawValue where settings.syncSettings: + applyRemoteSettings(record) + case SyncRecordType.queryHistory.rawValue where settings.syncQueryHistory: + applyRemoteQueryHistory(record) + default: + break + } + } + + for recordID in result.deletedRecordIDs { + let recordName = recordID.recordName + if recordName.hasPrefix("Connection_") { connectionsChanged = true } + if recordName.hasPrefix("Group_") || recordName.hasPrefix("Tag_") { groupsOrTagsChanged = true } + applyRemoteDeletion(recordID) + } + + // Notify UI so views refresh with pulled data + if connectionsChanged || groupsOrTagsChanged { + NotificationCenter.default.post(name: .connectionUpdated, object: nil) + } + } + + private func applyRemoteConnection(_ record: CKRecord) { + guard let remoteConnection = SyncRecordMapper.toConnection(record) else { return } + + var connections = ConnectionStorage.shared.loadConnections() + if let index = connections.firstIndex(where: { $0.id == remoteConnection.id }) { + // Check for conflict: if local is also dirty, queue conflict + if changeTracker.dirtyRecords(for: .connection).contains(remoteConnection.id.uuidString) { + let localRecord = SyncRecordMapper.toCKRecord( + connections[index], + in: CKRecordZone.ID( + zoneName: "TableProSync", + ownerName: CKCurrentUserDefaultName + ) + ) + let conflict = SyncConflict( + recordType: .connection, + entityName: remoteConnection.name, + localRecord: localRecord, + serverRecord: record, + localModifiedAt: (localRecord["modifiedAtLocal"] as? Date) ?? Date(), + serverModifiedAt: (record["modifiedAtLocal"] as? Date) ?? Date() + ) + conflictResolver.addConflict(conflict) + return + } + connections[index] = remoteConnection + } else { + connections.append(remoteConnection) + } + ConnectionStorage.shared.saveConnections(connections) + } + + private func applyRemoteGroup(_ record: CKRecord) { + guard let remoteGroup = SyncRecordMapper.toGroup(record) else { return } + + var groups = GroupStorage.shared.loadGroups() + if let index = groups.firstIndex(where: { $0.id == remoteGroup.id }) { + groups[index] = remoteGroup + } else { + groups.append(remoteGroup) + } + GroupStorage.shared.saveGroups(groups) + } + + private func applyRemoteTag(_ record: CKRecord) { + guard let remoteTag = SyncRecordMapper.toTag(record) else { return } + + var tags = TagStorage.shared.loadTags() + if let index = tags.firstIndex(where: { $0.id == remoteTag.id }) { + tags[index] = remoteTag + } else { + tags.append(remoteTag) + } + TagStorage.shared.saveTags(tags) + } + + private func applyRemoteSettings(_ record: CKRecord) { + guard let category = SyncRecordMapper.settingsCategory(from: record), + let data = SyncRecordMapper.settingsData(from: record) + else { return } + applySettingsData(data, for: category) + } + + private func applyRemoteQueryHistory(_ record: CKRecord) { + guard let entryIdString = record["entryId"] as? String, + let entryId = UUID(uuidString: entryIdString), + let query = record["query"] as? String, + let executedAt = record["executedAt"] as? Date + else { return } + + let connectionId = (record["connectionId"] as? String).flatMap { UUID(uuidString: $0) } ?? UUID() + let databaseName = record["databaseName"] as? String ?? "" + let executionTime = record["executionTime"] as? Double ?? 0 + let rowCount = (record["rowCount"] as? Int64).map { Int($0) } ?? 0 + let wasSuccessful = (record["wasSuccessful"] as? Int64 ?? 1) != 0 + let errorMessage = record["errorMessage"] as? String + + let entry = QueryHistoryEntry( + id: entryId, + query: query, + connectionId: connectionId, + databaseName: databaseName, + executedAt: executedAt, + executionTime: executionTime, + rowCount: rowCount, + wasSuccessful: wasSuccessful, + errorMessage: errorMessage + ) + + Task { + _ = await QueryHistoryStorage.shared.addHistory(entry) + await QueryHistoryStorage.shared.markHistoryEntriesSynced(ids: [entryIdString]) + } + } + + private func applyRemoteDeletion(_ recordID: CKRecord.ID) { + let recordName = recordID.recordName + + if recordName.hasPrefix("Connection_") { + let uuidString = String(recordName.dropFirst("Connection_".count)) + if let uuid = UUID(uuidString: uuidString) { + var connections = ConnectionStorage.shared.loadConnections() + connections.removeAll { $0.id == uuid } + ConnectionStorage.shared.saveConnections(connections) + } + } + if recordName.hasPrefix("Group_") { + let uuidString = String(recordName.dropFirst("Group_".count)) + if let uuid = UUID(uuidString: uuidString) { + var groups = GroupStorage.shared.loadGroups() + groups.removeAll { $0.id == uuid } + GroupStorage.shared.saveGroups(groups) + } + } + + if recordName.hasPrefix("Tag_") { + let uuidString = String(recordName.dropFirst("Tag_".count)) + if let uuid = UUID(uuidString: uuidString) { + var tags = TagStorage.shared.loadTags() + tags.removeAll { $0.id == uuid } + TagStorage.shared.saveTags(tags) + } + } + } + + // MARK: - Observers + + private func observeAccountChanges() { + accountObserver = NotificationCenter.default.addObserver( + forName: .CKAccountChanged, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + await checkAccountStatus() + evaluateStatus() + + // If account changed, clear metadata and re-sync + let currentAccountId = metadataStorage.lastAccountId + if let newAccountId = try? await self.currentAccountId(), + currentAccountId != nil, currentAccountId != newAccountId { + Self.logger.warning("iCloud account changed, clearing sync metadata") + metadataStorage.clearAll() + metadataStorage.lastAccountId = newAccountId + } + } + } + } + + private func observeLocalChanges() { + changeObserver = NotificationCenter.default.addObserver( + forName: .syncChangeTracked, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + guard syncStatus.isEnabled, !syncStatus.isSyncing else { return } + // Debounce: schedule sync after a short delay + syncTask?.cancel() + syncTask = Task { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + await self.syncNow() + } + } + } + } + + // MARK: - Account + + private func checkAccountStatus() async { + do { + let status = try await engine.checkAccountStatus() + iCloudAccountAvailable = (status == .available) + + if iCloudAccountAvailable { + if let accountId = try? await currentAccountId() { + metadataStorage.lastAccountId = accountId + } + } + } catch { + iCloudAccountAvailable = false + Self.logger.warning("Failed to check iCloud account: \(error.localizedDescription)") + } + } + + private func currentAccountId() async throws -> String? { + let container = CKContainer(identifier: "iCloud.com.TablePro") + let userRecordID = try await container.userRecordID() + return userRecordID.recordName + } + + // MARK: - Conflict Handling + + private func handlePushConflicts(_ error: CKError) { + guard let partialErrors = error.partialErrorsByItemID else { return } + + for (_, itemError) in partialErrors { + guard let ckError = itemError as? CKError, + ckError.code == .serverRecordChanged, + let serverRecord = ckError.serverRecord, + let clientRecord = ckError.clientRecord + else { continue } + + let recordType = serverRecord.recordType + let entityName = (serverRecord["name"] as? String) ?? recordType + + let syncRecordType: SyncRecordType + switch recordType { + case SyncRecordType.connection.rawValue: syncRecordType = .connection + case SyncRecordType.group.rawValue: syncRecordType = .group + case SyncRecordType.tag.rawValue: syncRecordType = .tag + case SyncRecordType.settings.rawValue: syncRecordType = .settings + case SyncRecordType.queryHistory.rawValue: syncRecordType = .queryHistory + default: continue + } + + let conflict = SyncConflict( + recordType: syncRecordType, + entityName: entityName, + localRecord: clientRecord, + serverRecord: serverRecord, + localModifiedAt: (clientRecord["modifiedAtLocal"] as? Date) ?? Date(), + serverModifiedAt: (serverRecord["modifiedAtLocal"] as? Date) ?? Date() + ) + conflictResolver.addConflict(conflict) + } + } + + /// Push a resolved conflict record back to CloudKit + func pushResolvedConflict(_ record: CKRecord) { + Task { + do { + try await engine.push(records: [record], deletions: []) + } catch { + Self.logger.error("Failed to push resolved conflict: \(error.localizedDescription)") + } + } + } + + // MARK: - Settings Helpers + + private func settingsData(for category: String) -> Data? { + let storage = AppSettingsStorage.shared + let encoder = JSONEncoder() + + switch category { + case "general": return try? encoder.encode(storage.loadGeneral()) + case "appearance": return try? encoder.encode(storage.loadAppearance()) + case "editor": return try? encoder.encode(storage.loadEditor()) + case "dataGrid": return try? encoder.encode(storage.loadDataGrid()) + case "history": return try? encoder.encode(storage.loadHistory()) + case "tabs": return try? encoder.encode(storage.loadTabs()) + case "keyboard": return try? encoder.encode(storage.loadKeyboard()) + case "ai": return try? encoder.encode(storage.loadAI()) + default: return nil + } + } + + private func applySettingsData(_ data: Data, for category: String) { + let manager = AppSettingsManager.shared + let decoder = JSONDecoder() + + switch category { + case "general": + if let settings = try? decoder.decode(GeneralSettings.self, from: data) { + manager.general = settings + } + case "appearance": + if let settings = try? decoder.decode(AppearanceSettings.self, from: data) { + manager.appearance = settings + } + case "editor": + if let settings = try? decoder.decode(EditorSettings.self, from: data) { + manager.editor = settings + } + case "dataGrid": + if let settings = try? decoder.decode(DataGridSettings.self, from: data) { + manager.dataGrid = settings + } + case "history": + if let settings = try? decoder.decode(HistorySettings.self, from: data) { + manager.history = settings + } + case "tabs": + if let settings = try? decoder.decode(TabSettings.self, from: data) { + manager.tabs = settings + } + case "keyboard": + if let settings = try? decoder.decode(KeyboardSettings.self, from: data) { + manager.keyboard = settings + } + case "ai": + if let settings = try? decoder.decode(AISettings.self, from: data) { + manager.ai = settings + } + default: + break + } + } + + // MARK: - Group/Tag Collection Helpers + + private func collectDirtyGroups( + into records: inout [CKRecord], + deletions: inout [CKRecord.ID], + zoneID: CKRecordZone.ID + ) { + let dirtyGroupIds = changeTracker.dirtyRecords(for: .group) + if !dirtyGroupIds.isEmpty { + let groups = GroupStorage.shared.loadGroups() + for id in dirtyGroupIds { + if let group = groups.first(where: { $0.id.uuidString == id }) { + records.append(SyncRecordMapper.toCKRecord(group, in: zoneID)) + } + } + } + + for tombstone in metadataStorage.tombstones(for: .group) { + deletions.append( + SyncRecordMapper.recordID(type: .group, id: tombstone.id, in: zoneID) + ) + } + } + + private func collectDirtyTags( + into records: inout [CKRecord], + deletions: inout [CKRecord.ID], + zoneID: CKRecordZone.ID + ) { + let dirtyTagIds = changeTracker.dirtyRecords(for: .tag) + if !dirtyTagIds.isEmpty { + let tags = TagStorage.shared.loadTags() + for id in dirtyTagIds { + if let tag = tags.first(where: { $0.id.uuidString == id }) { + records.append(SyncRecordMapper.toCKRecord(tag, in: zoneID)) + } + } + } + + for tombstone in metadataStorage.tombstones(for: .tag) { + deletions.append( + SyncRecordMapper.recordID(type: .tag, id: tombstone.id, in: zoneID) + ) + } + } +} diff --git a/TablePro/Core/Sync/SyncError.swift b/TablePro/Core/Sync/SyncError.swift new file mode 100644 index 00000000..3ae0d080 --- /dev/null +++ b/TablePro/Core/Sync/SyncError.swift @@ -0,0 +1,86 @@ +// +// SyncError.swift +// TablePro +// +// Sync-specific error types +// + +import CloudKit +import Foundation + +/// Errors that can occur during sync operations +enum SyncError: LocalizedError, Equatable { + case networkUnavailable + case accountUnavailable + case quotaExceeded + case zoneNotFound + case serverError(String) + case conflictDetected + case encodingFailed(String) + case unknown(String) + + var errorDescription: String? { + switch self { + case .networkUnavailable: + return String(localized: "Network is unavailable. Changes will sync when connectivity is restored.") + case .accountUnavailable: + return String(localized: "iCloud account is not available. Sign in to iCloud in System Settings.") + case .quotaExceeded: + return String(localized: "iCloud storage is full. Free up space or reduce the history sync limit.") + case .zoneNotFound: + return String(localized: "Sync zone not found. A full sync will be performed.") + case .serverError(let message): + return String(localized: "iCloud server error: \(message)") + case .conflictDetected: + return String(localized: "A sync conflict was detected and needs to be resolved.") + case .encodingFailed(let detail): + return String(localized: "Failed to encode sync data: \(detail)") + case .unknown(let message): + return String(localized: "An unknown sync error occurred: \(message)") + } + } + + /// Convert a generic Error into a SyncError + static func from(_ error: Error) -> SyncError { + if let syncError = error as? SyncError { + return syncError + } + + // Map CKError codes to SyncError + if let ckError = error as? CKError { + switch ckError.code { + case .networkUnavailable, .networkFailure: + return .networkUnavailable + case .notAuthenticated: + return .accountUnavailable + case .quotaExceeded: + return .quotaExceeded + case .zoneNotFound: + return .zoneNotFound + default: + return .serverError(ckError.localizedDescription) + } + } + + return .unknown(error.localizedDescription) + } + + static func == (lhs: SyncError, rhs: SyncError) -> Bool { + switch (lhs, rhs) { + case (.networkUnavailable, .networkUnavailable), + (.accountUnavailable, .accountUnavailable), + (.quotaExceeded, .quotaExceeded), + (.zoneNotFound, .zoneNotFound), + (.conflictDetected, .conflictDetected): + return true + case (.serverError(let a), .serverError(let b)): + return a == b + case (.encodingFailed(let a), .encodingFailed(let b)): + return a == b + case (.unknown(let a), .unknown(let b)): + return a == b + default: + return false + } + } +} diff --git a/TablePro/Core/Sync/SyncMetadataStorage.swift b/TablePro/Core/Sync/SyncMetadataStorage.swift new file mode 100644 index 00000000..40e292d7 --- /dev/null +++ b/TablePro/Core/Sync/SyncMetadataStorage.swift @@ -0,0 +1,188 @@ +// +// SyncMetadataStorage.swift +// TablePro +// +// Persists sync metadata (tokens, dirty sets, tombstones) in UserDefaults +// + +import CloudKit +import Foundation +import os + +/// Persistent storage for sync metadata using UserDefaults +final class SyncMetadataStorage { + static let shared = SyncMetadataStorage() + private static let logger = Logger(subsystem: "com.TablePro", category: "SyncMetadataStorage") + + private let defaults = UserDefaults.standard + + private enum Keys { + static let syncToken = "com.TablePro.sync.serverChangeToken" + static let dirtyPrefix = "com.TablePro.sync.dirty." + static let tombstonePrefix = "com.TablePro.sync.tombstones." + static let lastSyncDate = "com.TablePro.sync.lastSyncDate" + static let lastAccountId = "com.TablePro.sync.lastAccountId" + } + + private init() {} + + // MARK: - Server Change Token + + func saveSyncToken(_ token: CKServerChangeToken) { + do { + let data = try NSKeyedArchiver.archivedData( + withRootObject: token, + requiringSecureCoding: true + ) + defaults.set(data, forKey: Keys.syncToken) + } catch { + Self.logger.error("Failed to archive sync token: \(error.localizedDescription)") + } + } + + func clearSyncToken() { + defaults.removeObject(forKey: Keys.syncToken) + } + + func loadSyncToken() -> CKServerChangeToken? { + guard let data = defaults.data(forKey: Keys.syncToken) else { return nil } + + do { + return try NSKeyedUnarchiver.unarchivedObject( + ofClass: CKServerChangeToken.self, + from: data + ) + } catch { + Self.logger.error("Failed to unarchive sync token: \(error.localizedDescription)") + return nil + } + } + + // MARK: - Dirty Entity Tracking + + func addDirty(type: SyncRecordType, id: String) { + var ids = dirtyIds(for: type) + ids.insert(id) + saveDirtyIds(ids, for: type) + } + + func removeDirty(type: SyncRecordType, id: String) { + var ids = dirtyIds(for: type) + ids.remove(id) + saveDirtyIds(ids, for: type) + } + + func dirtyIds(for type: SyncRecordType) -> Set { + let key = Keys.dirtyPrefix + type.rawValue + guard let array = defaults.stringArray(forKey: key) else { return [] } + return Set(array) + } + + private func saveDirtyIds(_ ids: Set, for type: SyncRecordType) { + let key = Keys.dirtyPrefix + type.rawValue + if ids.isEmpty { + defaults.removeObject(forKey: key) + } else { + defaults.set(Array(ids), forKey: key) + } + } + + func clearDirty(type: SyncRecordType) { + let key = Keys.dirtyPrefix + type.rawValue + defaults.removeObject(forKey: key) + } + + // MARK: - Deletion Tombstones + + func addTombstone(type: SyncRecordType, id: String) { + var tombstones = loadTombstones(for: type) + tombstones.append(Tombstone(id: id, deletedAt: Date())) + saveTombstones(tombstones, for: type) + } + + func tombstones(for type: SyncRecordType) -> [(id: String, deletedAt: Date)] { + loadTombstones(for: type).map { ($0.id, $0.deletedAt) } + } + + func removeTombstone(type: SyncRecordType, id: String) { + var tombstones = loadTombstones(for: type) + tombstones.removeAll { $0.id == id } + saveTombstones(tombstones, for: type) + } + + func pruneTombstones(olderThan days: Int) { + let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date() + + for type in SyncRecordType.allCases { + var tombstones = loadTombstones(for: type) + let before = tombstones.count + tombstones.removeAll { $0.deletedAt < cutoff } + if tombstones.count != before { + saveTombstones(tombstones, for: type) + } + } + } + + private func loadTombstones(for type: SyncRecordType) -> [Tombstone] { + let key = Keys.tombstonePrefix + type.rawValue + guard let data = defaults.data(forKey: key) else { return [] } + + do { + return try JSONDecoder().decode([Tombstone].self, from: data) + } catch { + Self.logger.error("Failed to decode tombstones for \(type.rawValue): \(error.localizedDescription)") + return [] + } + } + + private func saveTombstones(_ tombstones: [Tombstone], for type: SyncRecordType) { + let key = Keys.tombstonePrefix + type.rawValue + if tombstones.isEmpty { + defaults.removeObject(forKey: key) + return + } + + do { + let data = try JSONEncoder().encode(tombstones) + defaults.set(data, forKey: key) + } catch { + Self.logger.error("Failed to encode tombstones for \(type.rawValue): \(error.localizedDescription)") + } + } + + // MARK: - Last Sync Date + + var lastSyncDate: Date? { + get { defaults.object(forKey: Keys.lastSyncDate) as? Date } + set { defaults.set(newValue, forKey: Keys.lastSyncDate) } + } + + // MARK: - Account ID + + var lastAccountId: String? { + get { defaults.string(forKey: Keys.lastAccountId) } + set { defaults.set(newValue, forKey: Keys.lastAccountId) } + } + + // MARK: - Clear All + + func clearAll() { + defaults.removeObject(forKey: Keys.syncToken) + defaults.removeObject(forKey: Keys.lastSyncDate) + defaults.removeObject(forKey: Keys.lastAccountId) + + for type in SyncRecordType.allCases { + clearDirty(type: type) + saveTombstones([], for: type) + } + + Self.logger.trace("Cleared all sync metadata") + } +} + +// MARK: - Tombstone + +private struct Tombstone: Codable { + let id: String + let deletedAt: Date +} diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift new file mode 100644 index 00000000..a41ff468 --- /dev/null +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -0,0 +1,310 @@ +// +// SyncRecordMapper.swift +// TablePro +// +// Maps between local models and CKRecord for CloudKit sync +// + +import CloudKit +import Foundation +import os + +/// CloudKit record types for sync +enum SyncRecordType: String, CaseIterable { + case connection = "Connection" + case group = "ConnectionGroup" + case tag = "ConnectionTag" + case settings = "AppSettings" + case queryHistory = "QueryHistory" +} + +/// Pure-function mapper between local models and CKRecord +struct SyncRecordMapper { + private static let logger = Logger(subsystem: "com.TablePro", category: "SyncRecordMapper") + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + + /// Current schema version stamped on every record + static let schemaVersion: Int64 = 1 + + /// Maximum query text length for CloudKit (10KB in UTF-8) + static let maxQueryLength = 10_240 + + // MARK: - Record Name Helpers + + static func recordID(type: SyncRecordType, id: String, in zone: CKRecordZone.ID) -> CKRecord.ID { + let recordName: String + switch type { + case .connection: recordName = "Connection_\(id)" + case .group: recordName = "Group_\(id)" + case .tag: recordName = "Tag_\(id)" + case .settings: recordName = "Settings_\(id)" + case .queryHistory: recordName = "History_\(id)" + } + return CKRecord.ID(recordName: recordName, zoneID: zone) + } + + // MARK: - Connection + + static func toCKRecord(_ connection: DatabaseConnection, in zone: CKRecordZone.ID) -> CKRecord { + let recordID = recordID(type: .connection, id: connection.id.uuidString, in: zone) + let record = CKRecord(recordType: SyncRecordType.connection.rawValue, recordID: recordID) + + record["connectionId"] = connection.id.uuidString as CKRecordValue + record["name"] = connection.name as CKRecordValue + record["host"] = connection.host as CKRecordValue + record["port"] = Int64(connection.port) as CKRecordValue + record["database"] = connection.database as CKRecordValue + record["username"] = connection.username as CKRecordValue + record["type"] = connection.type.rawValue as CKRecordValue + record["color"] = connection.color.rawValue as CKRecordValue + record["safeModeLevel"] = connection.safeModeLevel.rawValue as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + if let tagId = connection.tagId { + record["tagId"] = tagId.uuidString as CKRecordValue + } + if let groupId = connection.groupId { + record["groupId"] = groupId.uuidString as CKRecordValue + } + if let aiPolicy = connection.aiPolicy { + record["aiPolicy"] = aiPolicy.rawValue as CKRecordValue + } + if let redisDatabase = connection.redisDatabase { + record["redisDatabase"] = Int64(redisDatabase) as CKRecordValue + } + if let startupCommands = connection.startupCommands { + record["startupCommands"] = startupCommands as CKRecordValue + } + + // Encode complex structs as JSON Data + do { + let sshData = try encoder.encode(connection.sshConfig) + record["sshConfigJson"] = sshData as CKRecordValue + } catch { + logger.warning("Failed to encode SSH config for sync: \(error.localizedDescription)") + } + do { + let sslData = try encoder.encode(connection.sslConfig) + record["sslConfigJson"] = sslData as CKRecordValue + } catch { + logger.warning("Failed to encode SSL config for sync: \(error.localizedDescription)") + } + if !connection.additionalFields.isEmpty { + do { + let fieldsData = try encoder.encode(connection.additionalFields) + record["additionalFieldsJson"] = fieldsData as CKRecordValue + } catch { + logger.warning("Failed to encode additional fields for sync: \(error.localizedDescription)") + } + } + + return record + } + + static func toConnection(_ record: CKRecord) -> DatabaseConnection? { + guard let connectionIdString = record["connectionId"] as? String, + let connectionId = UUID(uuidString: connectionIdString), + let name = record["name"] as? String, + let typeRawValue = record["type"] as? String + else { + logger.warning("Failed to decode connection from CKRecord: missing required fields") + return nil + } + + let host = record["host"] as? String ?? "localhost" + let port = (record["port"] as? Int64).map { Int($0) } ?? 0 + let database = record["database"] as? String ?? "" + let username = record["username"] as? String ?? "" + let colorRaw = record["color"] as? String ?? ConnectionColor.none.rawValue + let safeModeLevelRaw = record["safeModeLevel"] as? String ?? SafeModeLevel.silent.rawValue + let tagId = (record["tagId"] as? String).flatMap { UUID(uuidString: $0) } + let groupId = (record["groupId"] as? String).flatMap { UUID(uuidString: $0) } + let aiPolicyRaw = record["aiPolicy"] as? String + let redisDatabase = (record["redisDatabase"] as? Int64).map { Int($0) } + let startupCommands = record["startupCommands"] as? String + + var sshConfig = SSHConfiguration() + if let sshData = record["sshConfigJson"] as? Data { + sshConfig = (try? decoder.decode(SSHConfiguration.self, from: sshData)) ?? SSHConfiguration() + } + + var sslConfig = SSLConfiguration() + if let sslData = record["sslConfigJson"] as? Data { + sslConfig = (try? decoder.decode(SSLConfiguration.self, from: sslData)) ?? SSLConfiguration() + } + + var additionalFields: [String: String]? + if let fieldsData = record["additionalFieldsJson"] as? Data { + additionalFields = try? decoder.decode([String: String].self, from: fieldsData) + } + + return DatabaseConnection( + id: connectionId, + name: name, + host: host, + port: port, + database: database, + username: username, + type: DatabaseType(rawValue: typeRawValue), + sshConfig: sshConfig, + sslConfig: sslConfig, + color: ConnectionColor(rawValue: colorRaw) ?? .none, + tagId: tagId, + groupId: groupId, + safeModeLevel: SafeModeLevel(rawValue: safeModeLevelRaw) ?? .silent, + aiPolicy: aiPolicyRaw.flatMap { AIConnectionPolicy(rawValue: $0) }, + redisDatabase: redisDatabase, + startupCommands: startupCommands, + additionalFields: additionalFields + ) + } + + // MARK: - Connection Group + + static func toCKRecord(_ group: ConnectionGroup, in zone: CKRecordZone.ID) -> CKRecord { + let recordID = recordID(type: .group, id: group.id.uuidString, in: zone) + let record = CKRecord(recordType: SyncRecordType.group.rawValue, recordID: recordID) + + record["groupId"] = group.id.uuidString as CKRecordValue + record["name"] = group.name as CKRecordValue + record["color"] = group.color.rawValue as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + return record + } + + static func toGroup(_ record: CKRecord) -> ConnectionGroup? { + guard let groupIdString = record["groupId"] as? String, + let groupId = UUID(uuidString: groupIdString), + let name = record["name"] as? String + else { + logger.warning("Failed to decode group from CKRecord: missing required fields") + return nil + } + + let colorRaw = record["color"] as? String ?? ConnectionColor.none.rawValue + + return ConnectionGroup( + id: groupId, + name: name, + color: ConnectionColor(rawValue: colorRaw) ?? .none + ) + } + + // MARK: - Connection Tag + + static func toCKRecord(_ tag: ConnectionTag, in zone: CKRecordZone.ID) -> CKRecord { + let recordID = recordID(type: .tag, id: tag.id.uuidString, in: zone) + let record = CKRecord(recordType: SyncRecordType.tag.rawValue, recordID: recordID) + + record["tagId"] = tag.id.uuidString as CKRecordValue + record["name"] = tag.name as CKRecordValue + record["isPreset"] = Int64(tag.isPreset ? 1 : 0) as CKRecordValue + record["color"] = tag.color.rawValue as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + return record + } + + static func toTag(_ record: CKRecord) -> ConnectionTag? { + guard let tagIdString = record["tagId"] as? String, + let tagId = UUID(uuidString: tagIdString), + let name = record["name"] as? String + else { + logger.warning("Failed to decode tag from CKRecord: missing required fields") + return nil + } + + let isPreset = (record["isPreset"] as? Int64 ?? 0) != 0 + let colorRaw = record["color"] as? String ?? ConnectionColor.gray.rawValue + + return ConnectionTag( + id: tagId, + name: name, + isPreset: isPreset, + color: ConnectionColor(rawValue: colorRaw) ?? .gray + ) + } + + // MARK: - App Settings + + static func toCKRecord( + category: String, + settingsData: Data, + in zone: CKRecordZone.ID + ) -> CKRecord { + let recordID = recordID(type: .settings, id: category, in: zone) + let record = CKRecord(recordType: SyncRecordType.settings.rawValue, recordID: recordID) + + record["category"] = category as CKRecordValue + record["settingsJson"] = settingsData as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + return record + } + + static func settingsCategory(from record: CKRecord) -> String? { + record["category"] as? String + } + + static func settingsData(from record: CKRecord) -> Data? { + record["settingsJson"] as? Data + } + + // MARK: - Query History + + static func toCKRecord( + entryId: String, + query: String, + connectionId: String?, + databaseName: String?, + executedAt: Date, + executionTime: Double, + rowCount: Int64, + wasSuccessful: Bool, + errorMessage: String?, + in zone: CKRecordZone.ID + ) -> CKRecord { + let recordID = recordID(type: .queryHistory, id: entryId, in: zone) + let record = CKRecord( + recordType: SyncRecordType.queryHistory.rawValue, + recordID: recordID + ) + + record["entryId"] = entryId as CKRecordValue + // Cap query text at maxQueryLength bytes + let cappedQuery: String + let queryData = Data(query.utf8) + if queryData.count > maxQueryLength { + cappedQuery = String( + data: queryData.prefix(maxQueryLength), + encoding: .utf8 + ) ?? String(query.prefix(maxQueryLength / 4)) + } else { + cappedQuery = query + } + record["query"] = cappedQuery as CKRecordValue + record["executedAt"] = executedAt as CKRecordValue + record["executionTime"] = executionTime as CKRecordValue + record["rowCount"] = rowCount as CKRecordValue + record["wasSuccessful"] = Int64(wasSuccessful ? 1 : 0) as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + if let connectionId { + record["connectionId"] = connectionId as CKRecordValue + } + if let databaseName { + record["databaseName"] = databaseName as CKRecordValue + } + if let errorMessage { + record["errorMessage"] = errorMessage as CKRecordValue + } + + return record + } +} diff --git a/TablePro/Core/Sync/SyncStatus.swift b/TablePro/Core/Sync/SyncStatus.swift new file mode 100644 index 00000000..f09e8bab --- /dev/null +++ b/TablePro/Core/Sync/SyncStatus.swift @@ -0,0 +1,37 @@ +// +// SyncStatus.swift +// TablePro +// +// Sync state representation +// + +import Foundation + +/// Current state of the sync system +enum SyncStatus: Equatable { + case idle + case syncing + case error(SyncError) + case disabled(DisableReason) + + var isSyncing: Bool { + self == .syncing + } + + var isEnabled: Bool { + switch self { + case .disabled: + return false + default: + return true + } + } +} + +/// Reason why sync is disabled +enum DisableReason: Equatable { + case noAccount + case licenseRequired + case licenseExpired + case userDisabled +} diff --git a/TablePro/Models/Settings/ProFeature.swift b/TablePro/Models/Settings/ProFeature.swift new file mode 100644 index 00000000..47402609 --- /dev/null +++ b/TablePro/Models/Settings/ProFeature.swift @@ -0,0 +1,41 @@ +// +// ProFeature.swift +// TablePro +// +// Pro feature definitions and access control types +// + +import Foundation + +/// Features that require a Pro (active) license +internal enum ProFeature: String, CaseIterable { + case iCloudSync + + var displayName: String { + switch self { + case .iCloudSync: + return String(localized: "iCloud Sync") + } + } + + var systemImage: String { + switch self { + case .iCloudSync: + return "icloud" + } + } + + var featureDescription: String { + switch self { + case .iCloudSync: + return String(localized: "Sync connections, settings, and history across your Macs.") + } + } +} + +/// Result of checking Pro feature availability +internal enum ProFeatureAccess { + case available + case unlicensed + case expired +} diff --git a/TablePro/Models/Settings/SyncSettings.swift b/TablePro/Models/Settings/SyncSettings.swift new file mode 100644 index 00000000..1f4c54f3 --- /dev/null +++ b/TablePro/Models/Settings/SyncSettings.swift @@ -0,0 +1,53 @@ +// +// SyncSettings.swift +// TablePro +// +// User-configurable sync preferences +// + +import Foundation + +/// User preferences for iCloud sync behavior +struct SyncSettings: Codable, Equatable { + var enabled: Bool + var syncConnections: Bool + var syncGroupsAndTags: Bool + var syncSettings: Bool + var syncQueryHistory: Bool + var historySyncLimit: HistorySyncLimit + + static let `default` = SyncSettings( + enabled: false, + syncConnections: true, + syncGroupsAndTags: true, + syncSettings: true, + syncQueryHistory: true, + historySyncLimit: .entries500 + ) +} + +/// Maximum number of query history entries to sync +enum HistorySyncLimit: String, Codable, CaseIterable { + case entries100 = "100" + case entries500 = "500" + case entries1000 = "1000" + case unlimited = "unlimited" + + var displayName: String { + switch self { + case .entries100: return "100" + case .entries500: return "500" + case .entries1000: return "1,000" + case .unlimited: return String(localized: "Unlimited") + } + } + + var limit: Int? { + switch self { + case .entries100: return 100 + case .entries500: return 500 + case .entries1000: return 1_000 + case .unlimited: return nil + } + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 73ee30d8..78852cda 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -97,6 +97,9 @@ } } } + }, + "\"%@\" was modified on both this Mac and another device." : { + }, "\"%@\" will be removed from your system. This action cannot be undone." : { "localizations" : { @@ -470,6 +473,9 @@ } } } + }, + "%@ requires a Pro license" : { + }, "%@ rows" : { "localizations" : { @@ -1224,6 +1230,9 @@ } } } + }, + "1 of %lld conflicts" : { + }, "1 year" : { "localizations" : { @@ -1642,6 +1651,9 @@ } } } + }, + "A sync conflict was detected and needs to be resolved." : { + }, "About TablePro" : { "localizations" : { @@ -1675,6 +1687,9 @@ } } } + }, + "Account:" : { + }, "Activate" : { "localizations" : { @@ -1691,6 +1706,9 @@ } } } + }, + "Activate License..." : { + }, "Activation Failed" : { "localizations" : { @@ -2338,6 +2356,9 @@ } } } + }, + "An unknown sync error occurred: %@" : { + }, "and" : { "localizations" : { @@ -4422,6 +4443,9 @@ } } } + }, + "Connections:" : { + }, "Constraint name" : { "extractionState" : "stale", @@ -7675,6 +7699,9 @@ } } } + }, + "Failed to encode sync data: %@" : { + }, "Failed to fetch table structure: %@" : { "extractionState" : "stale", @@ -8687,6 +8714,9 @@ } } } + }, + "Groups & Tags:" : { + }, "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : { "localizations" : { @@ -8816,6 +8846,9 @@ } } } + }, + "History Limit:" : { + }, "Homepage" : { "localizations" : { @@ -8854,6 +8887,27 @@ }, "Huge" : { + }, + "iCloud account is not available. Sign in to iCloud in System Settings." : { + + }, + "iCloud Connected" : { + + }, + "iCloud server error: %@" : { + + }, + "iCloud storage is full. Free up space or reduce the history sync limit." : { + + }, + "iCloud Sync" : { + + }, + "iCloud Sync is active" : { + + }, + "iCloud Sync:" : { + }, "Icon Sizes" : { @@ -9856,6 +9910,12 @@ } } } + }, + "Keep Other Version" : { + + }, + "Keep This Mac's Version" : { + }, "Key File" : { "localizations" : { @@ -10006,6 +10066,12 @@ } } } + }, + "Last synced %@" : { + + }, + "Last Synced:" : { + }, "Latency: %lldms" : { "localizations" : { @@ -10107,6 +10173,9 @@ } } } + }, + "License expired — sync paused" : { + }, "License Key:" : { "localizations" : { @@ -10652,6 +10721,9 @@ } } } + }, + "Modified:" : { + }, "MongoDB" : { "extractionState" : "stale", @@ -10852,6 +10924,9 @@ } } } + }, + "Network is unavailable. Changes will sync when connectivity is restored." : { + }, "Never" : { "localizations" : { @@ -11355,6 +11430,9 @@ } } } + }, + "No iCloud" : { + }, "No Indexes Defined" : { "localizations" : { @@ -11953,6 +12031,9 @@ } } } + }, + "Not Available" : { + }, "Not connected" : { "localizations" : { @@ -12676,6 +12757,9 @@ } } } + }, + "Other Device" : { + }, "Page size must be between %@ and %@" : { "localizations" : { @@ -13736,6 +13820,9 @@ } } } + }, + "Pro license required for iCloud Sync" : { + }, "Prompt at Connect" : { "localizations" : { @@ -13914,6 +14001,9 @@ } } } + }, + "Query History:" : { + }, "Query timeout:" : { "localizations" : { @@ -14552,6 +14642,9 @@ } } } + }, + "Renew License..." : { + }, "Reopen Last Session" : { "localizations" : { @@ -15899,6 +15992,15 @@ } } } + }, + "Settings were changed" : { + + }, + "Settings were changed on both this Mac and another device." : { + + }, + "Settings:" : { + }, "Share anonymous usage data" : { "localizations" : { @@ -16178,6 +16280,12 @@ } } } + }, + "Sign in to iCloud in System Settings to enable sync." : { + + }, + "Sign in to iCloud to enable sync" : { + }, "Silent" : { "localizations" : { @@ -17082,6 +17190,45 @@ } } } + }, + "Sync" : { + + }, + "Sync Categories" : { + + }, + "Sync Conflict" : { + + }, + "Sync connections, settings, and history across your Macs." : { + + }, + "Sync Error" : { + + }, + "Sync Now" : { + + }, + "Sync Off" : { + + }, + "Sync paused — Pro license expired" : { + + }, + "Sync zone not found. A full sync will be performed." : { + + }, + "Synced" : { + + }, + "Syncing with iCloud..." : { + + }, + "Syncing..." : { + + }, + "Syncs connections, settings, and history across your Macs via iCloud." : { + }, "Syntax Colors" : { @@ -17648,6 +17795,9 @@ }, "This is a registry theme." : { + }, + "This Mac" : { + }, "This Month" : { "localizations" : { @@ -19577,6 +19727,9 @@ } } } + }, + "Your license has expired" : { + }, "Zero Fill" : { "extractionState" : "stale", diff --git a/TablePro/TablePro.entitlements b/TablePro/TablePro.entitlements index 80fbf9cf..e658a7de 100644 --- a/TablePro/TablePro.entitlements +++ b/TablePro/TablePro.entitlements @@ -6,5 +6,13 @@ com.apple.security.cs.disable-library-validation + com.apple.developer.icloud-container-identifiers + + iCloud.com.TablePro + + com.apple.developer.icloud-services + + CloudKit + diff --git a/TablePro/Views/Components/ConflictResolutionView.swift b/TablePro/Views/Components/ConflictResolutionView.swift new file mode 100644 index 00000000..0c620621 --- /dev/null +++ b/TablePro/Views/Components/ConflictResolutionView.swift @@ -0,0 +1,203 @@ +// +// ConflictResolutionView.swift +// TablePro +// +// Sheet for resolving sync conflicts between local and remote versions +// + +import CloudKit +import SwiftUI + +struct ConflictResolutionView: View { + @Bindable private var conflictResolver = ConflictResolver.shared + @Environment(\.dismiss) private var dismiss + + var body: some View { + if let conflict = conflictResolver.currentConflict { + VStack(spacing: 16) { + header(for: conflict) + description(for: conflict) + comparisonBoxes(for: conflict) + actionButtons(for: conflict) + progressIndicator + } + .padding(24) + .frame(width: 500) + } + } + + // MARK: - Header + + private func header(for conflict: SyncConflict) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title2) + .foregroundStyle(.orange) + Text(String(localized: "Sync Conflict")) + .font(.headline) + } + } + + // MARK: - Description + + private func description(for conflict: SyncConflict) -> some View { + Group { + if conflict.recordType == .settings { + Text(String(localized: "Settings were changed on both this Mac and another device.")) + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + Text( + String( + localized: "\"\(conflict.entityName)\" was modified on both this Mac and another device." + ) + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .multilineTextAlignment(.center) + } + + // MARK: - Comparison Boxes + + private func comparisonBoxes(for conflict: SyncConflict) -> some View { + HStack(spacing: 12) { + GroupBox { + VStack(alignment: .leading, spacing: 8) { + Label(String(localized: "This Mac"), systemImage: "desktopcomputer") + .font(.subheadline.bold()) + + Divider() + + LabeledContent(String(localized: "Modified:")) { + Text(conflict.localModifiedAt, style: .date) + Text(conflict.localModifiedAt, style: .time) + } + .font(.caption) + + changedFields(from: conflict.localRecord, conflict: conflict) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(4) + } + + GroupBox { + VStack(alignment: .leading, spacing: 8) { + Label(String(localized: "Other Device"), systemImage: "laptopcomputer") + .font(.subheadline.bold()) + + Divider() + + LabeledContent(String(localized: "Modified:")) { + Text(conflict.serverModifiedAt, style: .date) + Text(conflict.serverModifiedAt, style: .time) + } + .font(.caption) + + changedFields(from: conflict.serverRecord, conflict: conflict) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(4) + } + } + } + + @ViewBuilder + private func changedFields(from record: CKRecord, conflict: SyncConflict) -> some View { + switch conflict.recordType { + case .connection: + if let host = record["host"] as? String { + fieldRow(label: "Host", value: host) + } + if let port = record["port"] as? Int64 { + fieldRow(label: "Port", value: "\(port)") + } + if let database = record["database"] as? String { + fieldRow(label: "Database", value: database) + } + if let username = record["username"] as? String { + fieldRow(label: "User", value: username) + } + case .settings: + Text(String(localized: "Settings were changed")) + .font(.caption) + .foregroundStyle(.secondary) + case .group, .tag: + if let name = record["name"] as? String { + fieldRow(label: "Name", value: name) + } + if let color = record["color"] as? String { + fieldRow(label: "Color", value: color) + } + case .queryHistory: + if let query = record["query"] as? String { + let nsQuery = query as NSString + let preview = nsQuery.length > 80 + ? nsQuery.substring(to: 80) + "..." + : query + fieldRow(label: "Query", value: preview) + } + } + } + + private func fieldRow(label: String, value: String) -> some View { + LabeledContent(label + ":") { + Text(value) + .lineLimit(1) + } + .font(.caption) + } + + // MARK: - Action Buttons + + private func actionButtons(for conflict: SyncConflict) -> some View { + HStack(spacing: 12) { + Button(String(localized: "Keep Other Version")) { + resolveConflict(keepLocal: false) + } + .buttonStyle(.bordered) + + Button(String(localized: "Keep This Mac's Version")) { + resolveConflict(keepLocal: true) + } + .buttonStyle(.borderedProminent) + } + } + + // MARK: - Progress + + private var progressIndicator: some View { + Group { + let total = conflictResolver.pendingConflicts.count + if total > 1 { + Text( + String( + localized: "1 of \(total) conflicts" + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Actions + + private func resolveConflict(keepLocal: Bool) { + let resolvedRecord = conflictResolver.resolveCurrentConflict(keepLocal: keepLocal) + + if let record = resolvedRecord { + SyncCoordinator.shared.pushResolvedConflict(record) + } + + if !conflictResolver.hasConflicts { + dismiss() + } + } +} + +#Preview { + ConflictResolutionView() + .frame(width: 500) +} diff --git a/TablePro/Views/Components/ProFeatureGate.swift b/TablePro/Views/Components/ProFeatureGate.swift new file mode 100644 index 00000000..feb92541 --- /dev/null +++ b/TablePro/Views/Components/ProFeatureGate.swift @@ -0,0 +1,83 @@ +// +// ProFeatureGate.swift +// TablePro +// +// View modifier that gates content behind a Pro license +// + +import SwiftUI + +/// Overlays a "Pro required" message on content when the user lacks an active license +struct ProFeatureGateModifier: ViewModifier { + let feature: ProFeature + + private let licenseManager = LicenseManager.shared + + func body(content: Content) -> some View { + let available = licenseManager.isFeatureAvailable(feature) + + content + .disabled(!available) + .overlay { + if !available { + proRequiredOverlay + } + } + } + + @ViewBuilder + private var proRequiredOverlay: some View { + let access = licenseManager.checkFeature(feature) + + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + + VStack(spacing: 12) { + Image(systemName: feature.systemImage) + .font(.system(size: 40)) + .foregroundStyle(.secondary) + + switch access { + case .available: + EmptyView() + case .expired: + Text("Your license has expired") + .font(.headline) + Text(feature.featureDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + Button(String(localized: "Renew License...")) { + openLicenseSettings() + } + .buttonStyle(.borderedProminent) + case .unlicensed: + Text("\(feature.displayName) requires a Pro license") + .font(.headline) + Text(feature.featureDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + Button(String(localized: "Activate License...")) { + openLicenseSettings() + } + .buttonStyle(.borderedProminent) + } + } + .padding() + } + } + + private func openLicenseSettings() { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + UserDefaults.standard.set(SettingsTab.license.rawValue, forKey: "selectedSettingsTab") + } + } +} + +extension View { + /// Gate this view behind a Pro license requirement + func requiresPro(_ feature: ProFeature) -> some View { + modifier(ProFeatureGateModifier(feature: feature)) + } +} diff --git a/TablePro/Views/Components/SyncStatusIndicator.swift b/TablePro/Views/Components/SyncStatusIndicator.swift new file mode 100644 index 00000000..7a8dba8b --- /dev/null +++ b/TablePro/Views/Components/SyncStatusIndicator.swift @@ -0,0 +1,126 @@ +// +// SyncStatusIndicator.swift +// TablePro +// +// Small cloud icon showing sync status in the welcome window footer +// + +import SwiftUI + +struct SyncStatusIndicator: View { + private let syncCoordinator = SyncCoordinator.shared + + var body: some View { + if shouldShow { + Button { + openSyncSettings() + } label: { + HStack(spacing: 4) { + Image(systemName: iconName) + Text(statusLabel) + } + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(foregroundStyle) + } + .buttonStyle(.plain) + .help(helpText) + } + } + + // MARK: - State Mapping + + private var shouldShow: Bool { + if case .disabled(.userDisabled) = syncCoordinator.syncStatus { + return false + } + return true + } + + private var iconName: String { + switch syncCoordinator.syncStatus { + case .idle: + return "cloud.fill" + case .syncing: + return "arrow.triangle.2.circlepath" + case .error: + return "exclamationmark.icloud" + case .disabled(.noAccount): + return "icloud.slash" + case .disabled(.licenseRequired), .disabled(.licenseExpired): + return "xmark.icloud" + case .disabled(.userDisabled): + return "icloud.slash" + } + } + + private var statusLabel: String { + switch syncCoordinator.syncStatus { + case .idle: + return String(localized: "Synced") + case .syncing: + return String(localized: "Syncing...") + case .error: + return String(localized: "Sync Error") + case .disabled(.noAccount): + return String(localized: "No iCloud") + case .disabled(.licenseRequired), .disabled(.licenseExpired): + return String(localized: "Sync Off") + case .disabled(.userDisabled): + return "" + } + } + + private var foregroundStyle: some ShapeStyle { + switch syncCoordinator.syncStatus { + case .idle: + return AnyShapeStyle(.tertiary) + case .syncing: + return AnyShapeStyle(.secondary) + case .error: + return AnyShapeStyle(Color.orange) + case .disabled: + return AnyShapeStyle(.tertiary) + } + } + + private var helpText: String { + switch syncCoordinator.syncStatus { + case .idle: + if let lastSync = syncCoordinator.lastSyncDate { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + let relative = formatter.localizedString(for: lastSync, relativeTo: Date()) + return String(localized: "Last synced \(relative)") + } + return String(localized: "iCloud Sync is active") + case .syncing: + return String(localized: "Syncing with iCloud...") + case .error(let error): + return error.localizedDescription + case .disabled(.noAccount): + return String(localized: "Sign in to iCloud to enable sync") + case .disabled(.licenseRequired): + return String(localized: "Pro license required for iCloud Sync") + case .disabled(.licenseExpired): + return String(localized: "License expired — sync paused") + case .disabled(.userDisabled): + return "" + } + } + + // MARK: - Actions + + private func openSyncSettings() { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + UserDefaults.standard.set(SettingsTab.sync.rawValue, forKey: "selectedSettingsTab") + } + } +} + +#Preview { + HStack(spacing: 16) { + SyncStatusIndicator() + } + .padding() +} diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 471ab662..65d9a7f2 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -1025,6 +1025,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if isNew { savedConnections.append(connectionToSave) storage.saveConnections(savedConnections) + SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) // Close and connect to database NSApplication.shared.closeWindows(withId: "connection-form") connectToDatabase(connectionToSave) @@ -1032,6 +1033,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if let index = savedConnections.firstIndex(where: { $0.id == connectionToSave.id }) { savedConnections[index] = connectionToSave storage.saveConnections(savedConnections) + SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) } NSApplication.shared.closeWindows(withId: "connection-form") NotificationCenter.default.post(name: .connectionUpdated, object: nil) @@ -1041,8 +1043,12 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length private func deleteConnection() { guard let id = connectionId else { return } var savedConnections = storage.loadConnections() + let hadConnection = savedConnections.contains { $0.id == id } savedConnections.removeAll { $0.id == id } storage.saveConnections(savedConnections) + if hadConnection { + SyncChangeTracker.shared.markDeleted(.connection, id: id.uuidString) + } NSApplication.shared.closeWindows(withId: "connection-form") NotificationCenter.default.post(name: .connectionUpdated, object: nil) } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 3fb1145a..16d425af 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -178,6 +178,7 @@ struct WelcomeWindowView: View { // Footer hints HStack(spacing: 16) { + SyncStatusIndicator() KeyboardHint(keys: "⌘N", label: "New") KeyboardHint(keys: "⌘,", label: "Settings") } diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index 355aa5f7..4bb4f407 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -9,7 +9,7 @@ import SwiftUI /// Settings tab identifiers for programmatic navigation enum SettingsTab: String { - case general, appearance, editor, dataGrid, keyboard, history, ai, plugins, license + case general, appearance, editor, dataGrid, keyboard, history, ai, plugins, sync, license } /// Main settings view with tab-based navigation (macOS Settings style) @@ -68,6 +68,13 @@ struct SettingsView: View { } .tag(SettingsTab.plugins.rawValue) + SyncSettingsView() + .tabItem { + Label("Sync", systemImage: "icloud") + } + .tag(SettingsTab.sync.rawValue) + .requiresPro(.iCloudSync) + LicenseSettingsView() .tabItem { Label("License", systemImage: "key") diff --git a/TablePro/Views/Settings/SyncSettingsView.swift b/TablePro/Views/Settings/SyncSettingsView.swift new file mode 100644 index 00000000..2a381823 --- /dev/null +++ b/TablePro/Views/Settings/SyncSettingsView.swift @@ -0,0 +1,170 @@ +// +// SyncSettingsView.swift +// TablePro +// +// Settings for iCloud sync configuration +// + +import SwiftUI + +struct SyncSettingsView: View { + @Bindable private var syncCoordinator = SyncCoordinator.shared + @State private var syncSettings: SyncSettings = AppSettingsStorage.shared.loadSync() + + private let licenseManager = LicenseManager.shared + + var body: some View { + Form { + Section("iCloud Sync") { + Toggle("iCloud Sync:", isOn: $syncSettings.enabled) + .onChange(of: syncSettings.enabled) { _, newValue in + persistSettings() + if newValue { + syncCoordinator.enableSync() + } else { + syncCoordinator.disableSync() + } + } + + Text("Syncs connections, settings, and history across your Macs via iCloud.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if syncSettings.enabled { + statusSection + + syncCategoriesSection + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .overlay { + if case .disabled(.licenseExpired) = syncCoordinator.syncStatus { + licensePausedBanner + } + } + } + + // MARK: - Status Section + + @ViewBuilder + private var statusSection: some View { + Section("Status") { + if syncCoordinator.iCloudAccountAvailable { + LabeledContent(String(localized: "Account:")) { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.caption) + Text(String(localized: "iCloud Connected")) + } + } + } else { + LabeledContent(String(localized: "Account:")) { + Text(String(localized: "Not Available")) + .foregroundStyle(.secondary) + } + + Text("Sign in to iCloud in System Settings to enable sync.") + .font(.caption) + .foregroundStyle(.orange) + } + + if let lastSync = syncCoordinator.lastSyncDate { + LabeledContent(String(localized: "Last Synced:")) { + Text(lastSync, style: .relative) + } + } + + HStack(spacing: 8) { + Button(String(localized: "Sync Now")) { + Task { + await syncCoordinator.syncNow() + } + } + .disabled(syncCoordinator.syncStatus.isSyncing || !syncCoordinator.iCloudAccountAvailable) + + if syncCoordinator.syncStatus.isSyncing { + ProgressView() + .controlSize(.small) + } + } + + if case .error(let error) = syncCoordinator.syncStatus { + Text(error.localizedDescription) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + // MARK: - Sync Categories Section + + private var syncCategoriesSection: some View { + Section("Sync Categories") { + Toggle("Connections:", isOn: $syncSettings.syncConnections) + .onChange(of: syncSettings.syncConnections) { _, _ in persistSettings() } + + Toggle("Groups & Tags:", isOn: $syncSettings.syncGroupsAndTags) + .onChange(of: syncSettings.syncGroupsAndTags) { _, _ in persistSettings() } + + Toggle("Settings:", isOn: $syncSettings.syncSettings) + .onChange(of: syncSettings.syncSettings) { _, _ in persistSettings() } + + Toggle("Query History:", isOn: $syncSettings.syncQueryHistory) + .onChange(of: syncSettings.syncQueryHistory) { _, _ in persistSettings() } + + if syncSettings.syncQueryHistory { + Picker("History Limit:", selection: $syncSettings.historySyncLimit) { + ForEach(HistorySyncLimit.allCases, id: \.self) { limit in + Text(limit.displayName).tag(limit) + } + } + .onChange(of: syncSettings.historySyncLimit) { _, _ in persistSettings() } + } + } + } + + // MARK: - License Paused Banner + + private var licensePausedBanner: some View { + VStack { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(String(localized: "Sync paused — Pro license expired")) + .font(.callout) + Spacer() + Button(String(localized: "Renew License...")) { + openLicenseSettings() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding() + + Spacer() + } + } + + // MARK: - Helpers + + private func persistSettings() { + AppSettingsStorage.shared.saveSync(syncSettings) + } + + private func openLicenseSettings() { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + UserDefaults.standard.set(SettingsTab.license.rawValue, forKey: "selectedSettingsTab") + } + } +} + +#Preview { + SyncSettingsView() + .frame(width: 450, height: 400) +} diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index fcbdff2e..65d8b962 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -1,6 +1,6 @@ --- title: Settings Overview -description: All settings categories - General, Appearance, Editor, Data Grid, Tabs, Keyboard, AI, History, Plugins, and License +description: All settings categories - General, Appearance, Editor, Data Grid, Tabs, Keyboard, AI, History, Plugins, Sync, and License --- # Settings Overview @@ -51,6 +51,9 @@ Open settings via **TablePro** > **Settings** or `Cmd+,`. Manage database driver plugins + + iCloud sync for connections, settings, and history across Macs (Pro) + License activation and management @@ -423,6 +426,62 @@ Some settings are available via `defaults write` for advanced users: | Custom plugin registry | `defaults write com.TablePro com.TablePro.customRegistryURL ` | Use a private plugin registry | | Clear custom registry | `defaults delete com.TablePro com.TablePro.customRegistryURL` | Revert to default registry | +## Sync Settings + +Manage iCloud sync from the **Sync** tab in Settings. This is a Pro feature. + +### iCloud Sync Toggle + +| Setting | Default | Description | +|---------|---------|-------------| +| **iCloud Sync** | Off | Enable or disable iCloud sync across all Macs | + +### Status + +| Element | Description | +|---------|-------------| +| **iCloud Account** | Shows the current iCloud account status | +| **Last Sync** | Timestamp of the most recent sync | +| **Sync Now** | Manually trigger a sync | + +### Sync Categories + +| Setting | Default | Description | +|---------|---------|-------------| +| **Connections** | On | Sync connection details (not passwords) | +| **Groups & Tags** | On | Sync connection organization | +| **App Settings** | On | Sync all 8 settings categories | +| **Query History** | On | Sync query history | + +### History Limit + +| Option | Description | +|--------|-------------| +| **100** | Sync last 100 queries | +| **500** | Sync last 500 queries | +| **1,000** | Sync last 1,000 queries | +| **Unlimited** | Sync all query history | + + +Passwords are never synced. They remain in each Mac's local Keychain. + + +See [iCloud Sync](/features/icloud-sync) for full details on conflict resolution, sync behavior, and troubleshooting. + +{/* Screenshot: Sync settings */} + + Sync settings + Sync settings + + ## License Settings Manage your license from the **License** tab in Settings. @@ -537,6 +596,17 @@ See [Appearance](/customization/appearance) for details. | Shortcut bindings | Keyboard | Customize menu keyboard shortcuts | | Reset to Defaults | Keyboard | Restore all shortcuts to original values | +### Sync-Related + +| Setting | Location | Description | +|---------|----------|-------------| +| iCloud Sync | Sync | Enable/disable iCloud sync (Pro) | +| Sync Connections | Sync | Sync connection details across Macs | +| Sync Groups & Tags | Sync | Sync connection organization | +| Sync App Settings | Sync | Sync preferences across Macs | +| Sync Query History | Sync | Sync query history with configurable limit | +| History Limit | Sync | Max history entries to sync (100/500/1,000/unlimited) | + ### Filter-Related | Setting | Location | Description | diff --git a/docs/development/building.mdx b/docs/development/building.mdx index b39af7d3..56d79c55 100644 --- a/docs/development/building.mdx +++ b/docs/development/building.mdx @@ -98,33 +98,19 @@ xcodebuild \ ## Native Libraries -TablePro uses native C libraries for database connectivity. - -### Library Files - -Located in `Libs/`: - -| File | Purpose | -|------|---------| -| `libmariadb_universal.a` | Universal (arm64 + x86_64) MariaDB library | -| `libmariadb.a` | Architecture-specific (created during build) | +TablePro uses native C libraries for database connectivity. These are hosted on a dedicated [GitHub Release](https://github.com/datlechin/TablePro/releases/tag/libs-v1) and downloaded via `scripts/download-libs.sh`. ### Library Setup -The universal library must exist before building: - ```bash -# Check if universal library exists -ls -la Libs/libmariadb_universal.a - -# If missing, create from architecture-specific libraries -lipo -create \ - Libs/libmariadb_arm64.a \ - Libs/libmariadb_x86_64.a \ - -output Libs/libmariadb_universal.a +# Download libraries (skips if already present) +scripts/download-libs.sh + +# Force re-download +scripts/download-libs.sh --force ``` -The build script extracts the correct architecture slice automatically. +The build script (`build-release.sh`) extracts the correct architecture slice automatically from the universal libraries. ## Creating DMG diff --git a/docs/development/setup.mdx b/docs/development/setup.mdx index a2efa7f7..685a75b6 100644 --- a/docs/development/setup.mdx +++ b/docs/development/setup.mdx @@ -22,7 +22,7 @@ description: Clone, build, and run TablePro locally with Xcode 15+, SwiftLint, a | **SwiftLint** | Code linting | | **SwiftFormat** | Code formatting | | **Homebrew** | Package management | -| **Git LFS** | Pulls pre-built native libraries from `Libs/` | +| **GitHub CLI (`gh`)** | Downloads pre-built native libraries from GitHub Releases | ## Getting Started @@ -32,10 +32,8 @@ description: Clone, build, and run TablePro locally with Xcode 15+, SwiftLint, a git clone https://github.com/datlechin/tablepro.git cd tablepro -# Pull native libraries (stored in Git LFS) -brew install git-lfs -git lfs install -git lfs pull +# Download pre-built native libraries from GitHub Releases +scripts/download-libs.sh # Create required build config (empty is fine for development) touch Secrets.xcconfig @@ -45,7 +43,7 @@ brew install swiftlint swiftformat ``` -Skipping `git lfs pull` leaves `Libs/` with pointer files instead of binaries, causing linker errors. +Skipping `scripts/download-libs.sh` leaves `Libs/` without binaries, causing linker errors. ### Step 2: Open in Xcode @@ -313,7 +311,7 @@ logger.error("Failed to connect: \(error.localizedDescription)") ### Missing Libraries or Secrets.xcconfig -Run `git lfs pull` and `touch Secrets.xcconfig` at the repo root. See [Step 1](#step-1-clone-and-bootstrap). +Run `scripts/download-libs.sh` and `touch Secrets.xcconfig` at the repo root. See [Step 1](#step-1-clone-and-bootstrap). ## Next Steps diff --git a/docs/docs.json b/docs/docs.json index 2e2a5bfe..3fa708c8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -63,7 +63,8 @@ "features/ai-chat", "features/keyboard-shortcuts", "features/deep-links", - "features/safe-mode" + "features/safe-mode", + "features/icloud-sync" ] }, { @@ -157,7 +158,8 @@ "vi/features/ai-chat", "vi/features/keyboard-shortcuts", "vi/features/deep-links", - "vi/features/safe-mode" + "vi/features/safe-mode", + "vi/features/icloud-sync" ] }, { @@ -256,7 +258,8 @@ "zh/features/ai-chat", "zh/features/keyboard-shortcuts", "zh/features/deep-links", - "zh/features/safe-mode" + "zh/features/safe-mode", + "zh/features/icloud-sync" ] }, { diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx new file mode 100644 index 00000000..d7027382 --- /dev/null +++ b/docs/features/icloud-sync.mdx @@ -0,0 +1,179 @@ +--- +title: iCloud Sync +description: Sync connections, settings, and query history across Macs via iCloud (Pro feature) +--- + +# iCloud Sync + +TablePro syncs your connections, groups, settings, and query history across all your Macs via CloudKit. iCloud Sync is a Pro feature that requires an active license. + +## What Syncs (and What Doesn't) + +| Data | Synced | Notes | +|------|--------|-------| +| **Connections** | Yes | Host, port, username, database type, SSH/SSL config | +| **Passwords** | No | Stay in each Mac's local Keychain | +| **Groups & Tags** | Yes | Full connection organization | +| **App Settings** | Yes | All 8 categories (General, Appearance, Editor, Data Grid, History, Tabs, Keyboard, AI) | +| **Query History** | Yes | Configurable limit: 100, 500, 1,000, or unlimited | + + +Passwords are never synced. They remain in your macOS Keychain on each machine. After syncing a connection to a new Mac, you need to enter the password once on that machine. + + +## Enabling iCloud Sync + +1. Open **Settings** (`Cmd+,`) and select the **Sync** tab +2. Toggle **iCloud Sync** on +3. Choose which categories to sync using the per-category toggles +4. Click **Sync Now** or wait for the next automatic sync + +Sync is off by default. You must opt in explicitly. + +{/* Screenshot: Sync settings tab */} + + iCloud Sync settings + iCloud Sync settings + + +## Sync Categories + +Each data type has its own toggle. Sync connections but not history, or settings but not connections. + +| Category | What it includes | +|----------|-----------------| +| **Connections** | All connection details except passwords | +| **Groups & Tags** | Connection groups and color tags | +| **App Settings** | Preferences from all 8 settings categories | +| **Query History** | Past queries up to the configured limit | + +### History Limit + +Control how many history entries sync across devices: + +| Option | Description | +|--------|-------------| +| **100** | Last 100 queries | +| **500** | Last 500 queries | +| **1,000** | Last 1,000 queries | +| **Unlimited** | All query history | + + +Start with 500. Unlimited history sync can slow down initial sync on large histories. + + +## Automatic Sync + +TablePro auto-syncs in three situations: + +- **App launch** -- pulls the latest data from iCloud +- **App foreground** -- syncs when you switch back to TablePro +- **Local edits** -- pushes changes 2 seconds after you modify synced data (debounced to avoid excess writes) + +Use the **Sync Now** button in Settings > Sync to trigger a manual sync at any time. + +## Conflict Resolution + +When the same record is modified on two Macs before syncing, TablePro prompts you to choose: + +| Option | Description | +|--------|-------------| +| **Keep This Mac's Version** | Discard the remote change, keep local | +| **Keep Other Version** | Discard the local change, use the remote version | + +Conflicts are resolved per-record, not per-category. Editing a connection on Mac A and a different connection on Mac B causes no conflict. + +{/* Screenshot: Conflict resolution dialog */} + + Sync conflict resolution + Sync conflict resolution + + +## Sync Status Indicator + +The welcome window footer shows the current sync status: + +| Status | Meaning | +|--------|---------| +| **Synced** | All data is up to date | +| **Syncing** | Sync in progress | +| **Error** | Sync failed (hover for details) | +| **Off** | iCloud Sync is disabled | + +{/* Screenshot: Sync status in welcome window footer */} + + Sync status indicator + Sync status indicator + + +## Pro License Requirement + +iCloud Sync requires an active Pro license. Without one, the Sync settings tab shows a "Pro required" overlay. + +When a license expires: + +- Sync stops immediately +- All previously synced data stays on each Mac as local data +- Re-activating a license resumes sync from where it left off + +## Troubleshooting + +### No records syncing + +1. Confirm iCloud is signed in: **System Settings** > **Apple Account** > **iCloud** +2. Check that iCloud Drive is enabled +3. Verify the specific category toggle is on in Settings > Sync +4. Click **Sync Now** to force a sync attempt + +### Stale data on one Mac + +Click **Sync Now** on the Mac with outdated data. If the problem persists, toggle iCloud Sync off and on again. This forces a full re-sync. + +### "iCloud account unavailable" error + +TablePro requires an active iCloud account. Sign in via **System Settings** > **Apple Account**. Corporate Managed Apple IDs work as long as iCloud Drive is not restricted by MDM policy. + +### Sync stopped after license expired + +This is expected. Sync requires an active Pro license. Renew or re-activate your license to resume syncing. + +## Related Pages + + + + All settings categories + + + License activation and management + + + Browse previous queries + + + Per-connection query execution controls + + diff --git a/docs/vi/development/building.mdx b/docs/vi/development/building.mdx index a15e1862..064f54d5 100644 --- a/docs/vi/development/building.mdx +++ b/docs/vi/development/building.mdx @@ -98,33 +98,19 @@ xcodebuild \ ## Thư Viện Native -TablePro dùng thư viện C native cho kết nối database. - -### Tệp thư viện - -Trong `Libs/`: - -| File | Mục đích | -|------|---------| -| `libmariadb_universal.a` | MariaDB universal (arm64 + x86_64) | -| `libmariadb.a` | Đặc thù kiến trúc (tạo khi build) | +TablePro dùng thư viện C native cho kết nối database. Các thư viện được lưu trữ trên [GitHub Release](https://github.com/datlechin/TablePro/releases/tag/libs-v1) riêng và tải về qua `scripts/download-libs.sh`. ### Thiết lập thư viện -Thư viện universal phải tồn tại trước khi build: - ```bash -# Kiểm tra -ls -la Libs/libmariadb_universal.a - -# Nếu thiếu, tạo từ thư viện từng kiến trúc -lipo -create \ - Libs/libmariadb_arm64.a \ - Libs/libmariadb_x86_64.a \ - -output Libs/libmariadb_universal.a +# Tải thư viện (bỏ qua nếu đã có) +scripts/download-libs.sh + +# Tải lại +scripts/download-libs.sh --force ``` -Build script tự động trích slice kiến trúc đúng. +Build script (`build-release.sh`) tự động trích slice kiến trúc đúng từ thư viện universal. ## Tạo DMG diff --git a/docs/vi/development/setup.mdx b/docs/vi/development/setup.mdx index 0ba498d1..8af12172 100644 --- a/docs/vi/development/setup.mdx +++ b/docs/vi/development/setup.mdx @@ -22,7 +22,7 @@ description: Clone, build và chạy TablePro cục bộ với Xcode 15+, SwiftL | **SwiftLint** | Code linting | | **SwiftFormat** | Code formatting | | **Homebrew** | Quản lý package | -| **Git LFS** | Pull thư viện native từ `Libs/` | +| **GitHub CLI (`gh`)** | Tải thư viện native từ GitHub Releases | ## Bắt đầu @@ -32,10 +32,8 @@ description: Clone, build và chạy TablePro cục bộ với Xcode 15+, SwiftL git clone https://github.com/datlechin/tablepro.git cd tablepro -# Pull thư viện native (lưu trong Git LFS) -brew install git-lfs -git lfs install -git lfs pull +# Tải thư viện native từ GitHub Releases +scripts/download-libs.sh # Tạo file config bắt buộc (file trống đủ cho phát triển) touch Secrets.xcconfig @@ -45,7 +43,7 @@ brew install swiftlint swiftformat ``` -Bỏ qua `git lfs pull` sẽ khiến `Libs/` chứa file con trỏ thay vì binary, gây lỗi linker. +Bỏ qua `scripts/download-libs.sh` sẽ khiến `Libs/` không có binary, gây lỗi linker. ### Bước 2: Mở trong Xcode @@ -313,7 +311,7 @@ logger.error("Failed to connect: \(error.localizedDescription)") ### Thiếu thư viện hoặc Secrets.xcconfig -Chạy `git lfs pull` và `touch Secrets.xcconfig` tại thư mục gốc. Xem [Bước 1](#bước-1-clone-và-bootstrap). +Chạy `scripts/download-libs.sh` và `touch Secrets.xcconfig` tại thư mục gốc. Xem [Bước 1](#bước-1-clone-và-bootstrap). ## Bước tiếp theo diff --git a/docs/vi/features/icloud-sync.mdx b/docs/vi/features/icloud-sync.mdx new file mode 100644 index 00000000..b975cb3a --- /dev/null +++ b/docs/vi/features/icloud-sync.mdx @@ -0,0 +1,179 @@ +--- +title: Đồng bộ iCloud +description: Đồng bộ kết nối, cài đặt và lịch sử truy vấn giữa các máy Mac qua iCloud (tính năng Pro) +--- + +# Đồng bộ iCloud + +TablePro đồng bộ kết nối, nhóm, cài đặt và lịch sử truy vấn giữa các máy Mac của bạn qua CloudKit. Đồng bộ iCloud là tính năng Pro, yêu cầu giấy phép đang hoạt động. + +## Dữ liệu được đồng bộ (và không được đồng bộ) + +| Dữ liệu | Đồng bộ | Ghi chú | +|----------|---------|---------| +| **Kết nối** | Có | Host, port, tên người dùng, loại cơ sở dữ liệu, cấu hình SSH/SSL | +| **Mật khẩu** | Không | Lưu trong Keychain riêng của từng máy Mac | +| **Nhóm & Thẻ** | Có | Toàn bộ cách tổ chức kết nối | +| **Cài đặt ứng dụng** | Có | Tất cả 8 danh mục (Chung, Giao diện, Trình soạn thảo, Lưới dữ liệu, Lịch sử, Tab, Phím tắt, AI) | +| **Lịch sử truy vấn** | Có | Giới hạn có thể cấu hình: 100, 500, 1.000, hoặc không giới hạn | + + +Mật khẩu không bao giờ được đồng bộ. Chúng luôn nằm trong macOS Keychain trên mỗi máy. Sau khi đồng bộ kết nối sang máy Mac mới, bạn cần nhập mật khẩu một lần trên máy đó. + + +## Bật Đồng bộ iCloud + +1. Mở **Cài đặt** (`Cmd+,`) và chọn tab **Đồng bộ** +2. Bật **Đồng bộ iCloud** +3. Chọn danh mục muốn đồng bộ bằng các công tắc riêng +4. Nhấn **Đồng bộ ngay** hoặc đợi đồng bộ tự động + +Đồng bộ mặc định tắt. Bạn phải bật thủ công. + +{/* Screenshot: Tab cài đặt Đồng bộ */} + + Cài đặt Đồng bộ iCloud + Cài đặt Đồng bộ iCloud + + +## Danh mục đồng bộ + +Mỗi loại dữ liệu có công tắc riêng. Bạn có thể đồng bộ kết nối nhưng không đồng bộ lịch sử, hoặc cài đặt nhưng không đồng bộ kết nối. + +| Danh mục | Bao gồm | +|----------|---------| +| **Kết nối** | Tất cả thông tin kết nối trừ mật khẩu | +| **Nhóm & Thẻ** | Nhóm kết nối và thẻ màu | +| **Cài đặt ứng dụng** | Tuỳ chọn từ tất cả 8 danh mục cài đặt | +| **Lịch sử truy vấn** | Các truy vấn trước đó theo giới hạn đã cấu hình | + +### Giới hạn lịch sử + +Kiểm soát số lượng mục lịch sử được đồng bộ giữa các thiết bị: + +| Tuỳ chọn | Mô tả | +|-----------|-------| +| **100** | 100 truy vấn gần nhất | +| **500** | 500 truy vấn gần nhất | +| **1.000** | 1.000 truy vấn gần nhất | +| **Không giới hạn** | Toàn bộ lịch sử truy vấn | + + +Nên bắt đầu với 500. Đồng bộ không giới hạn có thể làm chậm lần đồng bộ đầu tiên với lịch sử lớn. + + +## Đồng bộ tự động + +TablePro tự động đồng bộ trong ba trường hợp: + +- **Khởi động ứng dụng** -- lấy dữ liệu mới nhất từ iCloud +- **Ứng dụng trở lại foreground** -- đồng bộ khi bạn chuyển về TablePro +- **Chỉnh sửa cục bộ** -- đẩy thay đổi sau 2 giây khi bạn sửa dữ liệu đồng bộ (debounce để tránh ghi quá nhiều) + +Dùng nút **Đồng bộ ngay** trong Cài đặt > Đồng bộ để đồng bộ thủ công bất cứ lúc nào. + +## Giải quyết xung đột + +Khi cùng một bản ghi được sửa trên hai máy Mac trước khi đồng bộ, TablePro sẽ yêu cầu bạn chọn: + +| Tuỳ chọn | Mô tả | +|-----------|-------| +| **Giữ phiên bản máy này** | Bỏ thay đổi từ xa, giữ bản cục bộ | +| **Giữ phiên bản khác** | Bỏ thay đổi cục bộ, dùng bản từ xa | + +Xung đột được giải quyết theo từng bản ghi, không theo danh mục. Sửa kết nối trên máy A và kết nối khác trên máy B sẽ không gây xung đột. + +{/* Screenshot: Hộp thoại giải quyết xung đột */} + + Giải quyết xung đột đồng bộ + Giải quyết xung đột đồng bộ + + +## Chỉ báo trạng thái đồng bộ + +Footer của cửa sổ chào mừng hiển thị trạng thái đồng bộ hiện tại: + +| Trạng thái | Ý nghĩa | +|------------|---------| +| **Đã đồng bộ** | Tất cả dữ liệu đã cập nhật | +| **Đang đồng bộ** | Đồng bộ đang thực hiện | +| **Lỗi** | Đồng bộ thất bại (di chuột để xem chi tiết) | +| **Tắt** | Đồng bộ iCloud đã tắt | + +{/* Screenshot: Trạng thái đồng bộ trong footer cửa sổ chào mừng */} + + Chỉ báo trạng thái đồng bộ + Chỉ báo trạng thái đồng bộ + + +## Yêu cầu giấy phép Pro + +Đồng bộ iCloud yêu cầu giấy phép Pro đang hoạt động. Nếu không có, tab Đồng bộ trong cài đặt sẽ hiện lớp phủ "Yêu cầu Pro". + +Khi giấy phép hết hạn: + +- Đồng bộ dừng ngay lập tức +- Dữ liệu đã đồng bộ trước đó vẫn giữ trên mỗi máy Mac dưới dạng dữ liệu cục bộ +- Kích hoạt lại giấy phép sẽ tiếp tục đồng bộ từ nơi đã dừng + +## Xử lý sự cố + +### Không có bản ghi nào được đồng bộ + +1. Xác nhận đã đăng nhập iCloud: **Cài đặt hệ thống** > **Tài khoản Apple** > **iCloud** +2. Kiểm tra iCloud Drive đã được bật +3. Xác nhận công tắc danh mục cụ thể đã bật trong Cài đặt > Đồng bộ +4. Nhấn **Đồng bộ ngay** để thử đồng bộ + +### Dữ liệu cũ trên một máy Mac + +Nhấn **Đồng bộ ngay** trên máy Mac có dữ liệu lỗi thời. Nếu vẫn không được, tắt rồi bật lại Đồng bộ iCloud. Thao tác này buộc đồng bộ lại toàn bộ. + +### Lỗi "Tài khoản iCloud không khả dụng" + +TablePro yêu cầu tài khoản iCloud đang hoạt động. Đăng nhập qua **Cài đặt hệ thống** > **Tài khoản Apple**. Managed Apple ID của doanh nghiệp vẫn hoạt động miễn là iCloud Drive không bị hạn chế bởi chính sách MDM. + +### Đồng bộ dừng sau khi giấy phép hết hạn + +Đây là hành vi bình thường. Đồng bộ yêu cầu giấy phép Pro đang hoạt động. Gia hạn hoặc kích hoạt lại giấy phép để tiếp tục đồng bộ. + +## Trang liên quan + + + + Tất cả danh mục cài đặt + + + Kích hoạt và quản lý giấy phép + + + Duyệt các truy vấn trước đó + + + Kiểm soát thực thi truy vấn theo kết nối + + diff --git a/docs/zh/development/building.mdx b/docs/zh/development/building.mdx index 1187e15f..d4ca9dc2 100644 --- a/docs/zh/development/building.mdx +++ b/docs/zh/development/building.mdx @@ -98,33 +98,19 @@ xcodebuild \ ## 原生库 -TablePro 使用原生 C 库进行数据库连接。 - -### 库文件 - -位于 `Libs/` 目录下: - -| 文件 | 用途 | -|------|---------| -| `libmariadb_universal.a` | Universal(arm64 + x86_64)MariaDB 库 | -| `libmariadb.a` | 特定架构的库(构建时创建) | +TablePro 使用原生 C 库进行数据库连接。这些库托管在专用的 [GitHub Release](https://github.com/datlechin/TablePro/releases/tag/libs-v1) 上,通过 `scripts/download-libs.sh` 下载。 ### 库设置 -构建前必须存在 universal 库: - ```bash -# 检查 universal 库是否存在 -ls -la Libs/libmariadb_universal.a - -# 如果缺失,从特定架构的库创建 -lipo -create \ - Libs/libmariadb_arm64.a \ - Libs/libmariadb_x86_64.a \ - -output Libs/libmariadb_universal.a +# 下载库(如果已存在则跳过) +scripts/download-libs.sh + +# 强制重新下载 +scripts/download-libs.sh --force ``` -构建脚本会自动提取正确架构的切片。 +构建脚本(`build-release.sh`)会自动从 universal 库中提取正确架构的切片。 ## 创建 DMG diff --git a/docs/zh/development/setup.mdx b/docs/zh/development/setup.mdx index d6540323..902b7709 100644 --- a/docs/zh/development/setup.mdx +++ b/docs/zh/development/setup.mdx @@ -22,7 +22,7 @@ description: 克隆、构建和本地运行 TablePro,使用 Xcode 15+、SwiftL | **SwiftLint** | 代码检查 | | **SwiftFormat** | 代码格式化 | | **Homebrew** | 包管理器 | -| **Git LFS** | 从 `Libs/` 拉取预编译的原生库 | +| **GitHub CLI (`gh`)** | 从 GitHub Releases 下载预编译的原生库 | ## 开始 @@ -32,10 +32,8 @@ description: 克隆、构建和本地运行 TablePro,使用 Xcode 15+、SwiftL git clone https://github.com/datlechin/tablepro.git cd tablepro -# 拉取原生库(存储在 Git LFS 中) -brew install git-lfs -git lfs install -git lfs pull +# 从 GitHub Releases 下载预编译的原生库 +scripts/download-libs.sh # 创建必需的构建配置文件(开发时留空即可) touch Secrets.xcconfig @@ -45,7 +43,7 @@ brew install swiftlint swiftformat ``` -跳过 `git lfs pull` 会导致 `Libs/` 目录下只有指针文件而非实际的二进制文件,从而引发链接器错误。 +跳过 `scripts/download-libs.sh` 会导致 `Libs/` 目录下没有二进制文件,从而引发链接器错误。 ### 步骤 2:在 Xcode 中打开 @@ -313,7 +311,7 @@ logger.error("Failed to connect: \(error.localizedDescription)") ### 缺少库文件或 Secrets.xcconfig -在仓库根目录运行 `git lfs pull` 和 `touch Secrets.xcconfig`。参见[步骤 1](#步骤-1克隆和初始化)。 +在仓库根目录运行 `scripts/download-libs.sh` 和 `touch Secrets.xcconfig`。参见[步骤 1](#步骤-1克隆和初始化)。 ## 下一步 diff --git a/docs/zh/features/icloud-sync.mdx b/docs/zh/features/icloud-sync.mdx new file mode 100644 index 00000000..300dfe32 --- /dev/null +++ b/docs/zh/features/icloud-sync.mdx @@ -0,0 +1,179 @@ +--- +title: iCloud 同步 +description: 通过 iCloud 在多台 Mac 之间同步连接、设置和查询历史(Pro 功能) +--- + +# iCloud 同步 + +TablePro 通过 CloudKit 在你的所有 Mac 之间同步连接、分组、设置和查询历史。iCloud 同步是 Pro 功能,需要有效的许可证。 + +## 同步内容(和不同步的内容) + +| 数据 | 是否同步 | 备注 | +|------|---------|------| +| **连接** | 是 | 主机、端口、用户名、数据库类型、SSH/SSL 配置 | +| **密码** | 否 | 保留在每台 Mac 的本地 Keychain 中 | +| **分组和标签** | 是 | 完整的连接组织结构 | +| **应用设置** | 是 | 全部 8 个类别(通用、外观、编辑器、数据网格、历史、标签页、快捷键、AI) | +| **查询历史** | 是 | 可配置限制:100、500、1,000 或无限制 | + + +密码永远不会被同步。它们始终保留在每台机器的 macOS Keychain 中。将连接同步到新 Mac 后,你需要在该机器上输入一次密码。 + + +## 启用 iCloud 同步 + +1. 打开 **设置**(`Cmd+,`)并选择 **同步** 标签页 +2. 开启 **iCloud 同步** +3. 使用各类别的独立开关选择要同步的内容 +4. 点击 **立即同步** 或等待自动同步 + +同步默认关闭,需要手动开启。 + +{/* Screenshot: 同步设置标签页 */} + + iCloud 同步设置 + iCloud 同步设置 + + +## 同步类别 + +每种数据类型都有独立的开关。可以同步连接但不同步历史,或者同步设置但不同步连接。 + +| 类别 | 包含内容 | +|------|---------| +| **连接** | 除密码外的所有连接信息 | +| **分组和标签** | 连接分组和颜色标签 | +| **应用设置** | 全部 8 个设置类别的偏好设置 | +| **查询历史** | 按配置限制保留的历史查询 | + +### 历史限制 + +控制设备间同步的历史条目数量: + +| 选项 | 说明 | +|------|------| +| **100** | 最近 100 条查询 | +| **500** | 最近 500 条查询 | +| **1,000** | 最近 1,000 条查询 | +| **无限制** | 全部查询历史 | + + +建议从 500 开始。无限制历史同步在历史记录较大时可能导致首次同步变慢。 + + +## 自动同步 + +TablePro 在三种情况下自动同步: + +- **应用启动** -- 从 iCloud 拉取最新数据 +- **应用回到前台** -- 切换回 TablePro 时同步 +- **本地编辑** -- 修改同步数据后 2 秒推送更改(防抖以避免过多写入) + +在设置 > 同步中使用 **立即同步** 按钮可随时手动触发同步。 + +## 冲突解决 + +当同一记录在两台 Mac 上被修改且尚未同步时,TablePro 会提示你选择: + +| 选项 | 说明 | +|------|------| +| **保留本机版本** | 丢弃远程更改,保留本地版本 | +| **保留其他版本** | 丢弃本地更改,使用远程版本 | + +冲突按记录解决,而非按类别。在 Mac A 上编辑一个连接、在 Mac B 上编辑另一个连接不会产生冲突。 + +{/* Screenshot: 冲突解决对话框 */} + + 同步冲突解决 + 同步冲突解决 + + +## 同步状态指示器 + +欢迎窗口底部显示当前同步状态: + +| 状态 | 含义 | +|------|------| +| **已同步** | 所有数据已是最新 | +| **同步中** | 正在同步 | +| **错误** | 同步失败(悬停查看详情) | +| **关闭** | iCloud 同步已关闭 | + +{/* Screenshot: 欢迎窗口底部的同步状态 */} + + 同步状态指示器 + 同步状态指示器 + + +## Pro 许可证要求 + +iCloud 同步需要有效的 Pro 许可证。没有许可证时,设置中的同步标签页会显示"需要 Pro"遮罩。 + +当许可证过期时: + +- 同步立即停止 +- 之前同步的数据作为本地数据保留在每台 Mac 上 +- 重新激活许可证后从中断处继续同步 + +## 故障排除 + +### 没有记录被同步 + +1. 确认已登录 iCloud:**系统设置** > **Apple 账户** > **iCloud** +2. 检查 iCloud Drive 是否已启用 +3. 确认设置 > 同步中对应类别的开关已打开 +4. 点击 **立即同步** 强制尝试同步 + +### 某台 Mac 上数据过时 + +在数据过时的 Mac 上点击 **立即同步**。如果问题持续,关闭再重新打开 iCloud 同步。这会强制执行完整重新同步。 + +### "iCloud 账户不可用"错误 + +TablePro 需要活跃的 iCloud 账户。通过 **系统设置** > **Apple 账户** 登录。企业管理的 Apple ID 可以使用,前提是 iCloud Drive 未被 MDM 策略限制。 + +### 许可证过期后同步停止 + +这是预期行为。同步需要有效的 Pro 许可证。续订或重新激活许可证即可恢复同步。 + +## 相关页面 + + + + 所有设置类别 + + + 许可证激活和管理 + + + 浏览历史查询 + + + 按连接控制查询执行 + +