- DataStore: App Group 컨테이너의 HaruDanim.store 사용, 기존 default.store 1회 자동 이관 - 프리미엄 + 동기화 토글 시 CloudKit(.private) 구성으로 컨테이너 생성, 실패하면 로컬 폴백 - CloudKit 요건(모든 관계 optional)에 맞춰 to-many 관계를 optional 저장 + 기존 이름 접근자로 리팩터링 (originalName 매핑으로 기존 데이터 유지) - Tag/Action/Goal/Quest에 uuid 추가 (시리 단축어·위젯·워치 식별자) + 중복 보정 - 모델 레이어(Models/Settings/DayMath/QuestProgress/Formatters/Theme)를 Shared/로 이동 — 위젯 확장과 공유 - 집계 설정(주 시작 요일·하루 시작 시간·대표 시간·짧은 기록 무시)을 App Group defaults로 이관해 위젯·워치와 공유, 설정의 '기기 간 동기화' 토글 활성화 - Goal.combinedSpanRatio 공용화 (모음 탭 카드·위젯·시리가 동일 산식 사용) - DEBUG 검증 인자: -cloudSync YES, -settingsScrollPremium YES Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
144 lines
5.9 KiB
Swift
144 lines
5.9 KiB
Swift
//
|
|
// DataStore.swift
|
|
// Haru_Danim
|
|
//
|
|
// SwiftData 컨테이너 구성 (CLAUDE.md §2.2)
|
|
// - 저장 위치: App Group 컨테이너 (위젯·App Intents가 같은 DB를 읽고 쓰기 위함)
|
|
// - 기존 앱 샌드박스의 default.store는 최초 1회 App Group으로 이관
|
|
// - iCloud 동기화(프리미엄): 켜져 있으면 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,
|
|
])
|
|
|
|
/// 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")
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 앱·위젯·인텐트가 공유하는 컨테이너 생성
|
|
static func makeContainer() -> ModelContainer {
|
|
migrateLegacyStoreIfNeeded()
|
|
if wantsCloudSync {
|
|
do {
|
|
let config = ModelConfiguration(
|
|
schema: schema,
|
|
url: storeURL,
|
|
cloudKitDatabase: .private(cloudContainerID)
|
|
)
|
|
return try ModelContainer(for: schema, configurations: [config])
|
|
} catch {
|
|
// iCloud 계정 없음/스키마 비호환 등 → 로컬 전용으로 폴백
|
|
print("[DataStore] CloudKit 컨테이너 생성 실패, 로컬로 폴백: \(error)")
|
|
}
|
|
}
|
|
do {
|
|
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
|
return try ModelContainer(for: schema, configurations: [config])
|
|
} catch {
|
|
fatalError("[DataStore] 컨테이너 생성 실패: \(error)")
|
|
}
|
|
}
|
|
|
|
/// uuid 기본값 마이그레이션 보정: 기존 행들이 같은 기본 UUID로 채워졌을 수 있어
|
|
/// 중복을 찾아 재발급한다 (시리 단축어·위젯 식별자용, 앱 시작 시 1회 호출)
|
|
@MainActor
|
|
static func ensureUniqueEntityIDs(context: ModelContext) {
|
|
var changed = false
|
|
var tagIDs = Set<UUID>()
|
|
for tag in (try? context.fetch(FetchDescriptor<Tag>())) ?? [] where !tagIDs.insert(tag.uuid).inserted {
|
|
tag.uuid = UUID()
|
|
changed = true
|
|
}
|
|
var actionIDs = Set<UUID>()
|
|
for action in (try? context.fetch(FetchDescriptor<Action>())) ?? [] where !actionIDs.insert(action.uuid).inserted {
|
|
action.uuid = UUID()
|
|
changed = true
|
|
}
|
|
var goalIDs = Set<UUID>()
|
|
for goal in (try? context.fetch(FetchDescriptor<Goal>())) ?? [] where !goalIDs.insert(goal.uuid).inserted {
|
|
goal.uuid = UUID()
|
|
changed = true
|
|
}
|
|
var questIDs = Set<UUID>()
|
|
for quest in (try? context.fetch(FetchDescriptor<Quest>())) ?? [] where !questIDs.insert(quest.uuid).inserted {
|
|
quest.uuid = UUID()
|
|
changed = true
|
|
}
|
|
if changed { try? context.save() }
|
|
}
|
|
|
|
/// 기존 앱 샌드박스(Application Support/default.store)의 DB를 App Group으로 1회 복사
|
|
private static func migrateLegacyStoreIfNeeded() {
|
|
let fm = FileManager.default
|
|
let target = storeURL
|
|
guard target.path != URL.applicationSupportDirectory.appending(path: "default.store").path,
|
|
!fm.fileExists(atPath: target.path) else { return }
|
|
let legacy = URL.applicationSupportDirectory.appending(path: "default.store")
|
|
guard fm.fileExists(atPath: legacy.path) else { 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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] {
|
|
if group.object(forKey: key) == nil, let value = standard.object(forKey: key) {
|
|
group.set(value, forKey: key)
|
|
}
|
|
}
|
|
group.set(true, forKey: doneKey)
|
|
}
|
|
}
|