// // 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( 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(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( 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( 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? /// 최신 스냅숏을 워치로 전송 (데이터 변경 시마다 호출). /// 스냅숏 생성은 모든 목표×다짐×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())) ?? [] 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() } } }