polish(keging): 전면 자가 점검 — 클린룸 빌드 검증·유령 DI 정리·진동 체인 취소·경고 0

- '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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
This commit is contained in:
songyc macbook 2026-08-29 23:30:14 +09:00
parent f5f8d3bde4
commit 3cee5f08af
6 changed files with 42 additions and 19 deletions

View File

@ -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. 남은 작업·보류

View File

@ -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() }
})
}
}

View File

@ -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) }
}
}
if gap > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + gap, execute: fire)
} else {
fire()
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)
}
}
}
}

View File

@ -16,6 +16,15 @@ final class LiveActivityManager {
private init() {}
/// Live Activity
/// ( )
func cleanupStaleActivities() {
for stale in Activity<KegingActivityAttributes>.activities {
Task { await stale.end(nil, dismissalPolicy: .immediate) }
}
activity = nil
}
/// ,
func sync(phase: KegelPhase, rep: Int, totalReps: Int, phaseEnd: Date) {
let state = KegingActivityAttributes.ContentState(

View File

@ -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 }

View File

@ -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()