'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(DayMath·
QuestProgress·Models)는 발견 0건.
[M] DataStore: 스토어 손상 백업 복구 직후 legacy 이관 가드(타깃 없음)가
다시 열려 구 샌드박스 스토어(영구 잔존)가 부활 — 수개월 전 데이터로
조용한 롤백. App Group 플래그(migration.legacyStoreImported)로 평생
1회 보장 (§5.1 문서화)
[M] 통계 시리즈가 행동/꼬리표 이름 키 — 동명 2개면 차트가 한 선으로 합쳐
지고 Identifiable id 충돌, 동명 꼬리표는 합산. Format.disambiguated
(이름 (2) 형식)로 통계 탭·통계 내보내기·⑤ 위젯 3표면 통일, 꼬리표
집계는 identity 키로 재작성 (수치는 기존과 동일, 표시·범례만 구분)
[L] SessionAlertManager: 시작→즉시 종료 연타 시 조회(await)~추가 사이
경합으로 종료된 세션의 장시간 알림이 잔존 — 세대 카운터로 마지막
호출만 확정
[L] 세션 편집기: 분 절사 값을 무조건 덮어써 1분 미만 세션이 메모만
고쳐도 0초로 파괴, 시작=종료 0길이 기록은 저장돼도 어디에도 안 보임
— 안 움직인 필드는 원본 시각(초) 보존 + 0길이 저장 차단(문구 갱신)
[L] 타임테이블 시간축: 하루 시작이 정시가 아니면(06:30) 라벨이 시만
표기해 최대 59분 어긋남 — 분 성분 포함(HH:mm), 화면·내보내기 동일
[L] 기록·통계 필터: 제외했던 행동을 삭제하면 잔존 ID로 칩·내보내기
필터 문구가 허위 활성 — 실재 행동 기준으로 판정
[L] 목표 편집: 시작일을 종료일 뒤로 옮기면 종료<시작 저장 가능(DatePicker
in: 은 표시 제약만) — 저장 시 정규화
+ QuestEditor·TagViews·GoalViews·인텐트·WidgetSupport 정독 — 추가 발견 없음
+ §15-11 서브 에이전트 금지 명문화 (사용자 지시)
검증: Debug/Store 빌드, 카탈로그 missing/stale 0 (새 문구 2키 en/ja)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
218 lines
10 KiB
Swift
218 lines
10 KiB
Swift
//
|
|
// 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<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() }
|
|
}
|
|
|
|
/// 구 샌드박스 스토어 이관 완료 플래그 (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)
|
|
}
|
|
}
|