Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- iCloud sync 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
Expand Down
2 changes: 2 additions & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions TablePro/Core/Storage/AppSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -27,6 +38,7 @@ final class AppSettingsManager: ObservableObject {
didSet {
storage.saveAppearance(appearance)
appearance.theme.apply()
SyncCoordinator.shared.didUpdateAppearanceSettings(appearance)
notifyChange(domain: "appearance", notification: .appearanceSettingsDidChange)
}
}
Expand All @@ -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)
}
}
Expand All @@ -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)
}
}
Expand All @@ -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)
}
}
Expand Down
16 changes: 15 additions & 1 deletion TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions TablePro/Core/Storage/TableTemplateStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,21 @@ 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

let encoder = JSONEncoder()
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
Expand All @@ -57,14 +64,21 @@ 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)

let encoder = JSONEncoder()
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
Expand Down
9 changes: 8 additions & 1 deletion TablePro/Core/Storage/TagStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,21 @@ 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)
defaults.set(data, forKey: tagsKey)
} 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
Expand Down
67 changes: 67 additions & 0 deletions TablePro/Core/Sync/ICloudSyncEngine.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
33 changes: 33 additions & 0 deletions TablePro/Core/Sync/SyncConflict.swift
Original file line number Diff line number Diff line change
@@ -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
}
Loading