|
Bogdan Timofte
authored
2 weeks ago
|
1
|
# CloudKit Sync - Troubleshooting & Debugging
|
|
|
2
|
|
|
|
3
|
## Debugging Checklist
|
|
|
4
|
|
|
|
5
|
### 1. Verify CloudKit Account Status
|
|
|
6
|
```swift
|
|
|
7
|
// In AppDelegate
|
|
|
8
|
private func logCloudKitStatus() {
|
|
|
9
|
let container = CKContainer(identifier: "iCloud.ro.xdev.USB-Meter")
|
|
|
10
|
container.accountStatus { status, error in
|
|
|
11
|
if let error {
|
|
|
12
|
print("CloudKit error: \(error.localizedDescription)")
|
|
|
13
|
return
|
|
|
14
|
}
|
|
|
15
|
switch status {
|
|
|
16
|
case .available:
|
|
|
17
|
print("✅ CloudKit available")
|
|
|
18
|
case .noAccount:
|
|
|
19
|
print("❌ No iCloud account")
|
|
|
20
|
case .restricted:
|
|
|
21
|
print("❌ CloudKit restricted (parental controls?)")
|
|
|
22
|
case .couldNotDetermine:
|
|
|
23
|
print("❌ Status unknown")
|
|
|
24
|
case .temporarilyUnavailable:
|
|
|
25
|
print("⚠️ CloudKit temporarily unavailable")
|
|
|
26
|
@unknown default:
|
|
|
27
|
print("⚠️ Unknown status")
|
|
|
28
|
}
|
|
|
29
|
}
|
|
|
30
|
}
|
|
|
31
|
```
|
|
|
32
|
|
|
|
33
|
**Expected:** `.available` or changes won't sync
|
|
|
34
|
|
|
|
35
|
---
|
|
|
36
|
|
|
|
37
|
### 2. Check Entitlements
|
|
|
38
|
Verify `USB Meter.entitlements` has:
|
|
|
39
|
|
|
|
40
|
```xml
|
|
|
41
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
|
42
|
<array>
|
|
|
43
|
<string>iCloud.ro.xdev.USB-Meter</string>
|
|
|
44
|
</array>
|
|
|
45
|
<key>com.apple.developer.icloud-services</key>
|
|
|
46
|
<array>
|
|
|
47
|
<string>CloudKit</string>
|
|
|
48
|
</array>
|
|
|
49
|
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
|
|
50
|
<array>
|
|
|
51
|
<string>iCloud.ro.xdev.USB-Meter</string>
|
|
|
52
|
</array>
|
|
|
53
|
```
|
|
|
54
|
|
|
|
55
|
If missing:
|
|
|
56
|
- ❌ App can't access CloudKit
|
|
|
57
|
- ❌ No sync errors in logs (silent failure)
|
|
|
58
|
|
|
|
59
|
---
|
|
|
60
|
|
|
|
61
|
### 3. Monitor Sync Logs
|
|
|
62
|
|
|
|
63
|
Enable enhanced Core Data debugging:
|
|
|
64
|
|
|
|
65
|
**In Xcode scheme editor:**
|
|
|
66
|
1. Select scheme → Edit Scheme → Run → Arguments
|
|
|
67
|
2. Add Launch Arguments:
|
|
|
68
|
```
|
|
|
69
|
-com.apple.CoreData.SQLDebug 1
|
|
|
70
|
-com.apple.CoreData.ConcurrencyDebug 1
|
|
|
71
|
```
|
|
|
72
|
|
|
|
73
|
**In terminal:**
|
|
|
74
|
```bash
|
|
|
75
|
# Follow app logs
|
|
|
76
|
log stream --predicate 'process == "USB Meter"' --level debug
|
|
|
77
|
```
|
|
|
78
|
|
|
|
79
|
**Look for:**
|
|
|
80
|
- `NSPersistentCloudKitContainer` initialization messages
|
|
|
81
|
- `cloudkit:` messages (sync operations)
|
|
|
82
|
- `CoreData:` messages (database operations)
|
|
|
83
|
|
|
|
84
|
---
|
|
|
85
|
|
|
|
86
|
### 4. Check Persistent Store Description
|
|
|
87
|
|
|
|
88
|
```swift
|
|
|
89
|
// In AppDelegate.persistentContainer
|
|
|
90
|
if let description = container.persistentStoreDescriptions.first {
|
|
|
91
|
print("Store URL: \(description.url?.path ?? "nil")")
|
|
|
92
|
print("Options: \(description.options ?? [:])")
|
|
|
93
|
print("CloudKit container: \(description.cloudKitContainerOptions?.containerIdentifier ?? "nil")")
|
|
|
94
|
}
|
|
|
95
|
```
|
|
|
96
|
|
|
|
97
|
**Expected:**
|
|
|
98
|
- URL should point to `.../Library/Application Support/CKModel.sqlite`
|
|
|
99
|
- CloudKit container identifier must match entitlements
|
|
|
100
|
|
|
|
101
|
---
|
|
|
102
|
|
|
|
103
|
### 5. Verify Remote Notifications Are Set Up
|
|
|
104
|
|
|
|
105
|
```swift
|
|
|
106
|
// In AppData
|
|
|
107
|
private func setupRemoteChangeNotificationObserver() {
|
|
|
108
|
NotificationCenter.default.addObserver(
|
|
|
109
|
self,
|
|
|
110
|
selector: #selector(remoteStoreDidChange),
|
|
|
111
|
name: .NSPersistentStoreRemoteChange,
|
|
|
112
|
object: nil
|
|
|
113
|
)
|
|
|
114
|
print("✅ Remote change observer registered")
|
|
|
115
|
}
|
|
|
116
|
|
|
|
117
|
@objc private func remoteStoreDidChange() {
|
|
|
118
|
print("🔄 Remote CloudKit change received")
|
|
|
119
|
DispatchQueue.main.async {
|
|
|
120
|
self.reloadSettingsFromCloudStore()
|
|
|
121
|
}
|
|
|
122
|
}
|
|
|
123
|
```
|
|
|
124
|
|
|
|
125
|
**Problem:** If `reloadSettingsFromCloudStore` is never called, check if observer is registered
|
|
|
126
|
|
|
|
127
|
---
|
|
|
128
|
|
|
|
129
|
## Common Issues & Fixes
|
|
|
130
|
|
|
|
131
|
### Issue 1: "Changes not syncing to other devices"
|
|
|
132
|
|
|
|
133
|
**Diagnosis:**
|
|
|
134
|
```swift
|
|
|
135
|
// Check if save succeeded
|
|
|
136
|
do {
|
|
|
137
|
try appData.persistentContainer.viewContext.save()
|
|
|
138
|
print("✅ Save successful")
|
|
|
139
|
} catch {
|
|
|
140
|
print("❌ Save failed: \(error)")
|
|
|
141
|
}
|
|
|
142
|
|
|
|
143
|
// Check if DeviceSettings record exists
|
|
|
144
|
let request = NSFetchRequest<NSFetchRequestExpression>(entityName: "DeviceSettings")
|
|
|
145
|
let count = try? appData.persistentContainer.viewContext.count(for: request)
|
|
|
146
|
print("Records in store: \(count ?? 0)")
|
|
|
147
|
```
|
|
|
148
|
|
|
|
149
|
**Fixes:**
|
|
|
150
|
1. **Check Account Status** → must be `.available`
|
|
|
151
|
2. **Check Merge Policy** → ensure `NSMergeByPropertyStoreTrumpMergePolicy`
|
|
|
152
|
3. **Check Observer** → `remoteStoreDidChange` registered?
|
|
|
153
|
4. **Force Sync:**
|
|
|
154
|
```swift
|
|
|
155
|
appData.persistentContainer.viewContext.refreshAllObjects()
|
|
|
156
|
appData.reloadSettingsFromCloudStore()
|
|
|
157
|
```
|
|
|
158
|
|
|
|
159
|
### Issue 2: "Duplicates appearing in CloudKit"
|
|
|
160
|
|
|
|
161
|
**Diagnosis:**
|
|
|
162
|
```swift
|
|
|
163
|
// Check for MAC address duplicates
|
|
|
164
|
let request = NSFetchRequest<NSDictionary>(entityName: "DeviceSettings")
|
|
|
165
|
request.returnsDistinctResults = true
|
|
|
166
|
request.returnsObjectsAsFaults = false
|
|
|
167
|
request.resultType = .dictionaryResultType
|
|
|
168
|
request.returnsDistinctResults = true
|
|
|
169
|
|
|
|
170
|
let macs = try? appData.persistentContainer.viewContext.fetch(request)
|
|
|
171
|
.compactMap { $0["macAddress"] as? String }
|
|
|
172
|
|
|
|
173
|
let duplicates = macs?.filter { mac in
|
|
|
174
|
macs?.filter { $0 == mac }.count ?? 0 > 1
|
|
|
175
|
}
|
|
|
176
|
print("Duplicate MACs: \(duplicates ?? [])")
|
|
|
177
|
```
|
|
|
178
|
|
|
|
179
|
**Fixes:**
|
|
|
180
|
1. **Rebuild v3:** Ensure `cloudStoreRebuildVersion = 3` is deployed
|
|
|
181
|
2. **Check `rebuildCanonicalStoreIfNeeded`:**
|
|
|
182
|
```swift
|
|
|
183
|
// Manually trigger
|
|
|
184
|
appData.cloudStore?.rebuildCanonicalStoreIfNeeded(version: 3)
|
|
|
185
|
```
|
|
|
186
|
3. **Verify UserDefaults:**
|
|
|
187
|
```swift
|
|
|
188
|
let rebuilt = UserDefaults.standard.bool(forKey: "cloudStoreRebuildVersion.3")
|
|
|
189
|
print("Rebuild v3 completed: \(rebuilt)")
|
|
|
190
|
```
|
|
|
191
|
|
|
|
192
|
### Issue 3: "Connection status stuck as 'Connected on Device X'"
|
|
|
193
|
|
|
|
194
|
**Diagnosis:**
|
|
|
195
|
```swift
|
|
|
196
|
// Check connection record
|
|
|
197
|
let request = NSFetchRequest<NSManagedObject>(entityName: "DeviceSettings")
|
|
|
198
|
let records = try? appData.persistentContainer.viewContext.fetch(request)
|
|
|
199
|
|
|
|
200
|
for record in records ?? [] {
|
|
|
201
|
if let expiry = record.value(forKey: "connectedExpiryAt") as? Date {
|
|
|
202
|
let isExpired = expiry < Date.now
|
|
|
203
|
print("✋ MAC \(record.value(forKey: "macAddress") ?? "?") expires at \(expiry), expired: \(isExpired)")
|
|
|
204
|
}
|
|
|
205
|
}
|
|
|
206
|
```
|
|
|
207
|
|
|
|
208
|
**Fixes:**
|
|
|
209
|
1. **Manually clear lock:**
|
|
|
210
|
```swift
|
|
|
211
|
appData.clearMeterConnection(macAddress: "aa:bb:cc:dd:ee:ff")
|
|
|
212
|
```
|
|
|
213
|
2. **Check TTL hardcode:** Should be 120s in `setConnection()`
|
|
|
214
|
3. **Verify system clock** on Device A (didn't fall asleep or drift)
|
|
|
215
|
|
|
|
216
|
### Issue 4: "Core Data save errors" with `NSError 1569`
|
|
|
217
|
|
|
|
218
|
**Symptom:**
|
|
|
219
|
```
|
|
|
220
|
NSError Domain=NSCocoaErrorDomain Code=1569
|
|
|
221
|
"The object's persistent store is not reachable"
|
|
|
222
|
```
|
|
|
223
|
|
|
|
224
|
**Causes:**
|
|
|
225
|
1. ❌ Old model with `uniquenessConstraint` conflicting with CloudKit
|
|
|
226
|
2. ❌ All attributes on DeviceSettings aren't `optional="YES"`
|
|
|
227
|
|
|
|
228
|
**Fixes:**
|
|
|
229
|
1. **Verify model v2+:** Check `.xccurrentversion` points to `USB_Meter 2.xcdatamodel`
|
|
|
230
|
2. **Check attributes:** All must be `optional="YES"` (inspect `.xcdatamodel/contents`)
|
|
|
231
|
3. **Reset store if persists:**
|
|
|
232
|
```swift
|
|
|
233
|
// Delete local store to force rebuild from CloudKit
|
|
|
234
|
let storeURL = appData.persistentContainer.persistentStoreDescriptions.first?.url
|
|
|
235
|
let fileManager = FileManager.default
|
|
|
236
|
try? fileManager.removeItem(at: storeURL)
|
|
|
237
|
```
|
|
|
238
|
|
|
|
239
|
---
|
|
|
240
|
|
|
|
241
|
## Monitoring CloudKit Sync in Real Time
|
|
|
242
|
|
|
|
243
|
### Dashboard View (for debugging)
|
|
|
244
|
|
|
|
245
|
```swift
|
|
|
246
|
struct CloudKitDebugView: View {
|
|
|
247
|
@ObservedObject var appData: AppData
|
|
|
248
|
|
|
|
249
|
var body: some View {
|
|
|
250
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
251
|
Text("CloudKit Sync Status")
|
|
|
252
|
.font(.headline)
|
|
|
253
|
|
|
|
254
|
DebugItem(label: "Account", value: appData.cloudKitAccountStatus)
|
|
|
255
|
DebugItem(label: "Device ID", value: UIDevice.current.identifierForVendor?.uuidString ?? "?")
|
|
|
256
|
DebugItem(label: "Device Name", value: UIDevice.current.name)
|
|
|
257
|
DebugItem(label: "Last Sync", value: formatDate(appData.lastSyncTime))
|
|
|
258
|
DebugItem(label: "Records", value: "\(appData.deviceSettingsCount)")
|
|
|
259
|
DebugItem(label: "Pending", value: "\(appData.pendingSyncCount)")
|
|
|
260
|
|
|
|
261
|
Button("Force Reload") {
|
|
|
262
|
appData.reloadSettingsFromCloudStore()
|
|
|
263
|
}
|
|
|
264
|
}
|
|
|
265
|
.padding()
|
|
|
266
|
.font(.caption)
|
|
|
267
|
}
|
|
|
268
|
}
|
|
|
269
|
|
|
|
270
|
struct DebugItem: View {
|
|
|
271
|
let label: String
|
|
|
272
|
let value: String
|
|
|
273
|
|
|
|
274
|
var body: some View {
|
|
|
275
|
HStack {
|
|
|
276
|
Text(label).font(.caption2).foregroundColor(.secondary)
|
|
|
277
|
Spacer()
|
|
|
278
|
Text(value).font(.caption).monospaced()
|
|
|
279
|
}
|
|
|
280
|
}
|
|
|
281
|
}
|
|
|
282
|
```
|
|
|
283
|
|
|
|
284
|
---
|
|
|
285
|
|
|
|
286
|
## Network Conditions Testing
|
|
|
287
|
|
|
|
288
|
### Simulate CloudKit Unavailability
|
|
|
289
|
1. Xcode → Debug → Simulate Location → [Custom]
|
|
|
290
|
2. Instrument → Network Link Conditioner → "Very Bad Network"
|
|
|
291
|
|
|
|
292
|
### Expected Behavior:
|
|
|
293
|
- ✅ Local changes proceed (saved to local DB)
|
|
|
294
|
- ✅ Notification badge appears: "📶 Waiting for sync…"
|
|
|
295
|
- ✅ When network returns → auto-resumes sync
|
|
|
296
|
|
|
|
297
|
---
|
|
|
298
|
|
|
|
299
|
## CloudKit Container Inspection (Advanced)
|
|
|
300
|
|
|
|
301
|
Using CloudKit Console (requires Apple developer account):
|
|
|
302
|
|
|
|
303
|
1. Go to https://icloud.developer.apple.com/cloudkit
|
|
|
304
|
2. Select container: `iCloud.ro.xdev.USB-Meter`
|
|
|
305
|
3. Browse `DeviceSettings` records:
|
|
|
306
|
- Check for duplicates by `macAddress`
|
|
|
307
|
- Verify `connectedByDeviceID` matches expected device UUIDs
|
|
|
308
|
- Check `updatedAt` timestamps
|
|
|
309
|
|
|
|
310
|
---
|
|
|
311
|
|
|
|
312
|
## Performance Profiling
|
|
|
313
|
|
|
|
314
|
```swift
|
|
|
315
|
// Measure reloadSettingsFromCloudStore() performance
|
|
|
316
|
let start = Date()
|
|
|
317
|
appData.reloadSettingsFromCloudStore()
|
|
|
318
|
let duration = Date().timeIntervalSince(start)
|
|
|
319
|
print("Reload took \(duration * 1000)ms") // Should be <100ms for ~10 records
|
|
|
320
|
```
|
|
|
321
|
|
|
|
322
|
**Benchmarks:**
|
|
|
323
|
| Operation | Target | Actual (good) |
|
|
|
324
|
|-----------|--------|---------------|
|
|
|
325
|
| `setConnection()` | <50ms | ~10ms |
|
|
|
326
|
| `reloadSettingsFromCloudStore()` | <100ms | ~30ms (5 records) |
|
|
|
327
|
| CloudKit push (network) | <5s | varies by connection |
|
|
|
328
|
| Remote notification delivery | <10s | ~2-5s (typically) |
|
|
|
329
|
|
|
|
330
|
---
|
|
|
331
|
|
|
|
332
|
## Recording Logs for Bug Reports
|
|
|
333
|
|
|
|
334
|
```bash
|
|
|
335
|
# Save full debug logs
|
|
|
336
|
log collect --start '2025-03-26 10:00:00' --output /tmp/cloudkit_logs.logarchive
|
|
|
337
|
|
|
|
338
|
# Extract readable format
|
|
|
339
|
log show /tmp/cloudkit_logs.logarchive \
|
|
|
340
|
--predicate 'process == "USB Meter"' \
|
|
|
341
|
--level debug > ~/cloudkit_debug.txt
|
|
|
342
|
```
|
|
|
343
|
|
|
|
344
|
Include in bug report:
|
|
|
345
|
1. `~/cloudkit_debug.txt`
|
|
|
346
|
2. Reproduction steps
|
|
|
347
|
3. What you expected vs. what happened
|
|
|
348
|
4. Device names & OS versions involved
|