1 contributor
325 lines | 12.638kb

CloudKit Sync Mechanism

Architecture Overview

┌──────────────────────────────────────────────────────────────┐
│                        App Layer                            │
│  Meter.swift (UI)  ←→  BluetoothManager.swift (BT comms)  │
└───────────┬────────────────────────────┬────────────────────┘
            │                            │
            └───────────┬────────────────┘
                        │
              ┌─────────▼──────────┐
              │ AppData.swift      │
              │  (Orchestrator)    │
              └──────────┬─────────┘
                         │
        ┌────────────────┼────────────────┐
        │                │                │
   ┌────▼─────┐   ┌─────▼──────┐   ┌────▼──────────┐
   │ CloudDeviceSettingsStore   │   │ Bluetooth    │
   │ (Core Data Layer)          │   │ Persistence  │
   └────┬─────┘                 │   └──────────────┘
        │                       │
   ┌────▼────────────────────────────┐
   │ NSManagedObjectContext           │
   │ (viewContext)                    │
   └────┬─────────────────────────────┘
        │
   ┌────▼──────────────────────────────────────────┐
   │ NSPersistentCloudKitContainer                  │
   │ - Automatic bi-directional sync               │
   │ - Handles conflicts via merge policy          │
   │ - Remote change notifications                │
   └────┬─────────────────────┬────────────────────┘
        │                     │
   ┌────▼──────┐    ┌────────▼─────────────────┐
   │ Local DB  │    │ CloudKit Framework       │
   │ SQLite    │    │ - Push notifications     │
   │           │    │ - Persistent history    │
   └───────────┘    └────────┬──────────────────┘
                             │
                        ┌────▼────────────┐
                        │ iCloud Server   │
                        │ CloudKit DB     │
                        └────┬────────────┘
                             │
                        (Sync'd to other devices)

Sync Flow - Step by Step

Local Write Flow

1. User Action (connect meter)
   │
2. Meter.swift → operationalState.didSet
   │
3. appData.publishMeterConnection(mac: String, modelType: String)
   │
4. CloudDeviceSettingsStore.setConnection(macAddress:, modelType:)
   │
   ├─ Fetch existing DeviceSettings for macAddress
   │  • If nil: create new NSManagedObject
   │  • If exists: update it
   │
5. Update attributes:
   │  • connectedByDeviceID = UIDevice.current.identifierForVendor
   │  • connectedByDeviceName = UIDevice.current.name
   │  • connectedAt = Date()
   │  • connectedExpiryAt = Date.now + 120 seconds
   │  • updatedAt = Date()
   │
6. appData.persistentContainer.viewContext.save()
   │
7. NSPersistentCloudKitContainer intercepts save:
   │  • Detects changes to DeviceSettings entity
   │  • Serializes to CKRecord ("DeviceSettings/[macAddress]")
   │
8. CloudKit framework:
   │  • Queues for upload (background)
   │  • Sends to iCloud servers
   │
9. Remote device:
   │  • Receives NSPersistentStoreRemoteChangeNotification
   │  • Automatically merges into local Core Data
   │  • AppData detects change → reloadSettingsFromCloudStore()
   │  • UI updates via @Published updates

Receiving Remote Changes

1. iCloud Server discovers change
   │
2. Sends data to Device B via CloudKit
   │
3. iOS system fires:
   NSPersistentStoreRemoteChangeNotificationPostOptionKey
   │
4. AppData.setupRemoteChangeNotificationObserver():
   @objc private func remoteStoreDidChange() {
     // This runs on background thread!
     reloadSettingsFromCloudStore()
   }
   │
5. reloadSettingsFromCloudStore() fetches fresh data:
   │
   ├─ cloudStore.fetch(for: macAddress) [async per-device]
   │
6. Updates local @Published properties
   │  → triggers UI re-render in SwiftUI
   │
7. Meter.swift observes changes:
   │  → updates connection state badges
   │  → shows "Connected on [Device Name]" etc.

AppData: Core Sync Methods

Device Name Handling (AppData.myDeviceName)

static let myDeviceName: String = getDeviceName()

private func getDeviceName() -> String {
    let hostname = ProcessInfo.processInfo.hostName
    return hostname.trimmingCharacters(in: .whitespacesAndNewlines)
}

Why: Uses hostname on all platforms (iOS, iPadOS, macOS Catalyst, iPad on Mac). Examples: "iPhone-User", "MacBook-Pro.local". Avoids platform-specific names that are irrelevant ("iPad", "iPhone" without context).

persistentContainer (AppDelegate.swift)

lazy var persistentContainer: NSPersistentCloudKitContainer = {
    let container = NSPersistentCloudKitContainer(name: "CKModel")
    if let description = container.persistentStoreDescriptions.first {
        description.cloudKitContainerOptions =
            NSPersistentCloudKitContainerOptions(
                containerIdentifier: "iCloud.ro.xdev.USB-Meter"
            )
        // Enable historical tracking for conflict resolution
        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
        // Enable remote change notifications
        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
    }
    container.viewContext.automaticallyMergesChangesFromParent = true
    container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
    return container
}()

Key configs: - automaticallyMergesChangesFromParent = true: Auto-merge CloudKit changes without explicit fetch - NSMergeByPropertyStoreTrumpMergePolicy: CloudKit always wins on property conflicts

activateCloudDeviceSync (SceneDelegate.swift)

appData.activateCloudDeviceSync(context: appDelegate.persistentContainer.viewContext)

Does: 1. Sets up remote change observers 2. Triggers first reloadSettingsFromCloudStore() to load persisted CloudKit data 3. Starts polling for connection expiry checks

publishMeterConnection (AppData.swift)

public func publishMeterConnection(macAddress: String, modelType: String) {
    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
        self?.cloudStore.setConnection(macAddress: macAddress, modelType: modelType)
    }
}

Called from: Meter.swift when operationalState changes to .peripheralConnected

clearMeterConnection (AppData.swift)

public func clearMeterConnection(macAddress: String) {
    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
        self?.cloudStore.clearConnection(macAddress: macAddress)
    }
}

Called from: Meter.swift when operationalState.offline / .peripheralNotConnected

reloadSettingsFromCloudStore (AppData.swift)

private func reloadSettingsFromCloudStore() {
    // Fetch all DeviceSettings records
    // Update internal @Published state
    // Publish changes via objectWillChange
}

Called from: - activateCloudDeviceSync (initial load) - Remote change notification handler (when CloudKit syncs changes) - Periodic check for connection expiry


CloudDeviceSettingsStore: Private Implementation

private class CloudDeviceSettingsStore {
    let context: NSManagedObjectContext
    let entityName = "DeviceSettings"

    func setConnection(macAddress: String, modelType: String) {
        context.performAndWait {
            // 1. Normalize MAC
            let mac = normalizedMACAddress(macAddress)

            // 2. Fetch or create
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
            request.predicate = NSPredicate(format: "macAddress == %@", mac)
            var object = try? context.fetch(request).first
            if object == nil {
                object = NSManagedObject(entity: ..., insertInto: context)
            }

            // 3. Update connection metadata
            object?.setValue(mac, forKey: "macAddress")
            object?.setValue(modelType, forKey: "modelType")
            object?.setValue(UIDevice.current.identifierForVendor?.uuidString, forKey: "connectedByDeviceID")
            object?.setValue(UIDevice.current.name, forKey: "connectedByDeviceName")
            object?.setValue(Date(), forKey: "connectedAt")
            object?.setValue(Date().addingTimeInterval(120), forKey: "connectedExpiryAt")
            object?.setValue(Date(), forKey: "updatedAt")

            // 4. Save → triggers CloudKit sync
            try? context.save()
        }
    }

    func fetch(for macAddress: String) -> DeviceSettingsRecord? {
        // Similar to setConnection, but returns data only
    }
}

Connection Lifecycle

Device A Connects                Expiry Timer Started
    │                                    │
    ▼                                    ▼
┌─────────────────────────┐    ┌──────────────────┐
│ DeviceSettings created  │    │  120 seconds     │
│ connectedAt = now       │    │  (hardcoded TTL) │
│ connectedExpiryAt=now+2m│◄───┴──────────────────┘
└─────────────────────────┘
    │
    │ [CloudKit syncs]
    │
    ▼
Device B detects via notification:
  connectedByDeviceID ≠ my device → "Locked by Device A"

After 120 seconds:
  if (now > connectedExpiryAt) {
    // Lock expired
    // Device B can attempt new connection
  }

Error Handling & Recovery

Scenario: CloudKit Unavailable

  • App detects no iCloud account → operations on viewContext proceed locally
  • Changes queue locally
  • When CloudKit available → sync resumes automatically

Scenario: Duplicate Records (Pre-v3)

  • rebuildCanonicalStoreIfNeeded(version: 3) runs at app launch
  • Detects and merges duplicates in-place
  • Marks as done in UserDefaults

Scenario: Sync Conflicts

  • Example: Device A and B both update meterName simultaneously
  • NSMergeByPropertyStoreTrumpMergePolicy applies: latest CloudKit wins
  • User may see their local change revert briefly (acceptable UX for rare case)

Performance Considerations

Operation Thread Frequency Impact
setConnection() Background (global QoS) Per meter connection Minimal (~5-10ms)
reloadSettingsFromCloudStore() Main (UI updates) On remote change + periodic ~50-100ms for <10 meters
rebuildCanonicalStoreIfNeeded() Main (view context) Once per app version ~200ms (one-time)
CloudKit sync (background) System Continuous Battery: negligible

Testing Checklist

  • [ ] Connect meter on Device A, verify "Connected on A" badge on Device B within 5s
  • [ ] Disconnect on A, verify badge clears on B
  • [ ] Rename meter on A, verify name updates on B
  • [ ] Set TC66 temp unit on A, verify syncs to B
  • [ ] Airplane mode on A → CloudKit queues changes → reconnect → syncs ✓
  • [ ] Delete app data on A, reinstall → rebuilds from CloudKit ✓
  • [ ] Simulated multi-device conflict (manual Core Data edit) → resolves via merge policy ✓