From 3cee5f08af3f9e8bf91ab1aed68cb13b03d23900 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 29 Aug 2026 23:30:14 +0900 Subject: [PATCH] =?UTF-8?q?polish(keging):=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EC=A0=90=EA=B2=80=20=E2=80=94=20=ED=81=B4?= =?UTF-8?q?=EB=A6=B0=EB=A3=B8=20=EB=B9=8C=EB=93=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=C2=B7=EC=9C=A0=EB=A0=B9=20DI=20=EC=A0=95=EB=A6=AC=C2=B7?= =?UTF-8?q?=EC=A7=84=EB=8F=99=20=EC=B2=B4=EC=9D=B8=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=C2=B7=EA=B2=BD=EA=B3=A0=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'cannot find in scope' 판정: 새 DerivedData 클린룸 빌드 3종(Debug/Release/워치) 전부 그린 → SourceKit 편집기 오탐 확정 (실제 컴파일 에러 0) - 강제 종료 유령 Live Activity 정리: 운동 중 kill 시 DI가 몇 시간 잔존(시뮬 재현) → 앱 시작 시 cleanupStaleActivities()로 즉시 소멸(실측 확인) - 진동 체인 세대 토큰: 짧은 수축·이완에서 이전 패턴 체인이 다음 국면 첫 진동을 삼키는 엣지 방지, Haptics를 Task 기반으로 재작성해 Swift 6 경고 3건 전부 제거 - 워치에도 -noHealth QA 인자 추가 - 시뮬 실측: DI 백그라운드 갱신(수축 그린→이완 앰버)·포그라운드 복귀 벽시계 재동기화 (5/20 정확 착지)·유령 DI 정리·주간 통계 검산(11세트/220회)·완주 경로·워치 실행· 18.5 빌드 그린·전 카탈로그 missing/stale/ASCII~ 0 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT --- myApp/Keging/CLAUDE.md | 8 +++--- myApp/Keging/IOS/BackgroundAudioKeeper.swift | 8 +++--- myApp/Keging/IOS/Haptics.swift | 27 ++++++++++--------- myApp/Keging/IOS/LiveActivityManager.swift | 9 +++++++ myApp/Keging/IOS/WorkoutCoordinator.swift | 2 ++ .../WatchWorkoutSession.swift | 7 +++++ 6 files changed, 42 insertions(+), 19 deletions(-) diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index da7000a..ad320cf 100644 --- a/myApp/Keging/CLAUDE.md +++ b/myApp/Keging/CLAUDE.md @@ -20,7 +20,7 @@ | 최소 OS | iOS 18.0 / watchOS 10.0 | | 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit 없음. HealthKit은 **쓰기 전용**(운동 기록 저장 — §3.7) | | 언어 | **한국어(원문)/영어/일본어** — String Catalog(타깃별 `Localizable.xcstrings`, InfoPlist 포함). 설정 → 언어(시스템/한국어/English/日本語)에서 앱 내 전환: `AppleLanguages` 오버라이드, **완전 종료 후 재실행 시 적용**(하루 다님 검증 방식). 워치·Live Activity·단축어 표기는 각자의 시스템 언어를 따름 | -| 현재 상태 (2026-08-29) | **1.0 전 기능 구현 + 실기기(아이폰) 1차 피드백 3건 수정, 재테스트 대기.** 1차 발견·수정: ①다이나믹 아일랜드 백그라운드 멈춤 → 오디오 키퍼 하드닝 + 엔진 벽시계 재설계(§3.1·§3.4) ②진동 패턴 동일 현상 → 완료 체이닝 재작성(§3.3) ③운동 칼로리 0 → 에너지 샘플 직접 추가(§3.7). Debug·Release·워치 빌드 그린, 카탈로그 0/0, 완주 경로 시뮬 재검증. **재테스트 항목(사용자)**: DI 백그라운드 갱신, 진동 4종 구분, 칼로리 표기(+재권한 시트에 '활성 에너지' 추가됨 — 허용 필요), 워치(권한에 심박·에너지 읽기 추가) | +| 현재 상태 (2026-08-29 심야) | **1.0 전 기능 구현 + 실기기 1차 피드백 3건 수정 + 전면 자가 점검 완료, 아침 재빌드 대기.** 1차 수정: ①DI 백그라운드 멈춤 → 오디오 키퍼 하드닝+엔진 벽시계 재설계 ②진동 패턴 동일 → 완료 체이닝 ③칼로리 0 → 에너지 샘플 추가. 자가 점검(심야): **클린룸(새 DerivedData) 3종 빌드 그린·경고 0** — 사용자가 본 "cannot find in scope"는 SourceKit 편집기 오탐으로 확정(§2.2). 추가 수정 2건: 진동 체인 세대 취소, **강제 종료 유령 DI 정리**(cleanupStaleActivities — 시뮬 재현·정리 실측). 시뮬 실측: DI 백그라운드 갱신(수축→이완 색 변화)·포그라운드 복귀 재동기화(5/20 정확 착지)·통계 검산(11세트/220회)·완주·워치 벽시계 동작·18.5 빌드·l10n 0/0/0. **재테스트 항목(사용자, 아이폰)**: DI 백그라운드 갱신, 진동 4종 구분, 칼로리(재권한 시트 '활성 에너지' 허용 필요). **워치 실기기는 TestFlight 1.0에서**(사용자 결정 — 로컬 워치 배포가 네트워크 문제로 실패, 시뮬 검증은 완료) | ## 2. 빌드·검증 환경 @@ -122,12 +122,12 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ### 3.3 진동 (`Shared/WorkoutSettings.swift`, `IOS/Haptics.swift`, 워치 `WatchHaptics.swift`) - 패턴 4종 — **횟수 세기가 아니라 첫 순간의 질감으로 구분**(1.5(사용자 피드백)에서 횟수 기반 4종 교체): 톡(짧게 1방) / 톡톡(0.5s 간격 2방) / 드르륵(0.5s 간격 3연타) / 지잉(0.3s 겹침 3방=끊기지 않는 긴 진동). 기본: **수축=지잉(조이고 유지), 이완=톡(탁 풀기)**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(once/twice/thrice/heartbeat)은 `HapticPattern(migrating:)`으로 이관 -- iOS는 `AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate)` **완료 체이닝** — ⚠️**재생 중 같은 시스템 사운드 재호출은 무시된다**(실기기 실측: 시간 오프셋 방식은 겹침·짧은 간격이 전부 먹혀 톡=톡톡이 됐음). 완료 후 쉼: 톡=1방, 톡톡=0.25초 쉬고 1방, 드르륵=0.15초 간격 3연타, 지잉=즉시 이어붙인 3방. 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다** +- iOS는 `AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate)` **완료 체이닝** — ⚠️**재생 중 같은 시스템 사운드 재호출은 무시된다**(실기기 실측: 시간 오프셋 방식은 겹침·짧은 간격이 전부 먹혀 톡=톡톡이 됐음). 완료 후 쉼: 톡=1방, 톡톡=0.25초 쉬고 1방, 드르륵=0.15초 간격 3연타, 지잉=즉시 이어붙인 3방. **세대 토큰으로 새 패턴 시작 시 이전 체인 취소**(짧은 수축·이완에서 이전 체인이 다음 국면 첫 진동을 삼키는 것 방지). 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다** - 워치는 반복 재생이 뭉개져서 **패턴마다 성격이 다른 시스템 햅틱 1회**로 매핑: 톡=.start, 톡톡=.directionUp, 드르륵=.retry, 지잉=.notification. **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회) ### 3.4 백그라운드·Live Activity (`IOS/BackgroundAudioKeeper.swift`, `LiveActivityManager.swift`, `KegingWidgets/`) - 운동 시작 시 무음 WAV 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`, **volume 0.1**·prepareToPlay — 0.0은 백그라운드 유지가 풀리는 사례 대비) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. **인터럽션 종료·백그라운드 전환·활성화 때 재생 죽었으면 revive()** 옵저버. 그래도 정지되면 엔진 벽시계 재동기화(§3.1)가 복귀 시 교정. 종료·중단 시 즉시 해제. 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원) -- Live Activity: 국면 전환마다 update — 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너. 종료·중단 시 `.immediate` dismiss. 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색 +- Live Activity: 국면 전환마다 update — 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너. 종료·중단 시 `.immediate` dismiss. **앱 시작 시 `cleanupStaleActivities()`** — 운동 중 강제 종료로 남은 유령 DI를 정리(시뮬 실측: kill 후 몇 시간 잔존하던 것 → 재실행 즉시 소멸 확인). 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색 ### 3.5 단축어 (`IOS/KegingIntents.swift`) - `StartKegelIntent` 하나 — 앱을 열고(`openAppWhenRun`) 저장된 세팅대로 즉시 시작. 콜드 스타트 경합은 AppLaunchState 플래그 + 알림 이중 경로로 처리 @@ -185,7 +185,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] | `-autoStart` | 시작하자마자 운동 시작 (아이폰·워치 공통) | | `-quickConfig` | 세팅을 수축1초·이완1초·2회로 (완주 경로 검증) | | `-resetConfig` | 세팅을 기본값(10초·5초·20회)으로 복원 | -| `-noHealth` | 건강 권한 요청 생략 — 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 | +| `-noHealth` | 건강 권한 요청 생략(아이폰·워치 공통) — 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 | | `-AppleLanguages "(en)"` | (시스템 표준 인자) 그 실행만 언어 강제 — en/ja 렌더 QA용, 워치도 동일 | ## 8. 남은 작업·보류 diff --git a/myApp/Keging/IOS/BackgroundAudioKeeper.swift b/myApp/Keging/IOS/BackgroundAudioKeeper.swift index 124399a..467e99c 100644 --- a/myApp/Keging/IOS/BackgroundAudioKeeper.swift +++ b/myApp/Keging/IOS/BackgroundAudioKeeper.swift @@ -53,14 +53,16 @@ final class BackgroundAudioKeeper { observers.append(center.addObserver( forName: AVAudioSession.interruptionNotification, object: nil, queue: .main ) { [weak self] note in - guard let info = note.userInfo, + guard let self, + let info = note.userInfo, let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt, AVAudioSession.InterruptionType(rawValue: rawType) == .ended else { return } - Task { @MainActor in self?.revive() } + Task { @MainActor in self.revive() } }) for name in [UIApplication.didEnterBackgroundNotification, UIApplication.didBecomeActiveNotification] { observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in - Task { @MainActor in self?.revive() } + guard let self else { return } + Task { @MainActor in self.revive() } }) } } diff --git a/myApp/Keging/IOS/Haptics.swift b/myApp/Keging/IOS/Haptics.swift index 5a9f8d2..7d97269 100644 --- a/myApp/Keging/IOS/Haptics.swift +++ b/myApp/Keging/IOS/Haptics.swift @@ -11,7 +11,12 @@ import AudioToolbox import Foundation -nonisolated enum Haptics { +@MainActor +enum Haptics { + /// 진행 중 체인 취소용 세대 토큰 — 새 패턴이 시작되면 이전 체인의 남은 진동을 버린다 + /// (수축·이완이 짧을 때 이전 체인이 다음 국면의 첫 진동을 삼키는 것 방지) + private static var generation = 0 + static func play(_ pattern: HapticPattern) { // 각 진동 앞의 쉼(초) — 첫 진동은 즉시 let gaps: [TimeInterval] @@ -21,21 +26,19 @@ nonisolated enum Haptics { case .rattle: gaps = [0, 0.15, 0.15] // 드르륵: 짧은 쉼으로 3연타 case .long: gaps = [0, 0, 0] // 지잉: 완료 즉시 이어 붙여 긴 진동감 } - playChain(gaps) + generation += 1 + playChain(gaps, generation: generation) } - private static func playChain(_ gaps: [TimeInterval]) { - guard let gap = gaps.first else { return } + private static func playChain(_ gaps: [TimeInterval], generation: Int) { + guard generation == Self.generation, let gap = gaps.first else { return } let rest = Array(gaps.dropFirst()) - let fire = { - AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) { - DispatchQueue.main.async { playChain(rest) } + Task { @MainActor in + if gap > 0 { try? await Task.sleep(for: .seconds(gap)) } + guard generation == Self.generation else { return } + AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) { @MainActor @Sendable in + playChain(rest, generation: generation) } } - if gap > 0 { - DispatchQueue.main.asyncAfter(deadline: .now() + gap, execute: fire) - } else { - fire() - } } } diff --git a/myApp/Keging/IOS/LiveActivityManager.swift b/myApp/Keging/IOS/LiveActivityManager.swift index df641fb..6477e92 100644 --- a/myApp/Keging/IOS/LiveActivityManager.swift +++ b/myApp/Keging/IOS/LiveActivityManager.swift @@ -16,6 +16,15 @@ final class LiveActivityManager { private init() {} + /// 앱 시작 시 호출 — 강제 종료 등으로 남은 이전 Live Activity를 정리한다 + /// (안 하면 운동 중 강제 종료 시 다이나믹 아일랜드가 몇 시간 동안 유령으로 남음) + func cleanupStaleActivities() { + for stale in Activity.activities { + Task { await stale.end(nil, dismissalPolicy: .immediate) } + } + activity = nil + } + /// 없으면 시작, 있으면 갱신 — 국면 전환마다 이 하나만 호출 func sync(phase: KegelPhase, rep: Int, totalReps: Int, phaseEnd: Date) { let state = KegingActivityAttributes.ContentState( diff --git a/myApp/Keging/IOS/WorkoutCoordinator.swift b/myApp/Keging/IOS/WorkoutCoordinator.swift index 01972e5..ee01726 100644 --- a/myApp/Keging/IOS/WorkoutCoordinator.swift +++ b/myApp/Keging/IOS/WorkoutCoordinator.swift @@ -19,6 +19,8 @@ final class WorkoutCoordinator { guard !wired else { return } wired = true + LiveActivityManager.shared.cleanupStaleActivities() + let engine = KegelEngine.shared engine.onPhaseChange = { [weak engine] phase, rep, phaseEnd in guard let engine else { return } diff --git a/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift b/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift index 6f541bb..3293fc6 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift @@ -38,6 +38,13 @@ final class WatchWorkoutSession: NSObject { /// 운동 시작 직후 호출 — 권한 요청·세션 시작은 비동기라 타이머 진행에 영향 없음 func begin(startDate: Date) { + #if DEBUG + // 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 + if CommandLine.arguments.contains("-noHealth") { + RuntimeSessionManager.shared.start() + return + } + #endif Task { @MainActor in guard HKHealthStore.isHealthDataAvailable() else { RuntimeSessionManager.shared.start()