mycode/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
songyc macbook 86bdf3d2d7 fix(sweep): full-app inspection fixes — watch command control reload, complication state key, bundle version, stale copy/catalog cleanup
전체 점검에서 발견한 저위험 문제들 일괄 수정:
- 워치 실행 명령 처리 경로에 ControlCenter.reloadAllControls() 추가
  (DataChange.commit과 동일 경로 유지 — 워치에서 시작/종료 시
  아이폰 제어 센터 토글이 낡은 상태로 남던 문제)
- 컴플리케이션 전용 전송 상태 키에 측정 시작 시각 포함
  (같은 행동을 종료 직후 디바운스 안에 재시작하면 상태 변화로
  인식하지 못해 컴플리케이션 타이머가 갱신되지 않던 엣지)
- 설정 탭 버전 표기를 하드코딩("1.0") 대신 번들
  CFBundleShortVersionString에서 읽도록 변경
- '대표 시간 기준' 설명의 낡은 문구 "(Live Activity 지원은 추후
  추가 예정)" 제거 — 실제 용처(다이나믹 아일랜드·잠금화면·워치
  컴플리케이션)로 갱신, en/ja 번역 포함
- 카탈로그 stale 항목 22개 정리 (IOS 20 + Widgets 2 — 과거 문구
  수정 때 남은 죽은 키), CLAUDE.md l10n 루틴에 stale 청소 단계 추가
- CLAUDE.md 남은 배포 작업에 코드 측 잔여 2건(약관 링크,
  ITSAppUsesNonExemptEncryption) 명시

검증: Debug·Store·워치 3스킴 빌드 성공, 전 카탈로그 missing 0,
인텐트 스모크 테스트 10개 통과, 스트릭 덤프 정상(시작일 하한 유지),
설정·모음 탭 스크린샷 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 13:42:54 +09:00

252 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WatchSyncManager.swift
// Haru_Danim
//
// Apple Watch (CLAUDE.md §9.1)
// - DB
// - updateApplicationContext
//
import Foundation
import OSLog
import SwiftData
import WatchConnectivity
import WidgetKit
private let watchSyncLog = Logger(subsystem: "com.yechan.HaruDanim", category: "WatchSync")
@MainActor
final class WatchSyncManager: NSObject {
static let shared = WatchSyncManager()
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
#if DEBUG
watchSyncLog.notice("responds userInfo=\(self.responds(to: NSSelectorFromString("session:didReceiveUserInfo:"))) message=\(self.responds(to: NSSelectorFromString("session:didReceiveMessage:replyHandler:")))")
#endif
}
// MARK:
/// DB
func makeSnapshot() -> WatchSnapshot {
let context = DataStore.shared.mainContext
let now = Date.now
let math = DayMath()
let agg = Aggregator(math: math)
var snapshot = WatchSnapshot()
snapshot.isPremium = PremiumGate.isUnlocked(.watchApp)
let tags = (try? context.fetch(FetchDescriptor<Tag>(
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
))) ?? []
snapshot.tags = tags.map { WatchTagInfo(id: $0.uuid, name: $0.name, colorHex: $0.colorHex) }
// ( ) ( )
let actions = LocalPrefs.orderedActions(
(try? context.fetch(FetchDescriptor<Action>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
)
snapshot.actions = actions.map { action in
let todayValue = agg.todayValue(for: action, now: now)
let isCount = action.trackingType == .count
return WatchActionInfo(
id: action.uuid,
name: action.name,
symbolName: action.symbolName,
colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F",
isCount: isCount,
todayValue: todayValue,
isRunning: action.isRunning,
tickingBase: action.isRunning && !isCount ? now.addingTimeInterval(-todayValue) : nil,
tagIDs: action.sortedTags.map(\.uuid),
isFavorite: action.isFavorite
)
}
let goals = (try? context.fetch(FetchDescriptor<Goal>(
predicate: #Predicate { $0.statusRaw == "inProgress" },
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
))) ?? []
snapshot.goals = goals.map { goal in
WatchGoalInfo(
id: goal.uuid,
title: goal.title,
symbolName: goal.symbolName,
colorHex: goal.colorHex,
dayRatio: goal.combinedSpanRatio(.day, math: math, now: now),
weekRatio: goal.combinedSpanRatio(.week, math: math, now: now),
monthRatio: goal.combinedSpanRatio(.month, math: math, now: now),
quests: goal.sortedQuests.map { quest in
let progress = QuestProgress(quest: quest, math: math)
let day = progress.spanProgress(.day, now: now)
let week = progress.spanProgress(.week, now: now)
let month = progress.spanProgress(.month, now: now)
return WatchQuestInfo(
id: quest.uuid,
name: quest.targetName,
symbolName: quest.targetSymbol,
colorHex: quest.targetAction?.sortedTags.first?.colorHex
?? quest.targetTag?.colorHex ?? "#2F6B4F",
dayRatio: day.ratio,
weekRatio: week.ratio,
monthRatio: month.ratio,
isAtMost: quest.direction == .atMost,
dayAchieved: day.isAchieved,
weekAchieved: week.isAchieved,
monthAchieved: month.isAchieved
)
}
)
}
// ( )
let running = (try? context.fetch(FetchDescriptor<TimeSession>(
predicate: #Predicate { $0.endAt == nil },
sortBy: [SortDescriptor(\.startAt, order: .forward)]
))) ?? []
let mode = LiveActivityMode(
rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode)
?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
) ?? .latest
if let representative = mode == .earliest ? running.first : running.last,
let action = representative.action {
snapshot.runningName = action.name
snapshot.runningSymbol = action.symbolName
snapshot.runningStartAt = representative.startAt
snapshot.extraRunningCount = max(0, running.count - 1)
}
return snapshot
}
/// ( )
private var lastComplicationStateKey = ""
private var lastComplicationPushAt = Date.distantPast
/// ( )
private var pushTask: Task<Void, Never>?
/// ( ).
/// ××3 ,
/// ( )
/// .
func pushSnapshot() {
pushTask?.cancel()
pushTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
self.pushSnapshotNow()
}
}
private func pushSnapshotNow() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
let snapshot = makeSnapshot()
guard let data = snapshot.encoded() else { return }
// applicationContext:
try? session.updateApplicationContext([WatchSync.snapshotKey: data])
pushForComplicationIfNeeded(session: session, snapshot: snapshot, data: data)
}
/// .
/// 30
private func pushForComplicationIfNeeded(session: WCSession, snapshot: WatchSnapshot, data: Data) {
guard session.isComplicationEnabled else { return }
// ( )
//
let startStamp = snapshot.runningStartAt.map { Int($0.timeIntervalSince1970) } ?? 0
let stateKey = "\(snapshot.runningName ?? "")|\(startStamp)|\(snapshot.extraRunningCount)|\(snapshot.isPremium)"
let now = Date.now
guard stateKey != lastComplicationStateKey
|| now.timeIntervalSince(lastComplicationPushAt) > 30 * 60 else { return }
lastComplicationStateKey = stateKey
lastComplicationPushAt = now
session.transferCurrentComplicationUserInfo([WatchSync.snapshotKey: data])
}
// MARK:
///
fileprivate func handle(command: String?, actionID: String?) -> [String: Any] {
let context = DataStore.shared.mainContext
if command == WatchSync.runCommand,
let idString = actionID,
let id = UUID(uuidString: idString) {
if PremiumGate.isUnlocked(.watchApp) {
let actions = (try? context.fetch(FetchDescriptor<Action>())) ?? []
if let action = actions.first(where: { $0.uuid == id }) {
watchSyncLog.notice("run command: \(action.name, privacy: .public)")
ActionRunner.run(action, context: context)
try? context.save()
LiveActivityManager.sync(context: context)
WidgetCenter.shared.reloadAllTimelines()
// (DataChange.commit )
ControlCenter.shared.reloadAllControls()
} else {
watchSyncLog.error("run command: action not found \(idString, privacy: .public)")
}
} else {
watchSyncLog.error("run command ignored: watchApp premium locked")
}
}
guard let data = makeSnapshot().encoded() else { return [:] }
return [WatchSync.snapshotKey: data]
}
}
extension WatchSyncManager: WCSessionDelegate {
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: (any Error)?
) {
Task { @MainActor in
WatchSyncManager.shared.pushSnapshot()
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
/// / ( )
nonisolated func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
let command = message[WatchSync.commandKey] as? String
let actionID = message[WatchSync.actionIDKey] as? String
Task { @MainActor in
let reply = WatchSyncManager.shared.handle(command: command, actionID: actionID)
replyHandler(reply)
WatchSyncManager.shared.pushSnapshot()
}
}
/// sendMessage ( )
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
let command = userInfo[WatchSync.commandKey] as? String
let actionID = userInfo[WatchSync.actionIDKey] as? String
watchSyncLog.notice("didReceiveUserInfo command=\(command ?? "nil", privacy: .public)")
guard command == WatchSync.runCommand else { return }
//
if let sentAt = userInfo[WatchSync.sentAtKey] as? TimeInterval,
Date.now.timeIntervalSince1970 - sentAt > WatchSync.queuedRunMaxAge {
return
}
Task { @MainActor in
_ = WatchSyncManager.shared.handle(command: command, actionID: actionID)
WatchSyncManager.shared.pushSnapshot()
}
}
}