From b76b8f81f037f6d5134ac1df7840ece107322398 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Fri, 10 Jul 2026 13:28:15 +0200 Subject: [PATCH] =?UTF-8?q?Hotfix:=20accessors=20nil-tolerantes=20en=20Cor?= =?UTF-8?q?e=20Data=20=E2=80=94=20crashes=20de=20bridging=20con=20iCloud?= =?UTF-8?q?=20sync=20(build=2056)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dos crashes de producción con la misma causa raíz: atributos declarados no-opcionales en Swift (@NSManaged id: UUID, date: Date) que llegan nil en runtime — NSPersistentCloudKitContainer materializa registros de otros dispositivos por fases y los objetos borrados pueden seguir referenciados por vistas vivas. El force-bridge de nil revienta: 1. UUID._unconditionallyBridgeFromObjectiveC en ForEach (keypath Identifiable.id) 2. Date._unconditionallyBridgeFromObjectiveC en ChartsViewModel.monthlyTotals Fix sistémico (no por call-site): NSManagedObject.safeValue(forKey:fallback:healing:) lee el primitive y cae a un valor seguro; id se auto-cura (UUID nuevo escrito al primitive sin ensuciar el objeto). Aplicado a: - Snapshot: id (heal), date, createdAt (fallback distantPast) - InvestmentSource / Goal / Account: id (heal), name (fallback "") - Category: id (heal), name, colorHex, icon (fallbacks neutros) Los accessors son @objc → NSSortDescriptor(keyPath:) y KVC siguen funcionando. awakeFromInsert/awakeFromFetch se mantienen. Smoke test UITest en verde. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe --- .../CoreData/Account+CoreDataClass.swift | 10 ++++-- .../CoreData/Category+CoreDataClass.swift | 20 ++++++++--- .../Models/CoreData/Goal+CoreDataClass.swift | 10 ++++-- .../InvestmentSource+CoreDataClass.swift | 10 ++++-- .../NSManagedObject+SafeAccessors.swift | 34 +++++++++++++++++++ .../CoreData/Snapshot+CoreDataClass.swift | 15 ++++++-- 6 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 PortfolioJournal/Models/CoreData/NSManagedObject+SafeAccessors.swift diff --git a/PortfolioJournal/Models/CoreData/Account+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Account+CoreDataClass.swift index 871422f..4d30d43 100644 --- a/PortfolioJournal/Models/CoreData/Account+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/Account+CoreDataClass.swift @@ -7,8 +7,14 @@ public class Account: NSManagedObject, Identifiable { return NSFetchRequest(entityName: "Account") } - @NSManaged public var id: UUID - @NSManaged public var name: String + @objc public var id: UUID { + get { safeValue(forKey: "id", fallback: UUID(), healing: true) } + set { setManagedValue(newValue, forKey: "id") } + } + @objc public var name: String { + get { safeValue(forKey: "name", fallback: "") } + set { setManagedValue(newValue, forKey: "name") } + } @NSManaged public var createdAt: Date @NSManaged public var currency: String? @NSManaged public var inputMode: String diff --git a/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift index 9f18803..0231807 100644 --- a/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift @@ -8,10 +8,22 @@ public class Category: NSManagedObject, Identifiable { return NSFetchRequest(entityName: "Category") } - @NSManaged public var id: UUID - @NSManaged public var name: String - @NSManaged public var colorHex: String - @NSManaged public var icon: String + @objc public var id: UUID { + get { safeValue(forKey: "id", fallback: UUID(), healing: true) } + set { setManagedValue(newValue, forKey: "id") } + } + @objc public var name: String { + get { safeValue(forKey: "name", fallback: "") } + set { setManagedValue(newValue, forKey: "name") } + } + @objc public var colorHex: String { + get { safeValue(forKey: "colorHex", fallback: "#6B7280") } + set { setManagedValue(newValue, forKey: "colorHex") } + } + @objc public var icon: String { + get { safeValue(forKey: "icon", fallback: "questionmark") } + set { setManagedValue(newValue, forKey: "icon") } + } @NSManaged public var sortOrder: Int16 @NSManaged public var createdAt: Date @NSManaged public var allocationTarget: NSNumber? diff --git a/PortfolioJournal/Models/CoreData/Goal+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Goal+CoreDataClass.swift index d99b175..e8adad3 100644 --- a/PortfolioJournal/Models/CoreData/Goal+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/Goal+CoreDataClass.swift @@ -7,8 +7,14 @@ public class Goal: NSManagedObject, Identifiable { return NSFetchRequest(entityName: "Goal") } - @NSManaged public var id: UUID - @NSManaged public var name: String + @objc public var id: UUID { + get { safeValue(forKey: "id", fallback: UUID(), healing: true) } + set { setManagedValue(newValue, forKey: "id") } + } + @objc public var name: String { + get { safeValue(forKey: "name", fallback: "") } + set { setManagedValue(newValue, forKey: "name") } + } @NSManaged public var targetAmount: NSDecimalNumber? @NSManaged public var targetDate: Date? @NSManaged public var isActive: Bool diff --git a/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift index 303dff2..c36f2e4 100644 --- a/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift @@ -7,8 +7,14 @@ public class InvestmentSource: NSManagedObject, Identifiable { return NSFetchRequest(entityName: "InvestmentSource") } - @NSManaged public var id: UUID - @NSManaged public var name: String + @objc public var id: UUID { + get { safeValue(forKey: "id", fallback: UUID(), healing: true) } + set { setManagedValue(newValue, forKey: "id") } + } + @objc public var name: String { + get { safeValue(forKey: "name", fallback: "") } + set { setManagedValue(newValue, forKey: "name") } + } @NSManaged public var notificationFrequency: String @NSManaged public var customFrequencyMonths: Int16 @NSManaged public var isActive: Bool diff --git a/PortfolioJournal/Models/CoreData/NSManagedObject+SafeAccessors.swift b/PortfolioJournal/Models/CoreData/NSManagedObject+SafeAccessors.swift new file mode 100644 index 0000000..8582d54 --- /dev/null +++ b/PortfolioJournal/Models/CoreData/NSManagedObject+SafeAccessors.swift @@ -0,0 +1,34 @@ +import CoreData + +// MARK: - Nil-tolerant accessors for CloudKit-synced attributes +// +// Attributes declared non-optional in Swift (`id: UUID`, `date: Date`, …) CAN +// be nil at runtime: NSPersistentCloudKitContainer merges records from other +// devices in stages, and deleted objects may still be referenced by live views. +// Reading them through `@NSManaged` force-bridges nil and crashes +// (`UUID/Date._unconditionallyBridgeFromObjectiveC`) — seen in production in +// SwiftUI ForEach identity (nil id) and ChartsViewModel.monthlyTotals (nil +// date). These helpers read the primitive value and fall back safely. +extension NSManagedObject { + /// Reads a primitive attribute, returning `fallback` when nil. With + /// `healing: true` the fallback is also written back to the primitive + /// (without dirtying the object) so identity stays stable for the session. + func safeValue(forKey key: String, fallback: @autoclosure () -> T, healing: Bool = false) -> T { + willAccessValue(forKey: key) + defer { didAccessValue(forKey: key) } + if let value = primitiveValue(forKey: key) as? T { + return value + } + let healed = fallback() + if healing { + setPrimitiveValue(healed, forKey: key) + } + return healed + } + + func setManagedValue(_ value: T, forKey key: String) { + willChangeValue(forKey: key) + setPrimitiveValue(value, forKey: key) + didChangeValue(forKey: key) + } +} diff --git a/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift index 67f13dd..9d39c26 100644 --- a/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift @@ -7,12 +7,21 @@ public class Snapshot: NSManagedObject, Identifiable { return NSFetchRequest(entityName: "Snapshot") } - @NSManaged public var id: UUID - @NSManaged public var date: Date + @objc public var id: UUID { + get { safeValue(forKey: "id", fallback: UUID(), healing: true) } + set { setManagedValue(newValue, forKey: "id") } + } + @objc public var date: Date { + get { safeValue(forKey: "date", fallback: .distantPast) } + set { setManagedValue(newValue, forKey: "date") } + } @NSManaged public var value: NSDecimalNumber? @NSManaged public var contribution: NSDecimalNumber? @NSManaged public var notes: String? - @NSManaged public var createdAt: Date + @objc public var createdAt: Date { + get { safeValue(forKey: "createdAt", fallback: .distantPast) } + set { setManagedValue(newValue, forKey: "createdAt") } + } @NSManaged public var source: InvestmentSource? public override func awakeFromInsert() {