fix(sync): receive CloudKit silent pushes so cross-device changes arrive while the app is open

다른 기기(아이폰↔아이패드)에서 측정을 시작/종료해도 사용 중인
기기에는 반영되지 않고, 모음 탭 새로고침 버튼을 눌러도 그대로이던
버그 수정. 앱을 나갔다 들어와야만 반영되던 증상의 원인.

원인: 푸시 엔타이틀먼트(aps-environment)는 있었지만
UIBackgroundModes에 remote-notification이 없어 CloudKit의
"데이터 바뀜" 무음 푸시가 앱에 전달되지 않았다. Core Data+CloudKit은
이 푸시로 가져오기(import)를 트리거하므로, 없으면 앱 실행/포그라운드
복귀 때만 가져온다. 새로고침 버튼은 로컬 스토어 재조회일 뿐이라
가져오기 전에는 옛 데이터만 보였다.

수정: iOS 타깃에 IOS/Info.plist 신설(UIBackgroundModes:
remote-notification)하고 INFOPLIST_FILE로 지정 — 생성 Info.plist
(GENERATE_INFOPLIST_FILE=YES)와 병합된다. IOS 동기화 그룹에
membershipException을 추가해 plist가 리소스로 중복 복사되지 않게 함.

검증: Debug·Store 빌드 성공, 두 빌드 산출물 Info.plist에
UIBackgroundModes=[remote-notification]과 기존 생성 키(표시 이름·
Live Activity) 병합 확인, 시뮬레이터 콜드 스타트 정상. 기기 간
동기화 도착은 실기기 2대에서 확인 필요.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-14 16:10:00 +09:00
parent 74dab79065
commit 7720ed5925
4 changed files with 33 additions and 2 deletions

View File

@ -16,7 +16,7 @@
| 번들 ID | `com.yechan.HaruDanim` / App Group `group.com.yechan.HaruDanim` / CloudKit `iCloud.com.yechan.HaruDanim` |
| 수익 모델 | 부분 유료화. 무료 한도(행동 10·목표 2·목표당 다짐 3) + 프리미엄 결제 시 해제 |
| 결제 상품 | `com.yechan.HaruDanim.premium.monthly`(월 5,000원) / `.yearly`(년 50,000원) / `.lifetime`(80,000원) — 임시 가격, App Store Connect 등록은 아직 안 됨 |
| 남은 배포 작업 | App Store Connect 상품 3종 등록(구독 그룹 + 비소모성, 유료 앱 계약), 실기기 검증(펜슬 필기감·캘린더 권한), App Store 배포. **코드 측 잔여 2건**: ① 결제 화면(PremiumView)에 개인정보처리방침·이용약관 링크(심사 3.1.2 필수 — URL 준비되면 추가), ② Info.plist `ITSAppUsesNonExemptEncryption=NO` 등록 |
| 남은 배포 작업 | App Store Connect 상품 3종 등록(구독 그룹 + 비소모성, 유료 앱 계약), 실기기 검증(펜슬 필기감·캘린더 권한), App Store 배포. **코드 측 잔여 2건**: ① 결제 화면(PremiumView)에 개인정보처리방침·이용약관 링크(심사 3.1.2 필수 — URL 준비되면 추가), ② `IOS/Info.plist`(파일 있음)에 `ITSAppUsesNonExemptEncryption=NO` 추가 |
---
@ -123,6 +123,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
- 스토어 파일: App Group 컨테이너의 `HaruDanim.store` (위젯·인텐트가 같은 DB 사용). 구 샌드박스 `default.store`는 1회 이관
- **CloudKit 미러링은 메인 앱 프로세스만**. 위젯 확장(.appex)은 같은 파일을 로컬 전용으로 열음. 확장의 쓰기는 메인 앱이 원격 변경 알림으로 받아 내보냄
- iCloud 동기화 = 프리미엄 + `settings.cloudSync` 토글 (앱 재시작 시 적용). 실패 시 로컬 폴백
- **원격 변경 가져오기(import)는 무음 푸시가 트리거**`IOS/Info.plist``UIBackgroundModes: remote-notification`(+`aps-environment` 엔타이틀먼트) 필수. 이 모드가 없으면 앱 실행/포그라운드 복귀 때만 가져와서, 다른 기기의 변경이 사용 중에는 절대 안 보이고 새로고침 버튼도 무용지물이 된다(수정된 버그). 가져오기를 코드로 강제하는 공식 API는 없음. iOS 타깃 Info.plist는 `GENERATE_INFOPLIST_FILE=YES` + `INFOPLIST_FILE=IOS/Info.plist` 병합 방식(파일에는 생성 설정에 없는 키만 넣음, IOS 동기화 그룹의 membershipException으로 리소스 복사 제외)
- 컨테이너 생성 실패 시: 짧은 재시도 3회 → 스토어를 `.backup-<ts>`로 보존 후 새로 시작 (fatalError로 즉사하지 않음)
- `ensureUniqueEntityIDs`: uuid 기본값 마이그레이션 중복 보정 (앱 시작 1회)
@ -135,7 +136,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
### 5.3 변경 전파
- 앱 내 모든 mutation 끝에 **`DataChange.commit(context:)`**: save → LiveActivity sync(장시간 측정 알림 예약 동기화 포함) → `WidgetCenter.reloadAllTimelines()` + `ControlCenter.reloadAllControls()` → 워치 스냅숏 push(350ms 디바운스 — 연타 시 마지막 상태만 집계·전송). 위젯/시리 경로는 `IntentStore.performWrite`가 같은 역할(한 컨텍스트로 조회·수정·저장 원자 처리 + 저장 1회 재시도)
- `CloudSyncMonitor`: NSPersistentStoreRemoteChange를 2초 디바운스로 받아 위젯·워치·LiveActivity 갱신 (화면은 건드리지 않음)
- `AppRefresh`(모음 탭 새로고침 버튼): 루트 뷰 `.id(token)` 리셋으로 모든 @Query 재조회 — 다른 기기 변경이 화면에 안 보일 때의 수동 해결책
- `AppRefresh`(모음 탭 새로고침 버튼): 루트 뷰 `.id(token)` 리셋으로 모든 @Query 재조회 — 다른 기기 변경이 화면에 안 보일 때의 수동 해결책. **로컬 스토어에 이미 도착한 변경만 반영** — 가져오기 자체는 무음 푸시가 담당(§5.1)
---

View File

@ -111,6 +111,13 @@
);
target = 725A9E832FFCC65000846FFB /* Haru_Danim */;
};
AA0000000000000000000013 /* Exceptions for "IOS" folder in "Haru_Danim" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 725A9E832FFCC65000846FFB /* Haru_Danim */;
};
AA0000000000000000000012 /* Exceptions for "Haru_DanimWatchWidgets" folder in "Haru_DanimWatch Watch App" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@ -127,6 +134,9 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
725A9E862FFCC65000846FFB /* IOS */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
AA0000000000000000000013 /* Exceptions for "IOS" folder in "Haru_Danim" target */,
);
path = IOS;
sourceTree = "<group>";
};
@ -599,6 +609,7 @@
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IOS/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "하루 다님";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "일기 타임테이블에 캘린더 일정을 함께 표시하기 위해 필요해요.";
@ -640,6 +651,7 @@
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IOS/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "하루 다님";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "일기 타임테이블에 캘린더 일정을 함께 표시하기 위해 필요해요.";

View File

@ -8,6 +8,10 @@
// ( , ).
// - (CloudSyncMonitor) ·· .
// .
// " " , CloudKit
// API . (UIBackgroundModes:
// remote-notification, IOS/Info.plist)
// / ( ).
//
import Foundation

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- CloudKit 무음 푸시 수신용 — 다른 기기의 변경을 즉시 가져오기(import) 위해 필수.
이 모드가 없으면 앱 실행/포그라운드 복귀 때만 가져오기가 일어나
iPad·iPhone 간 측정 시작/종료가 화면·새로고침에 반영되지 않았다.
나머지 Info.plist 키는 GENERATE_INFOPLIST_FILE=YES가 생성해 이 파일과 병합한다. -->
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>