fix(widget): make interactive AppIntent writes reliable and crash-proof
Interactive home/lock-screen widget buttons (count +1, timer start/stop) gave touch feedback but often didn't persist, only working after several rapid taps. Two compounding defects in the widget-extension write path: - The extension could crash mid-write. refresh()/makeContainer() opens a new ModelContainer on every intent perform and every timeline read; in the extension that hit a local-store branch whose failure was fatalError. A transient App Group store contention with the running main app crashed the whole appex — the tap looked dead and only a later retry won the race. Split makeContainer into a throwing makeContainerThrowing(); refresh() now wraps it in try? and keeps the previous container on failure, so the extension degrades instead of crashing. The app's DataStore.shared keeps its crash-on-failure semantics. - Fetch → mutate → save could span different contexts and save errors were swallowed. IntentStore.context is a computed container.mainContext, and commit() re-read it for a try? save(), so a mid-flight container swap left the mutation on a dead context and any save failure vanished silently. Added IntentStore.performWrite(_:) which captures one context for the whole fetch+mutate+save, saves with a retry (no silent try?), then fires the Live Activity hook and reloadAllTimelines. All four mutating intents (RunAction/StartTime/StopTime/AddCount) now fetch in that single context. No @Model or main-app logic changed — fix is confined to the widget/intent data layer. Verified: 5 rapid RunActionIntent.perform() calls persist +5 with no lost writes across three cold launches (ok=true each). Build succeeds (app + embedded Haru_DanimWidgets.appex). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
a02e06a9df
commit
32306510c8
@ -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로 채워졌을 수 있어
|
||||
|
||||
@ -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<Action>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
|
||||
(try? ctx.fetch(FetchDescriptor<Action>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
static func goals() -> [Goal] {
|
||||
(try? context.fetch(FetchDescriptor<Goal>(
|
||||
static func goals(in ctx: ModelContext) -> [Goal] {
|
||||
(try? ctx.fetch(FetchDescriptor<Goal>(
|
||||
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
|
||||
))) ?? []
|
||||
}
|
||||
|
||||
static func quests() -> [Quest] {
|
||||
(try? context.fetch(FetchDescriptor<Quest>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
|
||||
static func quests(in ctx: ModelContext) -> [Quest] {
|
||||
(try? ctx.fetch(FetchDescriptor<Quest>(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<T>(_ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user