mycode/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
songyc macbook 7799998a2d fix(watch): 1.5.1 — 탭 이중 실행 방어(commandID)·미니 화면 접수 피드백 후 자동 메인 복귀
- 가끔 횟수 +2 실측: 응답 유실 시 큐 재전송이 같은 명령을 두 번 실행 —
  run 명령에 탭당 고유 commandID 동봉, iPhone이 최근 64개 기억해 중복 무시
- 컴플리케이션 미니 화면: 탭 → 체크 오버레이+눌림 축소(기존 진동과 함께)
  0.7초 → 자동 메인 복귀, 접수 중 재탭 무시 (놔둔 채 오탭 방지 — 사용자 요구)
- QA: -watchDetailAutoTap 신설, 시드 모드 미활성 세션 전송 가드
- 검증: 4종 빌드 그린, 워치 26.2·11.5 시각 QA, 페어링 E2E 실행 1회·중복 0
- MARKETING_VERSION 1.5.1(빌드 1), whats-new-1.5.1 3언어

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-09-02 14:12:03 +09:00

314 lines
15 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)]))) ?? []
)
// ' ' (1.5)
// 0 ( )
let complicationPeriods = LocalPrefs.watchActionPeriods()
snapshot.actions = actions.map { action in
let todayValue = agg.todayValue(for: action, now: now)
let isCount = action.trackingType == .count
var info = 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
)
if let raw = complicationPeriods[action.uuid],
let period = WatchActionPeriod(rawValue: raw) {
let range = Self.periodRange(period, math: math, now: now)
let value = isCount
? Double(agg.count(for: action, in: range))
: agg.seconds(for: action, in: range, now: now)
info.complicationPeriodRaw = raw
info.periodValue = value
if info.isRunning && !isCount {
info.periodTickingBase = now.addingTimeInterval(-value)
}
}
return info
}
// (· ) ' ' .
// (recommendations)· ,
// · ( , ) .
let goals = ((try? context.fetch(FetchDescriptor<Goal>(
predicate: #Predicate { $0.statusRaw == "inProgress" },
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
))) ?? [])
.filter { LocalPrefs.showsOnWatch($0.uuid) }
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,
// : (#E0558C) (1.5(8)
// : )
colorHex: quest.targetAction?.sortedTags.first?.colorHex
?? quest.targetTag?.colorHex
?? (quest.isHealthQuest
? (quest.healthColorHex.isEmpty ? "#E0558C" : quest.healthColorHex)
: "#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.runningActionID = action.uuid
snapshot.extraRunningCount = max(0, running.count - 1)
}
return snapshot
}
/// ' ' (1.5).
/// ( 7/30) ' ' (1.4) rollingRange
/// DayMath
private static func periodRange(_ period: WatchActionPeriod, math: DayMath, now: Date) -> Range<Date> {
switch period {
case .day: return math.dayRange(containing: now)
case .week: return math.weekRange(containing: now)
case .month: return math.monthRange(containing: now)
case .last7: return math.rollingRange(endingAtKey: math.dayKey(for: now), days: 7)
case .last30: return math.rollingRange(endingAtKey: math.dayKey(for: now), days: 30)
}
}
/// ( )
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:
/// ID (1.5.1) sendMessage
/// transferUserInfo ( 1 +2).
/// ID . (ID )
private var handledRunCommandIDs: [String] = []
private func isDuplicateRunCommand(_ commandID: String?) -> Bool {
guard let commandID else { return false }
if handledRunCommandIDs.contains(commandID) { return true }
handledRunCommandIDs.append(commandID)
if handledRunCommandIDs.count > 64 {
handledRunCommandIDs.removeFirst(handledRunCommandIDs.count - 64)
}
return false
}
///
fileprivate func handle(command: String?, actionID: String?, commandID: String?) -> [String: Any] {
let context = DataStore.shared.mainContext
if command == WatchSync.runCommand,
let idString = actionID,
let id = UUID(uuidString: idString) {
if isDuplicateRunCommand(commandID) {
watchSyncLog.notice("run command duplicate ignored: \(idString, privacy: .public)")
} else 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
let commandID = message[WatchSync.commandIDKey] as? String
Task { @MainActor in
let reply = WatchSyncManager.shared.handle(
command: command, actionID: actionID, commandID: commandID
)
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
let commandID = userInfo[WatchSync.commandIDKey] 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, commandID: commandID
)
WatchSyncManager.shared.pushSnapshot()
}
}
}