diff --git a/myApp/HaruDanim/Shared/DataStore.swift b/myApp/HaruDanim/Shared/DataStore.swift index f88f985..13c324b 100644 --- a/myApp/HaruDanim/Shared/DataStore.swift +++ b/myApp/HaruDanim/Shared/DataStore.swift @@ -60,7 +60,20 @@ nonisolated enum DataStore { /// 앱·위젯·인텐트가 공유하는 컨테이너 생성. /// CloudKit 연동은 메인 앱 프로세스에서만 — 확장은 같은 파일을 로컬 전용으로 연다. + /// 앱 본체(DataStore.shared)는 스토어 없이는 동작할 수 없어, 생성 실패 시 크래시한다. static func makeContainer() -> ModelContainer { + do { + return try makeContainerThrowing() + } catch { + fatalError("[DataStore] 컨테이너 생성 실패: \(error)") + } + } + + /// makeContainer의 throwing 버전. + /// 위젯 확장은 이 함수를 try?로 감싸므로, 메인 앱과의 일시적 스토어 경합으로 컨테이너 + /// 생성이 실패해도 확장이 크래시(= 인터랙티브 위젯 버튼이 '먹통'처럼 보이고 방금 누른 + /// 기록이 유실되는 원인)하지 않고 직전 컨테이너로 폴백할 수 있다. + static func makeContainerThrowing() throws -> ModelContainer { migrateLegacyStoreIfNeeded() if wantsCloudSync && !isExtensionProcess { do { @@ -75,12 +88,8 @@ nonisolated enum DataStore { 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)") - } + let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none) + return try ModelContainer(for: schema, configurations: [config]) } /// uuid 기본값 마이그레이션 보정: 기존 행들이 같은 기본 UUID로 채워졌을 수 있어 diff --git a/myApp/HaruDanim/Shared/HaruDanimIntents.swift b/myApp/HaruDanim/Shared/HaruDanimIntents.swift index 1bff7b9..bf920d8 100644 --- a/myApp/HaruDanim/Shared/HaruDanimIntents.swift +++ b/myApp/HaruDanim/Shared/HaruDanimIntents.swift @@ -50,56 +50,101 @@ enum IntentStore { /// 한 작업(perform/makeEntry) 안에서는 한 번만 호출해야 조회한 모델과 컨텍스트가 일치한다. static func refresh() { guard DataStore.isExtensionProcess else { return } - container = DataStore.makeContainer() + // 스토어 경합 등으로 새 컨테이너 열기에 실패해도 확장이 크래시하지 않도록 + // (인터랙티브 위젯 버튼 먹통/기록 유실의 원인) 직전 컨테이너를 그대로 유지한다. + if let fresh = try? DataStore.makeContainerThrowing() { + container = fresh + } } static func requirePremium() throws { guard PremiumGate.isUnlocked(.siriShortcuts) else { throw PremiumRequiredError() } } - static func actions() -> [Action] { + // MARK: 조회 (읽기 경로 — 현재 컨테이너 기준) + + static func actions() -> [Action] { actions(in: context) } + static func goals() -> [Goal] { goals(in: context) } + static func quests() -> [Quest] { quests(in: context) } + static func action(_ id: UUID) throws -> Action { try action(id, in: context) } + static func goal(_ id: UUID) throws -> Goal { try goal(id, in: context) } + static func quest(_ id: UUID) throws -> Quest { try quest(id, in: context) } + + // MARK: 조회 (지정 컨텍스트 기준 — 쓰기 작업에서 컨텍스트 고정용) + // 쓰기 작업은 조회·수정·저장을 반드시 '같은' 컨텍스트에서 해야 한다. 조회한 모델과 + // 저장 컨텍스트가 갈라지면(컴퓨티드 context가 도중에 다른 컨테이너를 가리키면) 저장이 + // '씹혀' 방금 누른 기록이 유실된다 — 그래서 performWrite가 컨텍스트를 한 번만 캡처해 넘긴다. + + static func actions(in ctx: ModelContext) -> [Action] { // 위젯·단축어의 행동 나열 순서도 이 기기의 모음 탭 배치(로컬 설정)를 따른다 LocalPrefs.orderedActions( - (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]))) ?? [] + (try? ctx.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]))) ?? [] ) } - static func goals() -> [Goal] { - (try? context.fetch(FetchDescriptor( + static func goals(in ctx: ModelContext) -> [Goal] { + (try? ctx.fetch(FetchDescriptor( sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)] ))) ?? [] } - static func quests() -> [Quest] { - (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]))) ?? [] + static func quests(in ctx: ModelContext) -> [Quest] { + (try? ctx.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]))) ?? [] } - static func action(_ id: UUID) throws -> Action { - guard let found = actions().first(where: { $0.uuid == id }) else { + static func action(_ id: UUID, in ctx: ModelContext) throws -> Action { + guard let found = actions(in: ctx).first(where: { $0.uuid == id }) else { throw IntentTargetError(message: "행동을 찾을 수 없어요. 앱에서 삭제됐을 수 있어요.") } return found } - static func goal(_ id: UUID) throws -> Goal { - guard let found = goals().first(where: { $0.uuid == id }) else { + static func goal(_ id: UUID, in ctx: ModelContext) throws -> Goal { + guard let found = goals(in: ctx).first(where: { $0.uuid == id }) else { throw IntentTargetError(message: "목표를 찾을 수 없어요. 앱에서 삭제됐을 수 있어요.") } return found } - static func quest(_ id: UUID) throws -> Quest { - guard let found = quests().first(where: { $0.uuid == id }) else { + static func quest(_ id: UUID, in ctx: ModelContext) throws -> Quest { + guard let found = quests(in: ctx).first(where: { $0.uuid == id }) else { throw IntentTargetError(message: "다짐을 찾을 수 없어요. 앱에서 삭제됐을 수 있어요.") } return found } - /// 데이터 변경 후 저장 + Live Activity 훅 + 위젯 타임라인 갱신 - static func commit() { - try? context.save() - IntentHooks.afterMutation(context) + // MARK: 쓰기 + + /// 조회 + 수정 + 저장을 '하나의' 컨텍스트로 원자적으로 처리하고, 끝에서 동기적으로 + /// Live Activity 훅과 위젯 타임라인 갱신을 수행한다. + /// - 작업 시작 시 컨텍스트를 한 번만 캡처해 끝까지 같은 인스턴스를 쓴다. 확장에서 refresh가 + /// 컨테이너를 교체하더라도 진행 중인 수정이 다른 컨텍스트로 새어 유실되지 않는다. + /// - 저장 오류를 삼키지 않고 한 번 재시도한다(메인 앱과의 일시적 스토어 경합 대비). + /// - body가 던지면(대상 없음 등) 수정 자체가 없으므로 저장/리로드를 건너뛴다. + @discardableResult + static func performWrite(_ body: (ModelContext) throws -> T) rethrows -> T { + refresh() + let ctx = context + let result = try body(ctx) + saveWithRetry(ctx) + IntentHooks.afterMutation(ctx) WidgetCenter.shared.reloadAllTimelines() + return result + } + + /// 변경이 있을 때만 저장하며, 실패 시 한 번 재시도한다. + private static func saveWithRetry(_ ctx: ModelContext) { + guard ctx.hasChanges else { return } + do { + try ctx.save() + } catch { + // 메인 앱과의 일시적 스토어 경합일 수 있어 한 번 더 시도 + do { + try ctx.save() + } catch { + print("[IntentStore] 저장 실패(재시도 후에도): \(error)") + } + } } } @@ -350,10 +395,11 @@ struct RunActionIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { guard PremiumGate.isUnlocked(.homeWidgets) else { throw PremiumRequiredError() } - IntentStore.refresh() - let model = try IntentStore.action(action.id) - ActionRunner.run(model, context: IntentStore.context) - IntentStore.commit() + // 조회·수정·저장을 한 컨텍스트로 묶어 저장 유실을 막는다. + try IntentStore.performWrite { ctx in + let model = try IntentStore.action(action.id, in: ctx) + ActionRunner.run(model, context: ctx) + } return .result() } } @@ -394,17 +440,18 @@ struct StartTimeActionIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { try IntentStore.requirePremium() - IntentStore.refresh() - let model = try IntentStore.action(action.id) - guard model.trackingType == .time else { - throw IntentTargetError(message: "'\(model.name)'은(는) 횟수 기록 행동이에요. 횟수 추가를 사용해 주세요.") + let dialog: IntentDialog = try IntentStore.performWrite { ctx in + let model = try IntentStore.action(action.id, in: ctx) + guard model.trackingType == .time else { + throw IntentTargetError(message: "'\(model.name)'은(는) 횟수 기록 행동이에요. 횟수 추가를 사용해 주세요.") + } + guard model.runningSession == nil else { + return "\(model.name)은(는) 이미 측정 중이에요." + } + ctx.insert(TimeSession(action: model, startAt: .now)) + return "\(model.name) 측정을 시작했어요." } - guard model.runningSession == nil else { - return .result(dialog: "\(model.name)은(는) 이미 측정 중이에요.") - } - IntentStore.context.insert(TimeSession(action: model, startAt: .now)) - IntentStore.commit() - return .result(dialog: "\(model.name) 측정을 시작했어요.") + return .result(dialog: dialog) } } @@ -420,23 +467,23 @@ struct StopTimeActionIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { try IntentStore.requirePremium() - IntentStore.refresh() - let model = try IntentStore.action(action.id) - guard let session = model.runningSession else { - return .result(dialog: "\(model.name)은(는) 측정 중이 아니에요.") + let dialog: IntentDialog = try IntentStore.performWrite { ctx in + let model = try IntentStore.action(action.id, in: ctx) + guard let session = model.runningSession else { + return "\(model.name)은(는) 측정 중이 아니에요." + } + // 앱과 동일한 "짧은 기록 무시" 규칙 적용 + let minSeconds = (AppGroup.defaults.object(forKey: SettingsKeys.minSessionSeconds) + ?? UserDefaults.standard.object(forKey: SettingsKeys.minSessionSeconds)) as? Int ?? 0 + let duration = session.duration() + if minSeconds > 0, duration < Double(minSeconds) { + ctx.delete(session) + return "\(model.name) 측정을 종료했어요. 너무 짧은 기록이라 저장하지 않았어요." + } + session.endAt = .now + return "\(model.name) 측정을 종료했어요. \(Format.durationShort(duration)) 기록했어요." } - // 앱과 동일한 "짧은 기록 무시" 규칙 적용 - let minSeconds = (AppGroup.defaults.object(forKey: SettingsKeys.minSessionSeconds) - ?? UserDefaults.standard.object(forKey: SettingsKeys.minSessionSeconds)) as? Int ?? 0 - let duration = session.duration() - if minSeconds > 0, duration < Double(minSeconds) { - IntentStore.context.delete(session) - IntentStore.commit() - return .result(dialog: "\(model.name) 측정을 종료했어요. 너무 짧은 기록이라 저장하지 않았어요.") - } - session.endAt = .now - IntentStore.commit() - return .result(dialog: "\(model.name) 측정을 종료했어요. \(Format.durationShort(duration)) 기록했어요.") + return .result(dialog: dialog) } } @@ -455,16 +502,18 @@ struct AddCountIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { try IntentStore.requirePremium() - IntentStore.refresh() - let model = try IntentStore.action(action.id) - guard model.trackingType == .count else { - throw IntentTargetError(message: "'\(model.name)'은(는) 시간 측정 행동이에요. 측정 시작/종료를 사용해 주세요.") + let dialog: IntentDialog = try IntentStore.performWrite { ctx in + let model = try IntentStore.action(action.id, in: ctx) + guard model.trackingType == .count else { + throw IntentTargetError(message: "'\(model.name)'은(는) 시간 측정 행동이에요. 측정 시작/종료를 사용해 주세요.") + } + ctx.insert(CountEntry(action: model, timestamp: .now, amount: amount)) + // 방금 삽입한 기록까지 반영된 오늘 누적(같은 컨텍스트의 역관계로 계산) + let math = DayMath() + let today = Aggregator(math: math).count(for: model, in: math.dayRange(containing: .now)) + return "\(model.name) \(amount)회 추가했어요. 오늘 총 \(today)회예요." } - IntentStore.context.insert(CountEntry(action: model, timestamp: .now, amount: amount)) - IntentStore.commit() - let math = DayMath() - let today = Aggregator(math: math).count(for: model, in: math.dayRange(containing: .now)) - return .result(dialog: "\(model.name) \(amount)회 추가했어요. 오늘 총 \(today)회예요.") + return .result(dialog: dialog) } }