diff --git a/CHANGELOG.md b/CHANGELOG.md index bb45bd30..b30b7990 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 for connections, tags, settings, and table templates across Macs +- Opt-in toggle in Settings > General (off by default) +- Conflict resolution UI when local and remote data differ +- Password badge indicator on synced connections that need local password entry +- Protocol-based sync engine architecture (SyncEngine, ICloudSyncEngine) + ## [0.1.1] - 2026-02-09 ### Added diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 019e7941..91dd0bb2 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -280,6 +280,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -367,6 +368,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index eeec8fd4..6ce84128 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -19,6 +19,17 @@ final class AppSettingsManager: ObservableObject { @Published var general: GeneralSettings { didSet { storage.saveGeneral(general) + + // Handle iCloud sync toggle changes + if oldValue.iCloudSyncEnabled != general.iCloudSyncEnabled { + if general.iCloudSyncEnabled { + SyncCoordinator.shared.enable() + } else { + SyncCoordinator.shared.disable() + } + } + + SyncCoordinator.shared.didUpdateGeneralSettings(general) notifyChange(domain: "general", notification: .generalSettingsDidChange) } } @@ -27,6 +38,7 @@ final class AppSettingsManager: ObservableObject { didSet { storage.saveAppearance(appearance) appearance.theme.apply() + SyncCoordinator.shared.didUpdateAppearanceSettings(appearance) notifyChange(domain: "appearance", notification: .appearanceSettingsDidChange) } } @@ -36,6 +48,7 @@ final class AppSettingsManager: ObservableObject { storage.saveEditor(editor) // Update cached theme values for thread-safe access SQLEditorTheme.reloadFromSettings(editor) + SyncCoordinator.shared.didUpdateEditorSettings(editor) notifyChange(domain: "editor", notification: .editorSettingsDidChange) } } @@ -50,6 +63,7 @@ final class AppSettingsManager: ObservableObject { storage.saveDataGrid(validated) // Update date formatting service with new format DateFormattingService.shared.updateFormat(validated.dateFormat) + SyncCoordinator.shared.didUpdateDataGridSettings(validated) notifyChange(domain: "dataGrid", notification: .dataGridSettingsDidChange) } } @@ -64,6 +78,7 @@ final class AppSettingsManager: ObservableObject { storage.saveHistory(validated) // Apply history settings immediately (cleanup if auto-cleanup enabled) Task { await applyHistorySettingsImmediately() } + SyncCoordinator.shared.didUpdateHistorySettings(validated) notifyChange(domain: "history", notification: .historySettingsDidChange) } } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 2a949937..22565ef6 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -42,7 +42,7 @@ final class ConnectionStorage { } /// Save all connections - func saveConnections(_ connections: [DatabaseConnection]) { + func saveConnections(_ connections: [DatabaseConnection], triggeredBySync: Bool = false) { let storedConnections = connections.map { StoredConnection(from: $0) } do { @@ -52,6 +52,13 @@ final class ConnectionStorage { } catch { print("Failed to save connections: \(error)") } + + // Push to iCloud if sync enabled (skip when applying remote data) + if !triggeredBySync { + Task { @MainActor in + SyncCoordinator.shared.didUpdateConnections(connections) + } + } } /// Add a new connection @@ -130,6 +137,13 @@ final class ConnectionStorage { return duplicate } + // MARK: - Password Availability + + /// Check if a connection has a local password stored in Keychain + func hasPassword(for connectionId: UUID) -> Bool { + loadPassword(for: connectionId) != nil + } + // MARK: - Keychain (Password Storage) /// Save password to Keychain diff --git a/TablePro/Core/Storage/TableTemplateStorage.swift b/TablePro/Core/Storage/TableTemplateStorage.swift index b1908580..1abd0cb9 100644 --- a/TablePro/Core/Storage/TableTemplateStorage.swift +++ b/TablePro/Core/Storage/TableTemplateStorage.swift @@ -35,7 +35,7 @@ final class TableTemplateStorage { // MARK: - Save/Load /// Save a table template - func saveTemplate(name: String, options: TableCreationOptions) throws { + func saveTemplate(name: String, options: TableCreationOptions, triggeredBySync: Bool = false) throws { var templates = try loadTemplates() templates[name] = options @@ -43,6 +43,13 @@ final class TableTemplateStorage { encoder.outputFormatting = .prettyPrinted let data = try encoder.encode(templates) try data.write(to: templatesURL) + + // Push to iCloud if sync enabled (skip when applying remote data) + if !triggeredBySync { + Task { @MainActor in + SyncCoordinator.shared.didUpdateTemplates(templates) + } + } } /// Load all templates @@ -57,7 +64,7 @@ final class TableTemplateStorage { } /// Delete a template - func deleteTemplate(name: String) throws { + func deleteTemplate(name: String, triggeredBySync: Bool = false) throws { var templates = try loadTemplates() templates.removeValue(forKey: name) @@ -65,6 +72,13 @@ final class TableTemplateStorage { encoder.outputFormatting = .prettyPrinted let data = try encoder.encode(templates) try data.write(to: templatesURL) + + // Push to iCloud if sync enabled (skip when applying remote data) + if !triggeredBySync { + Task { @MainActor in + SyncCoordinator.shared.didUpdateTemplates(templates) + } + } } /// Get template names diff --git a/TablePro/Core/Storage/TagStorage.swift b/TablePro/Core/Storage/TagStorage.swift index 5a18afae..8c590c66 100644 --- a/TablePro/Core/Storage/TagStorage.swift +++ b/TablePro/Core/Storage/TagStorage.swift @@ -40,7 +40,7 @@ final class TagStorage { } /// Save all tags - func saveTags(_ tags: [ConnectionTag]) { + func saveTags(_ tags: [ConnectionTag], triggeredBySync: Bool = false) { do { let encoder = JSONEncoder() let data = try encoder.encode(tags) @@ -48,6 +48,13 @@ final class TagStorage { } catch { print("Failed to save tags: \(error)") } + + // Push to iCloud if sync enabled (skip when applying remote data) + if !triggeredBySync { + Task { @MainActor in + SyncCoordinator.shared.didUpdateTags(tags) + } + } } /// Add a new custom tag diff --git a/TablePro/Core/Sync/ICloudSyncEngine.swift b/TablePro/Core/Sync/ICloudSyncEngine.swift new file mode 100644 index 00000000..c0ec92de --- /dev/null +++ b/TablePro/Core/Sync/ICloudSyncEngine.swift @@ -0,0 +1,67 @@ +// +// ICloudSyncEngine.swift +// TablePro +// +// NSUbiquitousKeyValueStore implementation of SyncEngine. +// + +import Foundation + +/// iCloud sync backend using NSUbiquitousKeyValueStore +final class ICloudSyncEngine: SyncEngine { + private let store = NSUbiquitousKeyValueStore.default + private var observer: NSObjectProtocol? + + var isAvailable: Bool { + FileManager.default.ubiquityIdentityToken != nil + } + + func startObserving(onChange: @escaping ([String]) -> Void) { + // Remove any existing observer first + stopObserving() + + observer = NotificationCenter.default.addObserver( + forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: store, + queue: .main + ) { notification in + guard let userInfo = notification.userInfo, + let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] + else { + return + } + onChange(changedKeys) + } + + // Trigger initial pull from iCloud + store.synchronize() + } + + func stopObserving() { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + observer = nil + } + + func write(_ data: Data, forKey key: String) { + store.set(data, forKey: key) + } + + func read(forKey key: String) -> Data? { + store.data(forKey: key) + } + + func remove(forKey key: String) { + store.removeObject(forKey: key) + } + + @discardableResult + func synchronize() -> Bool { + store.synchronize() + } + + deinit { + stopObserving() + } +} diff --git a/TablePro/Core/Sync/SyncConflict.swift b/TablePro/Core/Sync/SyncConflict.swift new file mode 100644 index 00000000..893a7727 --- /dev/null +++ b/TablePro/Core/Sync/SyncConflict.swift @@ -0,0 +1,33 @@ +// +// SyncConflict.swift +// TablePro +// +// Models for sync conflict detection and resolution. +// + +import Foundation + +/// Represents a sync conflict between local and remote data +struct SyncConflict: Identifiable { + let id = UUID() + let syncKey: String + let dataType: SyncDataType + let remoteTimestamp: Date + let remoteDeviceName: String + let remoteData: Data + + /// Human-readable summary for the conflict UI + var summary: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + let remoteTime = formatter.localizedString(for: remoteTimestamp, relativeTo: Date()) + + return "Local: Modified on this Mac\nRemote: Modified \(remoteTime) on \(remoteDeviceName)" + } +} + +/// User's choice for resolving a conflict +enum ConflictResolution { + case keepLocal + case keepRemote +} diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift new file mode 100644 index 00000000..7cd9c5b5 --- /dev/null +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -0,0 +1,465 @@ +// +// SyncCoordinator.swift +// TablePro +// +// Central coordinator for iCloud sync operations. +// Bridges between local storage singletons and the sync engine. +// + +import Combine +import Foundation +import os + +/// Coordinates sync between local storage and iCloud +@MainActor +final class SyncCoordinator: ObservableObject { + static let shared = SyncCoordinator() + + // MARK: - Published State + + @Published private(set) var isEnabled = false + @Published private(set) var isSyncing = false + @Published private(set) var lastSyncDate: Date? + @Published var pendingConflicts: [SyncConflict] = [] + + // MARK: - Private + + private let engine: SyncEngine + private let deviceId: String + private let deviceName: String + private let defaults = UserDefaults.standard + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + /// Flag to prevent push when applying remote data + private(set) var isSyncingFromRemote = false + + private static let logger = Logger(subsystem: "com.TablePro", category: "Sync") + + // MARK: - Sync Keys (stored in NSUbiquitousKeyValueStore) + + private enum SyncKey { + static let connections = "sync.connections" + static let tags = "sync.tags" + static let settingsGeneral = "sync.settings.general" + static let settingsAppearance = "sync.settings.appearance" + static let settingsEditor = "sync.settings.editor" + static let settingsDataGrid = "sync.settings.dataGrid" + static let settingsHistory = "sync.settings.history" + static let templates = "sync.templates" + + /// Map sync keys to data types + static func dataType(for key: String) -> SyncDataType? { + switch key { + case connections: return .connections + case tags: return .tags + case settingsGeneral: return .generalSettings + case settingsAppearance: return .appearanceSettings + case settingsEditor: return .editorSettings + case settingsDataGrid: return .dataGridSettings + case settingsHistory: return .historySettings + case templates: return .templates + default: return nil + } + } + + /// All sync keys + static let all = [ + connections, tags, + settingsGeneral, settingsAppearance, settingsEditor, settingsDataGrid, settingsHistory, + templates, + ] + } + + /// Keys for tracking last-synced state (stored locally in UserDefaults) + private enum LocalKey { + static let prefix = "com.TablePro.sync.lastSynced." + static let lastSyncDate = "com.TablePro.sync.lastSyncDate" + static let deviceId = "com.TablePro.sync.deviceId" + + static func lastSynced(for syncKey: String) -> String { + prefix + syncKey + } + } + + // MARK: - Initialization + + init(engine: SyncEngine = ICloudSyncEngine()) { + self.engine = engine + self.deviceId = Self.loadOrCreateDeviceId() + self.deviceName = Host.current().localizedName ?? "Unknown Mac" + + // Load last sync date + if let timestamp = defaults.object(forKey: LocalKey.lastSyncDate) as? Date { + self.lastSyncDate = timestamp + } + } + + // MARK: - Enable / Disable + + /// Enable iCloud sync and perform initial push + func enable() { + guard engine.isAvailable else { + Self.logger.warning("Cannot enable sync: iCloud unavailable") + return + } + + isEnabled = true + engine.startObserving { [weak self] changedKeys in + Task { @MainActor in + self?.handleRemoteChanges(changedKeys) + } + } + + // Push all local data to iCloud + performInitialPush() + + Self.logger.info("iCloud sync enabled") + } + + /// Disable iCloud sync + func disable() { + isEnabled = false + engine.stopObserving() + pendingConflicts.removeAll() + Self.logger.info("iCloud sync disabled") + } + + // MARK: - Push Methods (called by storage layers) + + /// Called when connections are saved locally + func didUpdateConnections(_ connections: [DatabaseConnection]) { + guard isEnabled, !isSyncingFromRemote else { return } + pushData(connections, forKey: SyncKey.connections) + } + + /// Called when tags are saved locally + func didUpdateTags(_ tags: [ConnectionTag]) { + guard isEnabled, !isSyncingFromRemote else { return } + pushData(tags, forKey: SyncKey.tags) + } + + /// Called when general settings are saved locally + func didUpdateGeneralSettings(_ settings: GeneralSettings) { + guard isEnabled, !isSyncingFromRemote else { return } + // Strip iCloudSyncEnabled to prevent sync loop + var syncable = settings + syncable.iCloudSyncEnabled = false + pushData(syncable, forKey: SyncKey.settingsGeneral) + } + + /// Called when appearance settings are saved locally + func didUpdateAppearanceSettings(_ settings: AppearanceSettings) { + guard isEnabled, !isSyncingFromRemote else { return } + pushData(settings, forKey: SyncKey.settingsAppearance) + } + + /// Called when editor settings are saved locally + func didUpdateEditorSettings(_ settings: EditorSettings) { + guard isEnabled, !isSyncingFromRemote else { return } + pushData(settings, forKey: SyncKey.settingsEditor) + } + + /// Called when data grid settings are saved locally + func didUpdateDataGridSettings(_ settings: DataGridSettings) { + guard isEnabled, !isSyncingFromRemote else { return } + pushData(settings, forKey: SyncKey.settingsDataGrid) + } + + /// Called when history settings are saved locally + func didUpdateHistorySettings(_ settings: HistorySettings) { + guard isEnabled, !isSyncingFromRemote else { return } + pushData(settings, forKey: SyncKey.settingsHistory) + } + + /// Called when table templates are saved locally + func didUpdateTemplates(_ templates: [String: TableCreationOptions]) { + guard isEnabled, !isSyncingFromRemote else { return } + pushData(templates, forKey: SyncKey.templates) + } + + // MARK: - Conflict Resolution + + /// Resolve a pending conflict with the user's choice + func resolveConflict(_ conflict: SyncConflict, resolution: ConflictResolution) { + switch resolution { + case .keepLocal: + // Re-push local data to overwrite remote + repushLocalData(forKey: conflict.syncKey) + + case .keepRemote: + // Apply remote data locally + applyRemoteData(conflict.remoteData, forKey: conflict.syncKey) + } + + pendingConflicts.removeAll { $0.id == conflict.id } + Self.logger.info("Resolved conflict for \(conflict.syncKey): \(String(describing: resolution))") + } + + // MARK: - Private: Push + + private func pushData(_ value: T, forKey key: String) { + do { + let payload = try encoder.encode(value) + let envelope = SyncEnvelope( + payload: payload, + modifiedAt: Date(), + deviceId: deviceId, + deviceName: deviceName + ) + let data = try encoder.encode(envelope) + engine.write(data, forKey: key) + engine.synchronize() + + // Save last-synced state locally for conflict detection + saveLastSyncedData(payload, forKey: key) + updateLastSyncDate() + + Self.logger.debug("Pushed \(key) to iCloud") + } catch { + Self.logger.error("Failed to push \(key): \(error.localizedDescription)") + } + } + + private func performInitialPush() { + isSyncing = true + defer { isSyncing = false } + + // Push connections + let connections = ConnectionStorage.shared.loadConnections() + pushData(connections, forKey: SyncKey.connections) + + // Push tags + let tags = TagStorage.shared.loadTags() + pushData(tags, forKey: SyncKey.tags) + + // Push settings (strip iCloudSyncEnabled) + let settingsManager = AppSettingsManager.shared + var generalSettings = settingsManager.general + generalSettings.iCloudSyncEnabled = false + pushData(generalSettings, forKey: SyncKey.settingsGeneral) + pushData(settingsManager.appearance, forKey: SyncKey.settingsAppearance) + pushData(settingsManager.editor, forKey: SyncKey.settingsEditor) + pushData(settingsManager.dataGrid, forKey: SyncKey.settingsDataGrid) + pushData(settingsManager.history, forKey: SyncKey.settingsHistory) + + // Push templates + if let templates = try? TableTemplateStorage.shared.loadTemplates() { + pushData(templates, forKey: SyncKey.templates) + } + + Self.logger.info("Initial push completed") + } + + private func repushLocalData(forKey key: String) { + switch key { + case SyncKey.connections: + let connections = ConnectionStorage.shared.loadConnections() + pushData(connections, forKey: key) + case SyncKey.tags: + let tags = TagStorage.shared.loadTags() + pushData(tags, forKey: key) + case SyncKey.settingsGeneral: + var settings = AppSettingsManager.shared.general + settings.iCloudSyncEnabled = false + pushData(settings, forKey: key) + case SyncKey.settingsAppearance: + pushData(AppSettingsManager.shared.appearance, forKey: key) + case SyncKey.settingsEditor: + pushData(AppSettingsManager.shared.editor, forKey: key) + case SyncKey.settingsDataGrid: + pushData(AppSettingsManager.shared.dataGrid, forKey: key) + case SyncKey.settingsHistory: + pushData(AppSettingsManager.shared.history, forKey: key) + case SyncKey.templates: + if let templates = try? TableTemplateStorage.shared.loadTemplates() { + pushData(templates, forKey: key) + } + default: + break + } + } + + // MARK: - Private: Pull / Merge + + private func handleRemoteChanges(_ changedKeys: [String]) { + guard isEnabled else { return } + + for key in changedKeys { + guard SyncKey.dataType(for: key) != nil else { continue } + processRemoteChange(forKey: key) + } + } + + private func processRemoteChange(forKey key: String) { + guard let data = engine.read(forKey: key) else { return } + + do { + let envelope = try decoder.decode(SyncEnvelope.self, from: data) + + // Ignore our own changes echoed back + guard envelope.deviceId != deviceId else { return } + + // Load last-synced state to detect if local has changed + let lastSyncedData = loadLastSyncedData(forKey: key) + let currentLocalData = loadCurrentLocalData(forKey: key) + + if let currentLocalData, envelope.payload != currentLocalData { + // Local and remote data differ + let localChanged = lastSyncedData == nil || currentLocalData != lastSyncedData + + if localChanged { + // Local modified since last sync (or first sync with existing data) → conflict + guard let dataType = SyncKey.dataType(for: key) else { return } + + let conflict = SyncConflict( + syncKey: key, + dataType: dataType, + remoteTimestamp: envelope.modifiedAt, + remoteDeviceName: envelope.deviceName, + remoteData: envelope.payload + ) + pendingConflicts.append(conflict) + Self.logger.info("Conflict detected for \(key)") + return + } + } + + // No conflict: auto-apply remote data + applyRemoteData(envelope.payload, forKey: key) + saveLastSyncedData(envelope.payload, forKey: key) + updateLastSyncDate() + + Self.logger.debug("Auto-applied remote \(key)") + } catch { + Self.logger.error("Failed to decode remote \(key): \(error.localizedDescription)") + } + } + + private func applyRemoteData(_ payload: Data, forKey key: String) { + // Set flag to prevent AppSettingsManager didSet hooks from pushing back + isSyncingFromRemote = true + defer { isSyncingFromRemote = false } + + do { + switch key { + case SyncKey.connections: + let connections = try decoder.decode([DatabaseConnection].self, from: payload) + ConnectionStorage.shared.saveConnections(connections, triggeredBySync: true) + NotificationCenter.default.post(name: .iCloudSyncDidUpdateData, object: nil) + + case SyncKey.tags: + let tags = try decoder.decode([ConnectionTag].self, from: payload) + TagStorage.shared.saveTags(tags, triggeredBySync: true) + NotificationCenter.default.post(name: .iCloudSyncDidUpdateData, object: nil) + + case SyncKey.settingsGeneral: + var settings = try decoder.decode(GeneralSettings.self, from: payload) + // Preserve local iCloudSyncEnabled toggle + settings.iCloudSyncEnabled = AppSettingsManager.shared.general.iCloudSyncEnabled + AppSettingsManager.shared.general = settings + + case SyncKey.settingsAppearance: + let settings = try decoder.decode(AppearanceSettings.self, from: payload) + AppSettingsManager.shared.appearance = settings + + case SyncKey.settingsEditor: + let settings = try decoder.decode(EditorSettings.self, from: payload) + AppSettingsManager.shared.editor = settings + + case SyncKey.settingsDataGrid: + let settings = try decoder.decode(DataGridSettings.self, from: payload) + AppSettingsManager.shared.dataGrid = settings + + case SyncKey.settingsHistory: + let settings = try decoder.decode(HistorySettings.self, from: payload) + AppSettingsManager.shared.history = settings + + case SyncKey.templates: + let templates = try decoder.decode([String: TableCreationOptions].self, from: payload) + for (name, options) in templates { + try TableTemplateStorage.shared.saveTemplate( + name: name, options: options, triggeredBySync: true + ) + } + + default: + break + } + } catch { + Self.logger.error("Failed to apply remote \(key): \(error.localizedDescription)") + } + + saveLastSyncedData(payload, forKey: key) + } + + // MARK: - Private: Local State Tracking + + /// Save what we last synced for three-way conflict detection + private func saveLastSyncedData(_ data: Data, forKey key: String) { + defaults.set(data, forKey: LocalKey.lastSynced(for: key)) + } + + /// Load last-synced state for conflict detection + private func loadLastSyncedData(forKey key: String) -> Data? { + defaults.data(forKey: LocalKey.lastSynced(for: key)) + } + + /// Load current local data for a sync key (for conflict comparison) + private func loadCurrentLocalData(forKey key: String) -> Data? { + do { + switch key { + case SyncKey.connections: + let connections = ConnectionStorage.shared.loadConnections() + return try encoder.encode(connections) + case SyncKey.tags: + let tags = TagStorage.shared.loadTags() + return try encoder.encode(tags) + case SyncKey.settingsGeneral: + var settings = AppSettingsManager.shared.general + settings.iCloudSyncEnabled = false + return try encoder.encode(settings) + case SyncKey.settingsAppearance: + return try encoder.encode(AppSettingsManager.shared.appearance) + case SyncKey.settingsEditor: + return try encoder.encode(AppSettingsManager.shared.editor) + case SyncKey.settingsDataGrid: + return try encoder.encode(AppSettingsManager.shared.dataGrid) + case SyncKey.settingsHistory: + return try encoder.encode(AppSettingsManager.shared.history) + case SyncKey.templates: + let templates = try TableTemplateStorage.shared.loadTemplates() + return try encoder.encode(templates) + default: + return nil + } + } catch { + Self.logger.error("Failed to load local data for \(key): \(error.localizedDescription)") + return nil + } + } + + private func updateLastSyncDate() { + lastSyncDate = Date() + defaults.set(lastSyncDate, forKey: LocalKey.lastSyncDate) + } + + // MARK: - Private: Device ID + + private static func loadOrCreateDeviceId() -> String { + let key = LocalKey.deviceId + if let existing = UserDefaults.standard.string(forKey: key) { + return existing + } + let newId = UUID().uuidString + UserDefaults.standard.set(newId, forKey: key) + return newId + } +} + +// MARK: - Sync Notification + +internal extension Notification.Name { + /// Posted when iCloud sync applies remote data that changes local storage + static let iCloudSyncDidUpdateData = Notification.Name("iCloudSyncDidUpdateData") +} diff --git a/TablePro/Core/Sync/SyncEngine.swift b/TablePro/Core/Sync/SyncEngine.swift new file mode 100644 index 00000000..1d457d04 --- /dev/null +++ b/TablePro/Core/Sync/SyncEngine.swift @@ -0,0 +1,79 @@ +// +// SyncEngine.swift +// TablePro +// +// Protocol defining a sync backend for iCloud or other services. +// + +import Foundation + +// MARK: - Sync Engine Protocol + +/// Protocol for sync backends (NSUbiquitousKeyValueStore, CloudKit, etc.) +protocol SyncEngine { + /// Whether the sync backend is available (e.g., iCloud signed in) + var isAvailable: Bool { get } + + /// Start observing remote changes + func startObserving(onChange: @escaping ([String]) -> Void) + + /// Stop observing remote changes + func stopObserving() + + /// Write data for a key + func write(_ data: Data, forKey key: String) + + /// Read data for a key + func read(forKey key: String) -> Data? + + /// Remove data for a key + func remove(forKey key: String) + + /// Force synchronization with remote + @discardableResult + func synchronize() -> Bool +} + +// MARK: - Sync Envelope + +/// Wrapper for synced data with metadata for conflict detection +struct SyncEnvelope: Codable { + let payload: Data + let modifiedAt: Date + let deviceId: String + let deviceName: String +} + +// MARK: - Sync Data Type + +/// Types of data that can be synced +enum SyncDataType: String, CaseIterable { + case connections = "Connections" + case tags = "Tags" + case generalSettings = "General Settings" + case appearanceSettings = "Appearance Settings" + case editorSettings = "Editor Settings" + case dataGridSettings = "Data Grid Settings" + case historySettings = "History Settings" + case templates = "Templates" +} + +// MARK: - Sync Error + +/// Errors that can occur during sync +enum SyncError: LocalizedError { + case iCloudUnavailable + case encodingFailed(String) + case decodingFailed(String) + + var errorDescription: String? { + switch self { + case .iCloudUnavailable: + return "iCloud is not available. Please sign in to iCloud in System Settings." + case .encodingFailed(let detail): + return "Failed to encode sync data: \(detail)" + case .decodingFailed(let detail): + return "Failed to decode sync data: \(detail)" + } + } +} diff --git a/TablePro/Models/AppSettings.swift b/TablePro/Models/AppSettings.swift index 816d779b..8d7a0d5e 100644 --- a/TablePro/Models/AppSettings.swift +++ b/TablePro/Models/AppSettings.swift @@ -30,21 +30,29 @@ enum StartupBehavior: String, Codable, CaseIterable, Identifiable { struct GeneralSettings: Codable, Equatable { var startupBehavior: StartupBehavior var automaticallyCheckForUpdates: Bool + var iCloudSyncEnabled: Bool static let `default` = GeneralSettings( startupBehavior: .showWelcome, - automaticallyCheckForUpdates: true + automaticallyCheckForUpdates: true, + iCloudSyncEnabled: false ) - init(startupBehavior: StartupBehavior = .showWelcome, automaticallyCheckForUpdates: Bool = true) { + init( + startupBehavior: StartupBehavior = .showWelcome, + automaticallyCheckForUpdates: Bool = true, + iCloudSyncEnabled: Bool = false + ) { self.startupBehavior = startupBehavior self.automaticallyCheckForUpdates = automaticallyCheckForUpdates + self.iCloudSyncEnabled = iCloudSyncEnabled } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) startupBehavior = try container.decode(StartupBehavior.self, forKey: .startupBehavior) automaticallyCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .automaticallyCheckForUpdates) ?? true + iCloudSyncEnabled = try container.decodeIfPresent(Bool.self, forKey: .iCloudSyncEnabled) ?? false } } diff --git a/TablePro/OpenTableApp.swift b/TablePro/OpenTableApp.swift index ab431ba3..242e4d93 100644 --- a/TablePro/OpenTableApp.swift +++ b/TablePro/OpenTableApp.swift @@ -123,6 +123,13 @@ struct TableProApp: App { Task { @MainActor in QueryHistoryManager.shared.performStartupCleanup() } + + // Start iCloud sync if previously enabled + Task { @MainActor in + if AppSettingsManager.shared.general.iCloudSyncEnabled { + SyncCoordinator.shared.enable() + } + } } /// Get tint color from settings (nil for system default) diff --git a/TablePro/TablePro.entitlements b/TablePro/TablePro.entitlements new file mode 100644 index 00000000..1a1a4215 --- /dev/null +++ b/TablePro/TablePro.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) + + diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index bada6aab..363c62b8 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -2,7 +2,7 @@ // GeneralSettingsView.swift // TablePro // -// Settings for startup behavior and confirmations +// Settings for startup behavior, iCloud sync, and software updates // import Sparkle @@ -11,6 +11,12 @@ import SwiftUI struct GeneralSettingsView: View { @Binding var settings: GeneralSettings @ObservedObject var updaterBridge: UpdaterBridge + @StateObject private var syncCoordinator = SyncCoordinator.shared + @State private var showConflictSheet = false + + private var iCloudAvailable: Bool { + FileManager.default.ubiquityIdentityToken != nil + } var body: some View { Form { @@ -20,6 +26,61 @@ struct GeneralSettingsView: View { } } + Section("iCloud Sync") { + Toggle("Sync data across devices", isOn: $settings.iCloudSyncEnabled) + .disabled(!iCloudAvailable) + + if settings.iCloudSyncEnabled { + VStack(alignment: .leading, spacing: 6) { + Label( + "Syncs connections, tags, settings, and templates", + systemImage: "checkmark.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + + Label( + "Passwords are stored locally in Keychain and not synced", + systemImage: "lock.shield" + ) + .font(.caption) + .foregroundStyle(.orange) + } + .padding(.vertical, 2) + + if let lastSync = syncCoordinator.lastSyncDate { + HStack(spacing: 4) { + Image(systemName: "checkmark.icloud") + .foregroundStyle(.green) + Text("Last synced \(lastSync, style: .relative) ago") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !syncCoordinator.pendingConflicts.isEmpty { + Button { + showConflictSheet = true + } label: { + Label( + "Resolve \(syncCoordinator.pendingConflicts.count) conflict(s)", + systemImage: "exclamationmark.triangle" + ) + } + .foregroundStyle(.orange) + } + } + + if !iCloudAvailable { + Label( + "Sign in to iCloud in System Settings to enable sync", + systemImage: "exclamationmark.triangle" + ) + .font(.caption) + .foregroundStyle(.orange) + } + } + Section("Software Update") { Toggle("Automatically check for updates", isOn: $settings.automaticallyCheckForUpdates) .onChange(of: settings.automaticallyCheckForUpdates) { newValue in @@ -37,6 +98,9 @@ struct GeneralSettingsView: View { .onAppear { updaterBridge.updater.automaticallyChecksForUpdates = settings.automaticallyCheckForUpdates } + .sheet(isPresented: $showConflictSheet) { + SyncConflictResolutionView(syncCoordinator: syncCoordinator) + } } } @@ -45,5 +109,5 @@ struct GeneralSettingsView: View { settings: .constant(.default), updaterBridge: UpdaterBridge() ) - .frame(width: 450, height: 300) + .frame(width: 450, height: 400) } diff --git a/TablePro/Views/Sync/SyncConflictResolutionView.swift b/TablePro/Views/Sync/SyncConflictResolutionView.swift new file mode 100644 index 00000000..8514b80f --- /dev/null +++ b/TablePro/Views/Sync/SyncConflictResolutionView.swift @@ -0,0 +1,137 @@ +// +// SyncConflictResolutionView.swift +// TablePro +// +// SwiftUI view for resolving iCloud sync conflicts. +// + +import SwiftUI + +/// Sheet view for resolving pending sync conflicts +struct SyncConflictResolutionView: View { + @ObservedObject var syncCoordinator: SyncCoordinator + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + // Header + VStack(spacing: 8) { + Image(systemName: "exclamationmark.icloud") + .font(.system(size: 32)) + .foregroundStyle(.orange) + + Text("Sync Conflicts") + .font(.headline) + + Text("Your local data differs from iCloud. Choose which version to keep for each item.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + + Divider() + + // Conflict list + if syncCoordinator.pendingConflicts.isEmpty { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundStyle(.green) + Text("All conflicts resolved") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(syncCoordinator.pendingConflicts) { conflict in + ConflictRowView(conflict: conflict) { resolution in + syncCoordinator.resolveConflict(conflict, resolution: resolution) + if syncCoordinator.pendingConflicts.isEmpty { + dismiss() + } + } + } + } + .listStyle(.inset) + } + + Divider() + + // Footer + HStack { + Button("Keep All Local") { + resolveAll(.keepLocal) + } + + Button("Keep All Remote") { + resolveAll(.keepRemote) + } + + Spacer() + + Button("Done") { + dismiss() + } + .keyboardShortcut(.return, modifiers: .command) + } + .padding() + } + .frame(minWidth: 500, maxWidth: 500, minHeight: 350, maxHeight: 500) + } + + private func resolveAll(_ resolution: ConflictResolution) { + let conflicts = syncCoordinator.pendingConflicts + for conflict in conflicts { + syncCoordinator.resolveConflict(conflict, resolution: resolution) + } + dismiss() + } +} + +// MARK: - Conflict Row + +private struct ConflictRowView: View { + let conflict: SyncConflict + let onResolve: (ConflictResolution) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: iconName) + .foregroundStyle(.orange) + Text(conflict.dataType.rawValue) + .font(.system(size: 13, weight: .medium)) + } + + Text(conflict.summary) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button("Keep Local") { + onResolve(.keepLocal) + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button("Keep Remote") { + onResolve(.keepRemote) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.vertical, 4) + } + + private var iconName: String { + switch conflict.dataType { + case .connections: return "externaldrive.connected.to.line.below" + case .tags: return "tag" + case .templates: return "doc.text" + default: return "gearshape" + } + } +} diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index c237118e..68de776e 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -71,6 +71,9 @@ struct WelcomeWindowView: View { .onReceive(NotificationCenter.default.publisher(for: .connectionUpdated)) { _ in loadConnections() } + .onReceive(NotificationCenter.default.publisher(for: .iCloudSyncDidUpdateData)) { _ in + loadConnections() + } } // MARK: - Left Panel @@ -353,6 +356,14 @@ private struct ConnectionRow: View { return TagStorage.shared.tag(for: tagId) } + /// Show a password badge when iCloud sync is enabled but no local password exists + private var needsPasswordBadge: Bool { + guard AppSettingsManager.shared.general.iCloudSyncEnabled else { return false } + // Only show badge for non-SQLite connections (SQLite doesn't use passwords) + guard connection.type != .sqlite else { return false } + return !ConnectionStorage.shared.hasPassword(for: connection.id) + } + var body: some View { HStack(spacing: 12) { // Database type icon @@ -378,6 +389,14 @@ private struct ConnectionRow: View { .padding(.vertical, DesignConstants.Spacing.xxxs) .background(Capsule().fill(tag.color.color.opacity(0.15))) } + + // Password badge for synced connections without local password + if needsPasswordBadge { + Image(systemName: "key") + .font(.system(size: DesignConstants.FontSize.tiny)) + .foregroundStyle(.orange) + .help("Password not synced. Enter password on first connect.") + } } Text(connectionSubtitle)