1 Commits

Author SHA1 Message Date
alexandrev-tibco d08c312c62 Hotfix: accessors nil-tolerantes en Core Data — crashes de bridging con iCloud sync (build 56)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 13:28:15 +02:00
7 changed files with 96 additions and 23 deletions
+10 -10
View File
@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -582,7 +582,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -739,7 +739,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -774,7 +774,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -805,7 +805,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -829,7 +829,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -852,7 +852,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -875,7 +875,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -899,7 +899,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
@@ -927,7 +927,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 56;
DEVELOPMENT_TEAM = 2825Q76T7H;
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
GENERATE_INFOPLIST_FILE = NO;
@@ -7,8 +7,14 @@ public class Account: NSManagedObject, Identifiable {
return NSFetchRequest<Account>(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
@@ -8,10 +8,22 @@ public class Category: NSManagedObject, Identifiable {
return NSFetchRequest<Category>(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?
@@ -7,8 +7,14 @@ public class Goal: NSManagedObject, Identifiable {
return NSFetchRequest<Goal>(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
@@ -7,8 +7,14 @@ public class InvestmentSource: NSManagedObject, Identifiable {
return NSFetchRequest<InvestmentSource>(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
@@ -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<T>(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<T>(_ value: T, forKey key: String) {
willChangeValue(forKey: key)
setPrimitiveValue(value, forKey: key)
didChangeValue(forKey: key)
}
}
@@ -7,12 +7,21 @@ public class Snapshot: NSManagedObject, Identifiable {
return NSFetchRequest<Snapshot>(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() {