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:
songyc macbook 2026-07-12 11:01:47 +09:00
parent a02e06a9df
commit 32306510c8
2 changed files with 120 additions and 62 deletions

View File

@ -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

View File

@ -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)
}
}