┌──────────────────────────────────────────────────────────────┐
│ 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)
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
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.
static let myDeviceName: String = Self.getDeviceName()
private static func getDeviceName() -> String {
#if os(macOS)
// On macOS (Catalyst, iPad App on Mac), use hostname
let hostname = ProcessInfo.processInfo.hostName
return hostname.trimmingCharacters(in: .whitespacesAndNewlines)
#else
// On iOS/iPadOS, use device name
return UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
#endif
}
Why: UIDevice.current.name returns "iPad" on macOS, which is wrong. Using hostname gives correct names like "MacBook-Pro.local" or "iMac.local".
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
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
}
}
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
}
rebuildCanonicalStoreIfNeeded(version: 3) runs at app launchmeterName simultaneouslyNSMergeByPropertyStoreTrumpMergePolicy applies: latest CloudKit wins| 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 |