// // WatchHeartRateBridge.swift // Keging // // 아이폰에서 시작한 세트의 워치 심박 측정 (iOS 측, §3.8). // 시작 버튼 → HKHealthStore.startWatchApp으로 워치 앱을 깨워 워크아웃 세션을 열게 하고, // 워치가 세션을 폰으로 미러링(watchOS 10+)하면 그 채널로 심박을 받고 완주/중단 명령을 보낸다. // 워치를 못 열면(미착용·미페어링·앱 미설치·멀리 있음) 심박 없이 폰 단독 기록으로 폴백하고, // 설정이 켜져 있으면 운동 화면이 경고를 띄운다. 진동은 폰에서만 울린다(워치는 센서 역할만). // 건강 저장은 워치 라이브 빌더가 맡고(심박·활성 에너지 실측), 결과 회신이 없으면 폰이 사후 저장. // import Combine import HealthKit import OSLog @MainActor final class WatchHeartRateBridge: NSObject, ObservableObject { static let shared = WatchHeartRateBridge() enum State: Equatable { case idle /// startWatchApp 호출 뒤 미러링 세션 도착 대기 case launching /// 워치 세션 연결됨 — 심박 수신 중 case measuring /// 워치를 못 열었거나 끊김 — 폰 단독 case unavailable /// 완주 명령 전송, 워치 저장 결과 대기 case finishing } /// 실기기 진단용 (Console.app에서 subsystem com.yechan.Keging, category watchHR) private let log = Logger(subsystem: "com.yechan.Keging", category: "watchHR") @Published private(set) var state: State = .idle { didSet { if state != oldValue { log.notice("state \(String(describing: oldValue)) → \(String(describing: self.state))") } } } @Published private(set) var heartRate: Double? /// 워치 앱을 열지 못함 — 운동 화면이 (설정 app.watchWarn에 따라) 경고 알럿으로 소비 @Published var launchFailed = false private let store = HKHealthStore() private var session: HKWorkoutSession? private var launchTimeout: Task? private var saveTimeout: Task? private var fallbackSave: (() -> Void)? /// 세트마다 증가 — 늦게 도착한 콜백이 다음 세트를 건드리지 않게 private var generation = 0 private override init() { super.init() } /// 앱 시작 시 1회 — 워치가 미러링을 시작하면 시스템이 이 핸들러로 세션을 넘긴다 func activate() { store.workoutSessionMirroringStartHandler = { [weak self] session in Task { @MainActor [weak self] in self?.attach(session) } } } /// 운동 시작 직후 — 워치 앱 깨우기 (비동기, 타이머 진행에 영향 없음) func begin(config: WorkoutConfig) { reset() generation += 1 let gen = generation // 권한 시트에 오래 답하느라 세트가 이미 끝났으면 깨우지 않는다 guard KegelEngine.shared.isRunning else { return } #if DEBUG // 화면 QA·스토어 스크린샷용 — 워치 없이 '심박 측정 중' 상태를 흉내 낸다 (§7 `-mockWatchHeartRate 78`) if let index = CommandLine.arguments.firstIndex(of: "-mockWatchHeartRate"), index + 1 < CommandLine.arguments.count, let bpm = Double(CommandLine.arguments[index + 1]) { state = .measuring heartRate = bpm return } #endif guard HKHealthStore.isHealthDataAvailable() else { state = .unavailable return } state = .launching let configuration = HKWorkoutConfiguration() configuration.activityType = config.healthWorkoutType.activityType configuration.locationType = .unknown store.startWatchApp(with: configuration) { [weak self] success, error in Task { @MainActor [weak self] in guard let self, self.generation == gen, self.state == .launching else { return } self.log.notice("startWatchApp success=\(success) error=\(error.map { String(describing: $0) } ?? "nil")") if success { self.armLaunchTimeout(gen) } else { self.markLaunchFailed() } } } } /// 세트 완주 — 워치가 저장하게 하고, 결과가 안 오면 폴백(폰 사후 저장) func finish(effortScore: Int, fallback: @escaping () -> Void) { guard state == .measuring, let session else { if state == .launching { PhoneSync.shared.sendMirrorDiscard() } reset() fallback() return } state = .finishing fallbackSave = fallback send(.finish(effortScore: effortScore), via: session) let gen = generation saveTimeout = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(15)) guard !Task.isCancelled, let self, self.generation == gen else { return } self.runFallback() } } /// 세트 중단 — 워치 워크아웃 폐기 (미러링이 아직 안 붙었으면 WatchConnectivity로) func discard() { if let session, state == .measuring { send(.discard, via: session) } else if state == .launching { PhoneSync.shared.sendMirrorDiscard() } reset() } /// WatchConnectivity로 온 워치 저장 결과 (PhoneSync) func handleSaveResult(saved: Bool) { log.notice("watch save result saved=\(saved) (state \(String(describing: self.state)))") guard state == .finishing else { return } if saved { fallbackSave = nil reset() } else { runFallback() } } // MARK: 내부 private func attach(_ session: HKWorkoutSession) { log.notice("mirrored session arrived (state \(String(describing: self.state)), running \(KegelEngine.shared.isRunning))") launchTimeout?.cancel() launchTimeout = nil // 세트가 이미 끝났거나 폰 단독으로 넘어간 뒤 늦게 도착 — 폐기시킨다 guard state == .launching, KegelEngine.shared.isRunning else { send(.discard, via: session) return } session.delegate = self self.session = session state = .measuring } private func armLaunchTimeout(_ gen: Int) { launchTimeout?.cancel() launchTimeout = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(30)) guard !Task.isCancelled, let self, self.generation == gen, self.state == .launching else { return } self.markLaunchFailed() } } private func markLaunchFailed() { state = .unavailable if KegelEngine.shared.isRunning { launchFailed = true } // 워치가 세션을 열었는데 미러링만 안 붙은 경우를 대비해 WatchConnectivity로 정리 명령 PhoneSync.shared.sendMirrorDiscard() } private func runFallback() { log.notice("fallback: phone saves workout itself") let fallback = fallbackSave fallbackSave = nil reset() fallback?() } private func send(_ message: WatchBridgeMessage, via session: HKWorkoutSession) { guard let data = message.encoded() else { return } session.sendToRemoteWorkoutSession(data: data) { _, _ in } } private func reset() { launchTimeout?.cancel() launchTimeout = nil saveTimeout?.cancel() saveTimeout = nil session?.delegate = nil session = nil heartRate = nil state = .idle } /// 워치 세션이 끝나거나 끊김 — 측정 중이었으면 폰 단독으로 전환(세트는 계속), /// 완주 명령 뒤라면 워치가 세션을 닫는 것이 정상이라 저장 결과(WatchConnectivity)를 계속 기다린다 private func remoteEnded() { guard state == .measuring else { return } state = .unavailable heartRate = nil session?.delegate = nil session = nil } } extension WatchHeartRateBridge: HKWorkoutSessionDelegate { nonisolated func workoutSession( _ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date ) { guard toState == .ended || toState == .stopped else { return } Task { @MainActor in self.remoteEnded() } } nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { Task { @MainActor in self.remoteEnded() } } nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didDisconnectFromRemoteDeviceWithError error: Error?) { Task { @MainActor in self.remoteEnded() } } nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didReceiveDataFromRemoteWorkoutSession data: [Data]) { let messages = data.compactMap(WatchBridgeMessage.decode) Task { @MainActor in for message in messages { if case let .heartRate(bpm) = message { if self.heartRate == nil { self.log.notice("first heart rate \(bpm)") } self.heartRate = bpm } } } } }