// // DataStore.swift // Haru_Danim // // SwiftData 컨테이너 구성 (CLAUDE.md §2.2) // - 저장 위치: App Group 컨테이너 (위젯·App Intents가 같은 DB를 읽고 쓰기 위함) // - 기존 앱 샌드박스의 default.store는 최초 1회 App Group으로 이관 // - iCloud 동기화(프리미엄): 켜져 있으면 CloudKit 연동 구성으로 컨테이너 생성, 실패 시 로컬로 폴백 // - CloudKit 미러링은 메인 앱 프로세스 전담. 위젯 확장(.appex)은 항상 로컬 전용 구성으로 // 같은 스토어 파일만 연다 — 확장에는 CloudKit 엔타이틀먼트가 없고 메모리·수명 제약으로 // 미러링 설정이 크래시/행을 일으킨다. 확장이 쓴 변경은 메인 앱이 히스토리로 집어 CloudKit에 내보낸다. // import Foundation import SwiftData nonisolated enum DataStore { /// 프로세스 전체가 공유하는 컨테이너. /// 앱 본체와 App Intents가 같은 인스턴스를 쓰게 해 변경 사항이 즉시 반영되게 한다. static let shared: ModelContainer = makeContainer() static let schema = Schema([ Tag.self, Action.self, TimeSession.self, CountEntry.self, Goal.self, Quest.self, DiaryEntry.self, DiaryTodo.self, DiaryPage.self, DiaryPageItem.self, DiaryTemplate.self, ]) /// iCloud 동기화 on/off (프리미엄, App Group defaults에 저장) static let cloudSyncKey = "settings.cloudSync" static let cloudContainerID = "iCloud.com.yechan.HaruDanim" /// App Group 컨테이너 안의 스토어 파일 위치 static var storeURL: URL { guard let base = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: AppGroup.identifier ) else { // App Group 미구성(이론상 없음) 시 기존 위치 유지 return URL.applicationSupportDirectory.appending(path: "default.store") } return base.appending(path: "HaruDanim.store") } /// 위젯 등 앱 확장(.appex) 프로세스에서 실행 중인지 static var isExtensionProcess: Bool { Bundle.main.bundleURL.pathExtension == "appex" } /// iCloud 동기화를 시도해야 하는 상태인지 (프리미엄 + 설정 on) static var wantsCloudSync: Bool { #if DEBUG if UserDefaults.standard.object(forKey: "cloudSync") != nil { return UserDefaults.standard.bool(forKey: "cloudSync") } #endif return PremiumGate.isUnlocked(.cloudSync) && AppGroup.defaults.bool(forKey: cloudSyncKey) } /// 앱·위젯·인텐트가 공유하는 컨테이너 생성. /// CloudKit 연동은 메인 앱 프로세스에서만 — 확장은 같은 파일을 로컬 전용으로 연다. /// /// 실패해도 곧장 크래시하지 않는다: /// 1) 위젯 등 다른 프로세스와의 일시적 스토어 경합(특히 스키마 마이그레이션이 필요한 /// 업데이트 직후 첫 실행)일 수 있어 짧게 재시도하고, /// 2) 그래도 실패하면(영구적 마이그레이션/파일 손상) 기존 스토어를 백업 이름으로 보존한 뒤 /// 새 스토어로 시작한다 — 예전에는 여기서 바로 fatalError라 스플래시도 못 띄우고 /// 앱이 무한히 죽는 상태가 될 수 있었다. 백업 파일(HaruDanim.store.backup-*)은 남는다. static func makeContainer() -> ModelContainer { do { return try makeContainerThrowing() } catch { print("[DataStore] 컨테이너 생성 실패, 재시도: \(error)") for attempt in 1...3 { Thread.sleep(forTimeInterval: 0.4 * Double(attempt)) if let container = try? makeContainerThrowing() { return container } } print("[DataStore] 재시도 실패 — 스토어를 백업하고 새로 시작: \(error)") backupBrokenStore() do { return try makeContainerThrowing() } catch { fatalError("[DataStore] 컨테이너 생성 실패(백업 후 재생성도 실패): \(error)") } } } /// 열 수 없게 된 스토어 파일(-wal/-shm 포함)을 타임스탬프 백업 이름으로 옮긴다. /// 삭제하지 않으므로 필요 시 파일을 되돌려 데이터를 복구할 수 있다. private static func backupBrokenStore() { let fileManager = FileManager.default let stamp = Int(Date.now.timeIntervalSince1970) for suffix in ["", "-wal", "-shm"] { let source = URL(fileURLWithPath: storeURL.path + suffix) guard fileManager.fileExists(atPath: source.path) else { continue } let destination = URL(fileURLWithPath: storeURL.path + ".backup-\(stamp)" + suffix) try? fileManager.moveItem(at: source, to: destination) } } /// makeContainer의 throwing 버전. /// 위젯 확장은 이 함수를 try?로 감싸므로, 메인 앱과의 일시적 스토어 경합으로 컨테이너 /// 생성이 실패해도 확장이 크래시(= 인터랙티브 위젯 버튼이 '먹통'처럼 보이고 방금 누른 /// 기록이 유실되는 원인)하지 않고 직전 컨테이너로 폴백할 수 있다. static func makeContainerThrowing() throws -> ModelContainer { migrateLegacyStoreIfNeeded() if wantsCloudSync && !isExtensionProcess { do { let config = ModelConfiguration( schema: schema, url: storeURL, cloudKitDatabase: .private(cloudContainerID) ) return try ModelContainer(for: schema, configurations: [config]) } catch { // iCloud 계정 없음/스키마 비호환 등 → 로컬 전용으로 폴백 print("[DataStore] CloudKit 컨테이너 생성 실패, 로컬로 폴백: \(error)") } } let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none) return try ModelContainer(for: schema, configurations: [config]) } /// uuid 기본값 마이그레이션 보정: 기존 행들이 같은 기본 UUID로 채워졌을 수 있어 /// 중복을 찾아 재발급한다 (시리 단축어·위젯 식별자용, 앱 시작 시 1회 호출) @MainActor static func ensureUniqueEntityIDs(context: ModelContext) { var changed = false var tagIDs = Set() for tag in (try? context.fetch(FetchDescriptor())) ?? [] where !tagIDs.insert(tag.uuid).inserted { tag.uuid = UUID() changed = true } var actionIDs = Set() for action in (try? context.fetch(FetchDescriptor())) ?? [] where !actionIDs.insert(action.uuid).inserted { action.uuid = UUID() changed = true } var goalIDs = Set() for goal in (try? context.fetch(FetchDescriptor())) ?? [] where !goalIDs.insert(goal.uuid).inserted { goal.uuid = UUID() changed = true } var questIDs = Set() for quest in (try? context.fetch(FetchDescriptor())) ?? [] where !questIDs.insert(quest.uuid).inserted { quest.uuid = UUID() changed = true } if changed { try? context.save() } } /// 구 샌드박스 스토어 이관 완료 플래그 (App Group — 앱·확장 프로세스 공유) private static let legacyMigrationDoneKey = "migration.legacyStoreImported" /// 기존 앱 샌드박스(Application Support/default.store)의 DB를 App Group으로 1회 복사. /// "1회"는 플래그로 보장한다 — 원본 이관이 copyItem이라 구 스토어가 영구 잔존하는데, /// 스토어 손상 복구(backupBrokenStore)로 타깃 파일이 사라진 직후 이 함수가 다시 돌면 /// 수개월 전 데이터가 담긴 구 스토어가 되살아나 조용한 롤백이 되기 때문("타깃 없음" /// 가드만으로는 그 경우를 구분 못 한다). private static func migrateLegacyStoreIfNeeded() { let fm = FileManager.default let target = storeURL guard target.path != URL.applicationSupportDirectory.appending(path: "default.store").path else { return } let defaults = AppGroup.defaults guard !defaults.bool(forKey: legacyMigrationDoneKey) else { return } if fm.fileExists(atPath: target.path) { // 이미 새 스토어 사용 중(과거에 이관됐거나 신규 생성) — 이후로는 이관 후보 아님 defaults.set(true, forKey: legacyMigrationDoneKey) return } let legacy = URL.applicationSupportDirectory.appending(path: "default.store") guard fm.fileExists(atPath: legacy.path) else { // 이관할 구 스토어 자체가 없음(신규 설치) defaults.set(true, forKey: legacyMigrationDoneKey) return } // -wal/-shm 저널 파일까지 함께 복사해야 최신 데이터가 유지됨 for suffix in ["", "-shm", "-wal"] { let from = URL(fileURLWithPath: legacy.path + suffix) let to = URL(fileURLWithPath: target.path + suffix) if fm.fileExists(atPath: from.path) { try? fm.copyItem(at: from, to: to) } } defaults.set(true, forKey: legacyMigrationDoneKey) } } // MARK: - 설정 값 App Group 이관 /// 위젯·워치가 집계에 사용하는 설정(주 시작 요일, 하루 시작 시간, 대표 시간 기준)을 /// UserDefaults.standard → App Group defaults로 1회 이관 nonisolated enum SettingsMigration { private static let doneKey = "migration.settingsToAppGroup" static func runIfNeeded() { let group = AppGroup.defaults guard !group.bool(forKey: doneKey) else { return } let standard = UserDefaults.standard for key in [SettingsKeys.weekStartWeekday, SettingsKeys.dayStartMinutes, SettingsKeys.liveActivityMode, SettingsKeys.minSessionSeconds, SettingsKeys.theme] { if group.object(forKey: key) == nil, let value = standard.object(forKey: key) { group.set(value, forKey: key) } } group.set(true, forKey: doneKey) } }