Release 1.3.1 (build 20): iCloud sync fix + sources search/filter
iCloud sync: - Fix: deploy CloudKit schema to production so exports work - Fix: PredictionCache marked syncable=NO (binary attr caused partial failures) - Fix: forceExportToiCloud uses createdAt+1ms for guaranteed persistent history - Fix: auto-deduplication on CloudKit import (processRemoteChanges) - Add: NSPersistentHistoryTrackingKey always enabled regardless of CloudKit state - Add: cloudKitForceReload notification so repositories re-fetch on remote changes Sources UI: - Add: horizontal category filter chips in SourceListView - Add: search toggle button with animated TextField Other: - Add: ITSAppUsesNonExemptEncryption=NO in Info.plist (skip manual compliance) - Add: fastlane submit lane with run_precheck_before_submit=false - Update: release notes all locales for 1.3.1 - Update: fastlane API key via pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,10 @@ import CloudKit
|
||||
import Combine
|
||||
import WidgetKit
|
||||
|
||||
extension Notification.Name {
|
||||
static let cloudKitForceReload = Notification.Name("cloudKitForceReload")
|
||||
}
|
||||
|
||||
class CoreDataStack: ObservableObject {
|
||||
static let shared = CoreDataStack()
|
||||
|
||||
@@ -115,6 +119,35 @@ class CoreDataStack: ObservableObject {
|
||||
}
|
||||
|
||||
@Published private(set) var isLoaded = false
|
||||
@Published private(set) var lastImportDate: Date?
|
||||
@Published private(set) var lastExportDate: Date?
|
||||
@Published private(set) var isSyncing = false
|
||||
@Published private(set) var lastSyncError: String?
|
||||
|
||||
var lastSyncDate: Date? { lastImportDate }
|
||||
|
||||
var localSourceCount: Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: "InvestmentSource")
|
||||
return (try? viewContext.count(for: request)) ?? 0
|
||||
}
|
||||
|
||||
var localSnapshotCount: Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
|
||||
return (try? viewContext.count(for: request)) ?? 0
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Register CloudKit event observer BEFORE the container is created so we
|
||||
// never miss an import/export event that fires during store loading.
|
||||
if Self.cloudKitEnabled {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(cloudKitEventChanged(_:)),
|
||||
name: NSPersistentCloudKitContainer.eventChangedNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
let container: NSPersistentContainer
|
||||
@@ -131,12 +164,15 @@ class CoreDataStack: ObservableObject {
|
||||
description.shouldMigrateStoreAutomatically = true
|
||||
description.shouldInferMappingModelAutomatically = true
|
||||
|
||||
// Always enable history tracking so data created before CloudKit was enabled
|
||||
// is visible to NSPersistentCloudKitContainer when sync is later turned on.
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||
|
||||
if Self.cloudKitEnabled {
|
||||
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
|
||||
containerIdentifier: Self.cloudKitContainerIdentifier
|
||||
)
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||
}
|
||||
|
||||
container.persistentStoreDescriptions = [description]
|
||||
@@ -177,42 +213,44 @@ class CoreDataStack: ObservableObject {
|
||||
return persistentContainer.viewContext
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Cleanup Duplicates
|
||||
|
||||
/// Removes duplicate objects that have the same UUID, keeping only the oldest one.
|
||||
/// This fixes data corruption from race conditions during object creation.
|
||||
func cleanupDuplicateObjects() {
|
||||
/// This fixes data corruption from race conditions during object creation, and also
|
||||
/// handles the case where a CloudKit first-time sync imports records that already exist
|
||||
/// locally (because they were created before CloudKit was enabled).
|
||||
@discardableResult
|
||||
func cleanupDuplicateObjects() -> Int {
|
||||
var totalRemoved = 0
|
||||
let context = viewContext
|
||||
context.performAndWait {
|
||||
// Clean up duplicate Goals
|
||||
cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
|
||||
// Clean up duplicate Accounts (already handled in AccountRepository but added here for safety)
|
||||
cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
|
||||
// Clean up duplicate InvestmentSources
|
||||
cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
|
||||
// Clean up duplicate Categories
|
||||
cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
|
||||
// Clean up Snapshots first — before cascade rules from InvestmentSource fire,
|
||||
// so we deduplicate by UUID and not rely solely on cascade.
|
||||
totalRemoved += cleanupDuplicates(entityName: "Snapshot", idKey: "id", context: context)
|
||||
totalRemoved += cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
|
||||
totalRemoved += cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
|
||||
totalRemoved += cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
|
||||
totalRemoved += cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
|
||||
|
||||
if context.hasChanges {
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
return totalRemoved
|
||||
}
|
||||
|
||||
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) {
|
||||
@discardableResult
|
||||
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) -> Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
|
||||
|
||||
guard let objects = try? context.fetch(request) else { return }
|
||||
guard let objects = try? context.fetch(request) else { return 0 }
|
||||
|
||||
var seenIds = Set<UUID>()
|
||||
var objectsToDelete: [NSManagedObject] = []
|
||||
|
||||
for object in objects {
|
||||
guard let objectId = object.value(forKey: idKey) as? UUID else { continue }
|
||||
|
||||
if seenIds.contains(objectId) {
|
||||
objectsToDelete.append(object)
|
||||
} else {
|
||||
@@ -221,11 +259,10 @@ class CoreDataStack: ObservableObject {
|
||||
}
|
||||
|
||||
if !objectsToDelete.isEmpty {
|
||||
print("Cleaning up \(objectsToDelete.count) duplicate \(entityName) objects")
|
||||
for object in objectsToDelete {
|
||||
context.delete(object)
|
||||
}
|
||||
print("[Dedup] Removing \(objectsToDelete.count) duplicate \(entityName) objects")
|
||||
for object in objectsToDelete { context.delete(object) }
|
||||
}
|
||||
return objectsToDelete.count
|
||||
}
|
||||
|
||||
// MARK: - Save Context
|
||||
@@ -260,17 +297,118 @@ class CoreDataStack: ObservableObject {
|
||||
@objc private func processRemoteChanges(_ notification: Notification) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
// Force viewContext to re-read all objects from the persistent store,
|
||||
// which now contains the changes CloudKit just synced from other devices.
|
||||
// This triggers NSManagedObjectContextObjectsDidChange so all repositories
|
||||
// automatically re-fetch and push fresh data to ViewModels.
|
||||
// Force viewContext to re-read all objects from the persistent store.
|
||||
self.viewContext.refreshAllObjects()
|
||||
// Remove any duplicates that CloudKit import may have introduced.
|
||||
// This handles the first-time sync case where records existed locally before
|
||||
// CloudKit was enabled: the initial export+import creates duplicate objects.
|
||||
let removed = self.cleanupDuplicateObjects()
|
||||
if removed > 0 {
|
||||
print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import")
|
||||
}
|
||||
// Notify repositories to re-fetch unconditionally.
|
||||
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
|
||||
self.objectWillChange.send()
|
||||
self.save()
|
||||
self.refreshWidgetData()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cloudKitEventChanged(_ notification: Notification) {
|
||||
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
|
||||
as? NSPersistentCloudKitContainer.Event else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let isActive = event.endDate == nil
|
||||
self.isSyncing = isActive
|
||||
if !isActive {
|
||||
if event.succeeded, let endDate = event.endDate {
|
||||
self.lastSyncError = nil
|
||||
switch event.type {
|
||||
case .import: self.lastImportDate = endDate
|
||||
case .export: self.lastExportDate = endDate
|
||||
default: break
|
||||
}
|
||||
} else if let error = event.error {
|
||||
let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup"
|
||||
self.lastSyncError = "\(typeLabel): \(Self.describeError(error))"
|
||||
print("CloudKit \(typeLabel) full error:\n\(error)\nuserInfo: \((error as NSError).userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func describeError(_ error: Error, depth: Int = 0) -> String {
|
||||
guard depth < 3 else { return "…" }
|
||||
let ns = error as NSError
|
||||
var parts: [String] = ["\(ns.domain)(\(ns.code))"]
|
||||
// At depth 0, always include the localised description so the user sees
|
||||
// a human-readable message even when userInfo has no other keys.
|
||||
if depth == 0, let msg = ns.userInfo[NSLocalizedDescriptionKey] as? String {
|
||||
parts.append(msg)
|
||||
}
|
||||
for (key, value) in ns.userInfo {
|
||||
let k = "\(key)"
|
||||
if k == NSLocalizedDescriptionKey || k == "NSLocalizedDescription" { continue }
|
||||
if let nestedError = value as? Error {
|
||||
parts.append("\(k):\(describeError(nestedError, depth: depth + 1))")
|
||||
} else if let dict = value as? [AnyHashable: Any], !dict.isEmpty {
|
||||
let pairs = dict.prefix(3).map { kk, vv -> String in
|
||||
if let e = vv as? Error { return "\(kk)→\(describeError(e, depth: depth + 1))" }
|
||||
return "\(kk)=\(vv)"
|
||||
}
|
||||
parts.append("\(k){\(pairs.joined(separator: ", "))}")
|
||||
} else if !(value is [AnyHashable: Any]) {
|
||||
parts.append("\(k)=\(value)")
|
||||
}
|
||||
}
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func forceReload() {
|
||||
viewContext.perform { [weak self] in
|
||||
self?.viewContext.refreshAllObjects()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces NSPersistentCloudKitContainer to export all local data to iCloud.
|
||||
///
|
||||
/// Strategy: advance `createdAt` by 1 ms for every record. This guarantees
|
||||
/// a *real* value change that the SQLite persistent store will write as a
|
||||
/// persistent-history transaction — which is what NSPersistentCloudKitContainer
|
||||
/// needs to discover records and enqueue them for CloudKit export.
|
||||
///
|
||||
/// Setting a property to the *same* value may be silently discarded by the
|
||||
/// SQLite layer (no SQL UPDATE issued → no history entry → nothing to export).
|
||||
func forceExportToiCloud(completion: @escaping (Int) -> Void) {
|
||||
guard Self.cloudKitEnabled else { completion(0); return }
|
||||
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal"]
|
||||
let context = newBackgroundContext()
|
||||
context.perform { [weak self] in
|
||||
var totalTouched = 0
|
||||
for entityName in entities {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
request.fetchBatchSize = 50
|
||||
guard let objects = try? context.fetch(request) else { continue }
|
||||
for object in objects {
|
||||
// Advance createdAt by 1 ms → always a genuine value change.
|
||||
let t = (object.value(forKey: "createdAt") as? Date) ?? Date()
|
||||
object.setValue(t.addingTimeInterval(0.001), forKey: "createdAt")
|
||||
totalTouched += 1
|
||||
}
|
||||
}
|
||||
if context.hasChanges {
|
||||
try? context.save()
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completion(totalTouched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces an immediate re-read of all Core Data objects from the persistent store.
|
||||
/// Call this when the app returns to the foreground so any iCloud changes made on
|
||||
/// other devices (while this device was inactive) are picked up right away.
|
||||
|
||||
Reference in New Issue
Block a user