diff --git a/.github/workflows/develop-build.yml b/.github/workflows/develop-build.yml index 99304163..a74d406f 100644 --- a/.github/workflows/develop-build.yml +++ b/.github/workflows/develop-build.yml @@ -25,7 +25,7 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '26.1.1' + xcode-version: '26.5' - uses: actions/checkout@v4 with: @@ -61,3 +61,13 @@ jobs: -destination generic/platform="iOS Simulator" \ -configuration Debug \ build + + - name: Run iCloud Reset Regression Tests + run: | + xcodebuild \ + -workspace Animal-Crossing-Wiki.xcworkspace \ + -scheme ACNH-wiki \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -configuration Debug \ + -only-testing:ACNH-wikiTests/CoreDataStorageICloudResetTests \ + test diff --git a/.swiftlint.yml b/.swiftlint.yml index 0507e148..6b7354d7 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -8,11 +8,11 @@ excluded: # 린트 과정에서 무시할 파일 경로. `included`보다 우선 - Tuist - Workspace.swift - Animal-Crossing-Wiki/Projects/*/Project.swift + - Animal-Crossing-Wiki/Projects/App/build identifier_name: min_length: - 2 # only min_length - error: 4 # only error excluded: # 제외할 문자열 목록 사용 - a - b diff --git a/Animal-Crossing-Wiki/Configurations/Base.xcconfig b/Animal-Crossing-Wiki/Configurations/Base.xcconfig index d1889fe6..44d21b41 100644 --- a/Animal-Crossing-Wiki/Configurations/Base.xcconfig +++ b/Animal-Crossing-Wiki/Configurations/Base.xcconfig @@ -4,7 +4,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor CODE_SIGN_IDENTITY = iPhone Developer ENABLE_PREVIEWS = YES -EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64 FRAMEWORK_SEARCH_PATHS = $(inherited) GCC_GENERATE_DEBUGGING_SYMBOLS = YES HEADER_SEARCH_PATHS = $(inherited) diff --git a/Animal-Crossing-Wiki/Projects/App/Project.swift b/Animal-Crossing-Wiki/Projects/App/Project.swift index d327d023..55c9d268 100644 --- a/Animal-Crossing-Wiki/Projects/App/Project.swift +++ b/Animal-Crossing-Wiki/Projects/App/Project.swift @@ -53,7 +53,10 @@ let schemes: [Scheme] = [ name: "ACNH-wiki", shared: true, buildAction: .buildAction(targets: ["ACNH-wiki"]), - testAction: nil, + testAction: .targets( + ["ACNH-wikiTests"], + arguments: .arguments(environmentVariables: ["IS_UNIT_TESTING": "1"]) + ), runAction: .runAction( configuration: .debug, executable: "ACNH-wiki", @@ -93,6 +96,19 @@ let project = Project( coreDataModels: [ CoreDataModel.coreDataModel("CoreDataModels/CoreDataStorage.xcdatamodeld") ] + ), + .target( + name: "ACNH-wikiTests", + destinations: .iOS, + product: .unitTests, + bundleId: "leeari.NookPortalPlusTests", + deploymentTargets: .iOS("16.0"), + infoPlist: .default, + sources: ["Tests/**"], + dependencies: [ + .target(name: "ACNH-wiki") + ], + settings: settings ) ], schemes: schemes, diff --git a/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings b/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings index d4558c83..69538471 100644 --- a/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings +++ b/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings @@ -98,6 +98,7 @@ Find the villagers you have visited and tap the home icon on the villager's page "iCloud is temporarily unavailable. Data will sync when available." = "iCloud is temporarily unavailable. Data will sync when available."; "iCloud status could not be determined. Data will be saved locally only." = "iCloud status could not be determined. Data will be saved locally only."; "iCloud storage is full. Please free up space to continue syncing." = "iCloud storage is full. Please free up space to continue syncing."; +"iCloud sync keeps failing. Creating new data is paused to protect your existing iCloud data. Please check your iCloud sign-in and storage." = "iCloud sync keeps failing. Creating new data is paused to protect your existing iCloud data. Please check your iCloud sign-in and storage."; // MARK: - TaskEditViewController "Today's Tasks" = "Today's Tasks"; diff --git a/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings b/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings index 0d1b9403..d46112fd 100644 --- a/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings +++ b/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings @@ -100,6 +100,7 @@ "iCloud is temporarily unavailable. Data will sync when available." = "iCloud를 일시적으로 사용할 수 없습니다. 사용 가능해지면 동기화됩니다."; "iCloud status could not be determined. Data will be saved locally only." = "iCloud 상태를 확인할 수 없습니다. 데이터는 기기에만 저장됩니다."; "iCloud storage is full. Please free up space to continue syncing." = "iCloud 저장 공간이 가득 찼습니다. 동기화를 계속하려면 공간을 확보하세요."; +"iCloud sync keeps failing. Creating new data is paused to protect your existing iCloud data. Please check your iCloud sign-in and storage." = "iCloud 동기화가 계속 실패하고 있습니다. 기존 iCloud 데이터를 보호하기 위해 새 데이터 생성을 보류하고 있습니다. iCloud 로그인 상태와 저장 공간을 확인해 주세요."; // MARK: - TaskEditViewController "Today's Tasks" = "오늘의 할일"; diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/AppDelegate.swift b/Animal-Crossing-Wiki/Projects/App/Sources/AppDelegate.swift index fc543e2c..e333973b 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/AppDelegate.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/AppDelegate.swift @@ -18,6 +18,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AppAppearance.setUpAppearance() + if AppEnvironment.isUnitTesting { + return true + } + FirebaseApp.configure() Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage+Recovery.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage+Recovery.swift new file mode 100644 index 00000000..7effbb41 --- /dev/null +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage+Recovery.swift @@ -0,0 +1,95 @@ +// +// CoreDataStorage+Recovery.swift +// Animal-Crossing-Wiki +// +// Created by Ari on 2026/06/11. +// + +import Foundation +import CoreData +import os + +// MARK: - Data Recovery + +extension CoreDataStorage { + + /// 사용자가 설정에서 "iCloud에서 복원"을 명시적으로 눌렀을 때만 실행되는 복원 플로우. + /// 로컬 store를 삭제하고 앱 재시작 시 CloudKit에서 전체 re-import 유도. + /// 로컬 데이터가 iCloud 백업으로 완전히 대체되므로 파괴적 동작 — 2단 확인 alert 후에만 호출. + enum RecoveryError: LocalizedError { + case iCloudNotAvailable + case storeNotFound + + var errorDescription: String? { + switch self { + case .iCloudNotAvailable: return "iCloud is not available" + case .storeNotFound: return "CoreData store not found" + } + } + } + + /// 로컬 store 파일을 삭제하고 앱 재시작 시 CloudKit에서 전체 re-import 유도. + /// store를 런타임에 재등록하면 CloudKit 옵션이 누락되므로, 파일만 삭제하고 재시작을 안내한다. + func performCloudKitRecovery(completion: @escaping (Result) -> Void) { + checkiCloudAccountStatus { [weak self] status in + guard status == .available else { + completion(.failure(RecoveryError.iCloudNotAvailable)) + return + } + guard let self else { + return + } + + guard let storeDescription = self.persistentContainer.persistentStoreDescriptions.first, + let storeURL = storeDescription.url else { + completion(.failure(RecoveryError.storeNotFound)) + return + } + + do { + // 기존 store 분리 + let coordinator = self.persistentContainer.persistentStoreCoordinator + if let store = coordinator.persistentStore(for: storeURL) { + try coordinator.remove(store) + } + + // Store 파일 삭제 — fileExists 대신 직접 시도 + 부재 에러 무시 (TOCTOU 방지) + let fileManager = FileManager.default + let storePath = storeURL.path + for suffix in ["", "-shm", "-wal"] { + do { + try fileManager.removeItem(atPath: storePath + suffix) + } catch let error as NSError where error.code == NSFileNoSuchFileError { + // 파일이 이미 없음 — 정상 + } + } + + // ckAssets 폴더 삭제 + let ckAssetsURL = storeURL.deletingLastPathComponent() + .appendingPathComponent("ckAssets") + do { + try fileManager.removeItem(at: ckAssetsURL) + } catch let error as NSError where error.code == NSFileNoSuchFileError { + // 폴더가 이미 없음 — 정상 + } + + // migration flag 유지 — 재시작 시 re-export 중복 방지 + UserDefaults.standard.set(true, forKey: "didMigrateExistingDataToCloudKit_v2") + + // 기존 유저 플래그 유지 — 재시작 후 CloudKit re-import 전까지 빈 UC 생성 방지 + // (복구 = 기존 유저이므로 true 유지가 올바름) + + // Recovery grace period 시작 — 재시작 후 import 지연/실패 상태를 UI와 진단 로그에서 + // 구분하기 위한 표시용 플래그. 빈 UC 생성을 허용하지는 않는다. + self.markRecoveryInitiated() + + Log.warning("recovery: local store wiped, awaiting restart + CloudKit re-import") + Log.event(.recoveryTriggered) + completion(.success(())) + } catch { + os_log(.error, log: .default, "🔄 Recovery failed: %{public}@", error.localizedDescription) + completion(.failure(error)) + } + } + } +} diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift index 88da826c..7dd58b6d 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift @@ -64,6 +64,14 @@ final class CoreDataStorage { set { _isSyncResetInProgress.withLock { $0 = newValue } } } + /// Fresh install에서 CloudKit 첫 import 대기가 timeout된 상태. + /// timeout은 "원격 데이터 없음"이 아니라 "아직 모름"이므로 빈 UC 생성을 막는다. + private let _isFirstImportTimedOut = OSAllocatedUnfairLock(initialState: false) + private(set) var isFirstImportTimedOut: Bool { + get { _isFirstImportTimedOut.withLock { $0 } } + set { _isFirstImportTimedOut.withLock { $0 = newValue } } + } + /// 첫 번째 Import 완료 시점 — grace period 계산에 사용 private let _firstImportCompletedAt = OSAllocatedUnfairLock(initialState: nil) @@ -93,15 +101,23 @@ final class CoreDataStorage { /// 한 번이라도 UserCollectionEntity가 존재했는지 여부 (UserDefaults 기반, 메모리 캐싱) /// 이 플래그가 true인데 UC가 0개면, 빈 UC 자동 생성 대신 .notFound를 throw - private var _hasEverHadUserCollectionCached = UserDefaults.standard.bool( - forKey: CoreDataStorage.hasEverHadUserCollectionKey - ) + /// 초기값은 init에서 주입된 `userDefaults`로부터 읽는다. + private let _hasEverHadUserCollectionCached: OSAllocatedUnfairLock private(set) var hasEverHadUserCollection: Bool { - get { _hasEverHadUserCollectionCached } + get { _hasEverHadUserCollectionCached.withLock { $0 } } set { - guard _hasEverHadUserCollectionCached != newValue else { return } - _hasEverHadUserCollectionCached = newValue - UserDefaults.standard.set(newValue, forKey: Self.hasEverHadUserCollectionKey) + // UserDefaults I/O는 unfair lock 임계영역 밖에서 수행한다 (잠재적 재진입 트랩 회피). + let didChange = _hasEverHadUserCollectionCached.withLock { cached -> Bool in + guard cached != newValue else { + return false + } + cached = newValue + return true + } + guard didChange else { + return + } + userDefaults.set(newValue, forKey: Self.hasEverHadUserCollectionKey) } } @@ -115,29 +131,53 @@ final class CoreDataStorage { private static let recoveryInitiatedAtKey = "CoreDataStorage_recoveryInitiatedAt" - /// performCloudKitRecovery 후 재시작했는데 import가 지연되는 동안 - /// getUserCollection이 .notFound를 영구히 throw하는 것을 막기 위한 유예 시간 (10분). + /// performCloudKitRecovery 후 재시작했는데 import가 지연되는 상황을 + /// UI/로그에서 구분하기 위한 유예 시간 (10분). private static let recoveryGracePeriodSeconds: TimeInterval = 600 /// 복구 시작 시각 기록 — 재시작 후 grace window 계산에 사용 func markRecoveryInitiated() { - UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.recoveryInitiatedAtKey) + userDefaults.set(Date().timeIntervalSince1970, forKey: Self.recoveryInitiatedAtKey) Log.info("recovery initiated timestamp recorded (10min grace started)") } /// Recovery 완료 후 UC가 정상 복구되면 호출하여 플래그 정리 func clearRecoveryInitiated() { - UserDefaults.standard.removeObject(forKey: Self.recoveryInitiatedAtKey) + userDefaults.removeObject(forKey: Self.recoveryInitiatedAtKey) } - /// 복구 시작 후 grace period 내인지 확인 — 이 기간에는 hasEverHadUserCollection 체크를 우회하여 UC 생성을 허용 + /// 성공한 import로 UC가 실제 복구되었으면 recovery grace 타임스탬프를 정리한다. + /// UC가 아직 없으면(부분 import) 타임스탬프를 유지해 진단 상태가 남도록 한다. + private func clearRecoveryInitiatedIfRecovered() { + guard isWithinRecoveryGracePeriod else { + return + } + + persistentContainer.performBackgroundTask { [weak self] context in + guard let owner = self else { + return + } + + let count = (try? context.count(for: UserCollectionEntity.fetchRequest())) ?? 0 + guard count > 0 else { + return + } + + owner.clearRecoveryInitiated() + Log.info("recovery grace cleared — UC restored by successful import") + } + } + + /// 복구 시작 후 grace period 내인지 확인 — 상태 표시에만 사용하고, UC 생성 허용에는 사용하지 않는다. var isWithinRecoveryGracePeriod: Bool { - let timestamp = UserDefaults.standard.double(forKey: Self.recoveryInitiatedAtKey) - guard timestamp > 0 else { return false } + let timestamp = userDefaults.double(forKey: Self.recoveryInitiatedAtKey) + guard timestamp > 0 else { + return false + } let elapsed = Date().timeIntervalSince1970 - timestamp if elapsed < 0 || elapsed > Self.recoveryGracePeriodSeconds { // 만료 시 자동 정리 - UserDefaults.standard.removeObject(forKey: Self.recoveryInitiatedAtKey) + userDefaults.removeObject(forKey: Self.recoveryInitiatedAtKey) return false } return true @@ -148,7 +188,9 @@ final class CoreDataStorage { /// 첫 Import 완료 후 grace period 내인지 확인 private var isWithinGracePeriod: Bool { - guard let firstImportDate = _firstImportCompletedAt.withLock({ $0 }) else { return false } + guard let firstImportDate = _firstImportCompletedAt.withLock({ $0 }) else { + return false + } return Date().timeIntervalSince(firstImportDate) < Self.gracePeriodSeconds } @@ -157,7 +199,22 @@ final class CoreDataStorage { /// 주의: hasEverHadUserCollection은 여기에 포함하지 않음 — 그 플래그는 getUserCollection()에서만 사용 /// 여기에 포함하면 기존 유저의 DailyTask 자동 생성이 영구적으로 차단됨 var shouldSuppressDataCreation: Bool { - isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress || isWithinGracePeriod + isWaitingForFirstImport + || isImportInProgress + || isSyncResetInProgress + || isFirstImportTimedOut + || isWithinGracePeriod + } + + /// 반복되는 sync 실패로 사용자가 "빈 컬렉션" 보호 모드에 갇힌 상태인지. + /// UC가 없는데 생성이 억제 중이거나 known-user 보호로 차단된 경우 true. + /// SceneDelegate가 동기화 실패 시 보호 모드 안내 노출 여부를 판단할 때 사용한다. + var isAwaitingCloudDataWithoutCollection: Bool { + guard shouldSuppressDataCreation || hasEverHadUserCollection else { + return false + } + + return isFreshInstall() } // MARK: - Private API Notification Names (fragile) @@ -168,9 +225,28 @@ final class CoreDataStorage { static let didReset = Notification.Name("NSCloudKitMirroringDelegateDidResetSyncNotificationName") } - private init() {} + private let injectedPersistentContainer: NSPersistentCloudKitContainer? + + /// 동기화 영속 플래그(known-user/recovery)의 저장소. production은 `.standard`, + /// 테스트는 격리된 suite를 주입해 실기기의 실제 앱 상태를 오염시키지 않는다. + private let userDefaults: UserDefaults + + private init( + persistentContainer: NSPersistentCloudKitContainer? = nil, + userDefaults: UserDefaults = .standard + ) { + self.injectedPersistentContainer = persistentContainer + self.userDefaults = userDefaults + self._hasEverHadUserCollectionCached = OSAllocatedUnfairLock( + initialState: userDefaults.bool(forKey: Self.hasEverHadUserCollectionKey) + ) + } lazy var persistentContainer: NSPersistentCloudKitContainer = { + if let injectedPersistentContainer { + return injectedPersistentContainer + } + let container = NSPersistentCloudKitContainer(name: "CoreDataStorage") container.persistentStoreDescriptions.forEach { description in @@ -243,9 +319,32 @@ final class CoreDataStorage { /// Import 대기 플래그 해제 — setupApp() 또는 no-iCloud 경로에서 호출 func clearWaitingForFirstImport() { isWaitingForFirstImport = false + isFirstImportTimedOut = false Log.info("clearWaitingForFirstImport") } + enum FirstImportWaitCompletionReason: Equatable { + case importArrived + case noICloud + case timeout + } + + /// Fresh install 첫 CloudKit import 대기 종료. + /// timeout은 데이터 없음의 증거가 아니므로 UC 생성을 계속 억제한다. + func completeFirstImportWait(reason: FirstImportWaitCompletionReason) { + // timedOut 플래그를 먼저 확정한 뒤 waiting을 해제한다. 순서를 뒤집으면 두 플래그가 + // 모두 false가 되는 찰나에 shouldSuppressDataCreation이 잠깐 풀리는 TOCTOU 윈도우가 + // 생긴다 (timeout 경로). 이 순서면 억제는 항상 fail-safe하게 유지된다. + switch reason { + case .importArrived, .noICloud: + isFirstImportTimedOut = false + case .timeout: + isFirstImportTimedOut = lastSuccessfulImportDate == nil + } + isWaitingForFirstImport = false + Log.info("completeFirstImportWait reason=\(reason)") + } + // MARK: - Persistent History Cleanup func cleanupPersistentHistory() { @@ -382,39 +481,19 @@ final class CoreDataStorage { } if event.endDate != nil { + let didSucceed = event.error == nil if let error = event.error { os_log(.error, log: .default, "CloudKit %{public}@ failed: %{public}@", type, error.localizedDescription) postSyncFailureIfNeeded(error) } else { os_log(.info, log: .default, "CloudKit %{public}@ succeeded", type) // 동기화 성공 시각 기록 (설정 화면 표시용) - if event.type == .import { - lastSuccessfulImportDate = Date() - } else if event.type == .export { + if event.type == .export { lastSuccessfulExportDate = Date() } } if event.type == .import { - isImportInProgress = false - isWaitingForFirstImport = false - isSyncResetInProgress = false - _exportRetryCount.withLock { $0 = 0 } - - _firstImportCompletedAt.withLock { date in - if date == nil { date = Date() } - } - - let hasChanges = hasImportedChanges() - logSyncDiagnostics(phase: "Import-end") - - // Import 후 자동 consolidation/orphan cleanup이 로컬 데이터를 삭제하는 버그로 - // 제거됨 — 사용자가 설정에서 명시적으로 실행할 때만 돌아간다. - - NotificationCenter.default.post( - name: Self.didFinishCloudImport, - object: nil, - userInfo: hasChanges ? ["hasChanges": true] : nil - ) + finishCloudImport(succeeded: didSucceed) } if event.type == .export { if event.error != nil { @@ -432,6 +511,43 @@ final class CoreDataStorage { } } + private func finishCloudImport(succeeded: Bool) { + isImportInProgress = false + + guard succeeded else { + // Failed import means CloudKit data is still unknown. Keep waiting/timeout/reset + // suppression flags so the app cannot create and export an empty UC after grace. + // _exportRetryCount is export-only (never incremented by import), so the success + // path's reset is intentionally absent here. + logSyncDiagnostics(phase: "Import-failed", throttled: false) + return + } + + lastSuccessfulImportDate = Date() + isWaitingForFirstImport = false + isFirstImportTimedOut = false + isSyncResetInProgress = false + _exportRetryCount.withLock { $0 = 0 } + + _firstImportCompletedAt.withLock { date in + if date == nil { date = Date() } + } + + clearRecoveryInitiatedIfRecovered() + + let hasChanges = hasImportedChanges() + logSyncDiagnostics(phase: "Import-end") + + // Import 후 자동 consolidation/orphan cleanup이 로컬 데이터를 삭제하는 버그로 + // 제거됨 — 사용자가 설정에서 명시적으로 실행할 때만 돌아간다. + + NotificationCenter.default.post( + name: Self.didFinishCloudImport, + object: nil, + userInfo: hasChanges ? ["hasChanges": true] : nil + ) + } + // MARK: - Export Retry private func retryExportAfterMergeError() { @@ -557,7 +673,9 @@ extension CoreDataStorage { if throttled { let shouldProceed = lastDiagnosticsDate.withLock { lastDate -> Bool in let now = Date() - guard now.timeIntervalSince(lastDate) >= 5 else { return false } + guard now.timeIntervalSince(lastDate) >= 5 else { + return false + } lastDate = now return true } @@ -568,10 +686,13 @@ extension CoreDataStorage { } persistentContainer.performBackgroundTask { [weak self] context in - guard let self else { return } + guard let owner = self else { + return + } + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy - let counts = self.entityCounts(in: context) + let counts = owner.entityCounts(in: context) let ucCount = counts["UserCollectionEntity"] ?? -1 let itemCount = counts["ItemEntity"] ?? -1 @@ -590,21 +711,26 @@ extension CoreDataStorage { itemCount: itemCount, taskCount: counts["DailyTaskEntity"] ?? -1, villagerCount: (counts["VillagersLikeEntity"] ?? 0) + (counts["VillagersHouseEntity"] ?? 0), - hasEverHadUC: self.hasEverHadUserCollection, + hasEverHadUC: owner.hasEverHadUserCollection, isFreshInstall: nil, - isWaitingForFirstImport: self.isWaitingForFirstImport, - isImportInProgress: self.isImportInProgress, - isSyncResetInProgress: self.isSyncResetInProgress, - isWithinRecoveryGracePeriod: self.isWithinRecoveryGracePeriod, - lastImportDate: self.lastSuccessfulImportDate, - lastExportDate: self.lastSuccessfulExportDate + isWaitingForFirstImport: owner.isWaitingForFirstImport, + isFirstImportTimedOut: owner.isFirstImportTimedOut, + isImportInProgress: owner.isImportInProgress, + isSyncResetInProgress: owner.isSyncResetInProgress, + isWithinRecoveryGracePeriod: owner.isWithinRecoveryGracePeriod, + lastImportDate: owner.lastSuccessfulImportDate, + lastExportDate: owner.lastSuccessfulExportDate )) // UC가 2개 이상일 때만 상세 진단 (중복 탐지) - guard ucCount > 1 else { return } + guard ucCount > 1 else { + return + } let ucRequest = UserCollectionEntity.fetchRequest() - guard let ucResults = try? context.fetch(ucRequest) else { return } + guard let ucResults = try? context.fetch(ucRequest) else { + return + } for (index, uc) in ucResults.enumerated() { let critters = uc.critters?.count ?? 0 @@ -752,10 +878,11 @@ extension CoreDataStorage { /// UC 관계가 nil인 고아 엔티티를 삭제 private func cleanupOrphanedEntities(in context: NSManagedObjectContext) { - // Import 또는 sync reset 진행 중에는 cleanup 건너뜀 - // — CloudKit이 relationship을 비동기로 해소하므로 일시적으로 orphan처럼 보일 수 있음 - guard !isImportInProgress, !isSyncResetInProgress else { - os_log(.info, log: .default, "🔧 Orphan cleanup skipped — sync in progress") + // Import/sync reset 진행 중이거나 import 완료 직후 grace period에는 cleanup 건너뜀 + // — CloudKit이 relationship을 비동기로 해소하므로 import가 끝난 뒤에도 + // 일시적으로 orphan처럼 보일 수 있음 (grace period가 존재하는 이유와 동일) + guard !isImportInProgress, !isSyncResetInProgress, !isWithinGracePeriod else { + os_log(.info, log: .default, "🔧 Orphan cleanup skipped — sync in progress or within grace period") return } @@ -905,15 +1032,21 @@ extension CoreDataStorage { // 1. Import 대기 중 (신규 설치 시 CloudKit Import 완료 전) // 2. Import 진행 중 (timeout 후에도 import가 아직 끝나지 않은 경우) // 3. Sync reset 진행 중 (Change Token Expired 후 re-import 대기) - // 4. 첫 Import 완료 후 120초 유예 (relationship 해소 시간 확보) - // 5. 기존 유저 — 이전에 UC가 존재했으므로, CloudKit re-import 대기 필요 - if isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress { - Log.info("getUserCollection: No UC — skipping (waiting=\(isWaitingForFirstImport), importing=\(isImportInProgress), reset=\(isSyncResetInProgress))") + // 4. fresh install 첫 import 대기가 timeout됨 (CloudKit 데이터 유무가 아직 불명확) + // 5. 첫 Import 완료 후 120초 유예 (relationship 해소 시간 확보) + // 6. 기존 유저 — 이전에 UC가 존재했으므로, CloudKit re-import 대기 필요 + if isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress || isFirstImportTimedOut { + Log.info( + "getUserCollection: No UC — skipping " + + "(waiting=\(isWaitingForFirstImport), importing=\(isImportInProgress), " + + "reset=\(isSyncResetInProgress), timedOut=\(isFirstImportTimedOut))" + ) Log.event(.ucCreationSuppressed, parameters: [ Log.Param.reason: SuppressionReason.syncInProgress.rawValue, Log.Param.waiting: isWaitingForFirstImport.description, Log.Param.importing: isImportInProgress.description, - Log.Param.reset: isSyncResetInProgress.description + Log.Param.reset: isSyncResetInProgress.description, + Log.Param.timedOut: isFirstImportTimedOut.description ]) throw CoreDataStorageError.notFound } @@ -928,17 +1061,7 @@ extension CoreDataStorage { // 기존 유저인데 UC가 0개 → CloudKit 미러 재구성 또는 re-import 대기 상태 // 빈 UC를 생성하면 CloudKit에 빈 데이터가 Export되어 기존 데이터를 오염시킬 수 있음 - // - // 예외: performCloudKitRecovery 직후 grace period(10분) 내에는 - // import가 지연되더라도 앱이 동작 가능하도록 UC 생성을 허용한다. - // 복구 자체가 "로컬 재생성 + CloudKit에서 재import" 플로우이므로 안전. if hasEverHadUserCollection { - if isWithinRecoveryGracePeriod { - Log.info("UC created within recovery grace period (hasEverHadUC=true)") - Log.event(.ucCreated, parameters: [Log.Param.path: UCCreationPath.recoveryGrace.rawValue]) - logSyncDiagnostics(phase: "UC-created-recovery", throttled: false) - return UserCollectionEntity(UserInfo(), context: context) - } // 핵심 데이터 유실 증상: "기존 유저인데 UC가 사라짐". // 3.2.0 이후 클레임의 주 증상으로 추정되는 상태. Log.warning("UC missing but hasEverHadUC=true — user data appears reset, blocking empty UC to protect cloud") @@ -973,92 +1096,32 @@ extension CoreDataStorage { } } -// MARK: - Data Recovery - +#if DEBUG +// MARK: - Testing extension CoreDataStorage { - - /// 사용자가 설정에서 "iCloud에서 복원"을 명시적으로 눌렀을 때만 실행되는 복원 플로우. - /// 로컬 store를 삭제하고 앱 재시작 시 CloudKit에서 전체 re-import 유도. - /// 로컬 데이터가 iCloud 백업으로 완전히 대체되므로 파괴적 동작 — 2단 확인 alert 후에만 호출. - enum RecoveryError: LocalizedError { - case iCloudNotAvailable - case storeNotFound - - var errorDescription: String? { - switch self { - case .iCloudNotAvailable: return "iCloud is not available" - case .storeNotFound: return "CoreData store not found" - } - } + convenience init( + testingPersistentContainer: NSPersistentCloudKitContainer, + userDefaults: UserDefaults = .standard + ) { + self.init(persistentContainer: testingPersistentContainer, userDefaults: userDefaults) } - /// 로컬 store 파일을 삭제하고 앱 재시작 시 CloudKit에서 전체 re-import 유도. - /// store를 런타임에 재등록하면 CloudKit 옵션이 누락되므로, 파일만 삭제하고 재시작을 안내한다. - func performCloudKitRecovery(completion: @escaping (Result) -> Void) { - checkiCloudAccountStatus { [weak self] status in - guard status == .available else { - completion(.failure(RecoveryError.iCloudNotAvailable)) - return - } - guard let self else { return } - - guard let storeDescription = self.persistentContainer.persistentStoreDescriptions.first, - let storeURL = storeDescription.url else { - completion(.failure(RecoveryError.storeNotFound)) - return - } - - do { - // 기존 store 분리 - let coordinator = self.persistentContainer.persistentStoreCoordinator - if let store = coordinator.persistentStore(for: storeURL) { - try coordinator.remove(store) - } - - // Store 파일 삭제 — fileExists 대신 직접 시도 + 부재 에러 무시 (TOCTOU 방지) - let fileManager = FileManager.default - let storePath = storeURL.path - for suffix in ["", "-shm", "-wal"] { - do { - try fileManager.removeItem(atPath: storePath + suffix) - } catch let error as NSError where error.code == NSFileNoSuchFileError { - // 파일이 이미 없음 — 정상 - } - } - - // ckAssets 폴더 삭제 - let ckAssetsURL = storeURL.deletingLastPathComponent() - .appendingPathComponent("ckAssets") - do { - try fileManager.removeItem(at: ckAssetsURL) - } catch let error as NSError where error.code == NSFileNoSuchFileError { - // 폴더가 이미 없음 — 정상 - } - - // migration flag 유지 — 재시작 시 re-export 중복 방지 - UserDefaults.standard.set(true, forKey: "didMigrateExistingDataToCloudKit_v2") - - // 기존 유저 플래그 유지 — 재시작 후 CloudKit re-import 전까지 빈 UC 생성 방지 - // (복구 = 기존 유저이므로 true 유지가 올바름) + func markImportInProgressForTesting() { + isImportInProgress = true + } - // Recovery grace period 시작 — 재시작 후 import가 지연되어도 - // 10분간은 UC 생성을 허용하여 앱이 동작 가능하도록 보장 - self.markRecoveryInitiated() + func markSyncResetInProgressForTesting() { + isSyncResetInProgress = true + } - Log.warning("recovery: local store wiped, awaiting restart + CloudKit re-import") - Log.event(.recoveryTriggered) - completion(.success(())) - } catch { - os_log(.error, log: .default, "🔄 Recovery failed: %{public}@", error.localizedDescription) - completion(.failure(error)) - } - } + func finishCloudImportForTesting(succeeded: Bool) { + finishCloudImport(succeeded: succeeded) } } +#endif private enum UCCreationPath: String { case freshUser = "fresh_user" - case recoveryGrace = "recovery_grace" } private enum SuppressionReason: String { diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift index b10cbe09..582b9aba 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift @@ -18,12 +18,13 @@ final class SafetySnapshotService { // MARK: - File Location private static let fileName = "local_safety_snapshot.plist" + private static let snapshotFileProtection: FileProtectionType = .completeUntilFirstUserAuthentication var snapshotURL: URL { - guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - fatalError("Documents directory unavailable") + guard let directory = snapshotDirectoryProvider() else { + fatalError("Safety snapshot directory unavailable") } - return documents.appendingPathComponent(Self.fileName) + return directory.appendingPathComponent(Self.fileName) } var snapshotExists: Bool { @@ -48,9 +49,33 @@ final class SafetySnapshotService { private let queue = DispatchQueue(label: "app.safety.snapshot", qos: .utility) private var pendingWorkItem: DispatchWorkItem? private var observers: [NSObjectProtocol] = [] + private let containerProvider: () -> NSPersistentContainer + private let snapshotDirectoryProvider: () -> URL? + private let fileAttributeSetter: (URL, [FileAttributeKey: Any]) throws -> Void + + #if DEBUG + /// 테스트 전용 fault-injection seam. `restore()`가 기존 데이터를 wipe한 직후, + /// 스냅샷을 적용하기 직전에 호출한다. production 빌드에는 컴파일되지 않으므로 + /// 복원 경로에 죽은 코드가 남지 않는다. + var beforeApplyingSnapshotForTesting: ((NSManagedObjectContext) throws -> Void)? + #endif + + init( + containerProvider: @escaping () -> NSPersistentContainer = { CoreDataStorage.shared.persistentContainer }, + snapshotDirectoryProvider: @escaping () -> URL? = { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + }, + fileAttributeSetter: @escaping (URL, [FileAttributeKey: Any]) throws -> Void = { url, attributes in + try FileManager.default.setAttributes(attributes, ofItemAtPath: url.path) + } + ) { + self.containerProvider = containerProvider + self.snapshotDirectoryProvider = snapshotDirectoryProvider + self.fileAttributeSetter = fileAttributeSetter + } private var container: NSPersistentContainer { - CoreDataStorage.shared.persistentContainer + containerProvider() } /// 앱 시작 시 1회 호출 — 다음 이벤트에 대해 모두 스냅샷 작성을 예약: @@ -131,7 +156,11 @@ final class SafetySnapshotService { do { let snapshot = try UserCollectionSnapshot.dump(from: context) let data = try snapshot.toData() - try data.write(to: snapshotURL, options: [.atomic]) + // CloudKit can deliver background imports while the device is locked. The snapshot must + // remain writable after the first unlock so a sync-reset flush can preserve recent data. + try data.write(to: snapshotURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + try fileAttributeSetter(snapshotURL, [.protectionKey: Self.snapshotFileProtection]) + applySnapshotResourceValues() updateMetadataCache(createdAt: snapshot.createdAt, childCount: snapshot.totalChildCount) os_log(.info, log: .default, "🛟 SafetySnapshot written: %d children, %d bytes", @@ -146,6 +175,22 @@ final class SafetySnapshotService { } } + private func applySnapshotResourceValues() { + var url = snapshotURL + var values = URLResourceValues() + values.isExcludedFromBackup = true + do { + try url.setResourceValues(values) + } catch { + // 백업 제외(isExcludedFromBackup) 회귀를 Crashlytics에서 관측할 수 있도록 비치명 에러로 보고한다. + // 파일 보호 등급은 write 옵션 + setAttributes로 이미 적용되므로 암호화 경계 자체는 유지된다. + Log.error( + name: "SafetySnapshotBackupExclusion", + reason: error.localizedDescription + ) + } + } + // MARK: - Restore enum RestoreOutcome { @@ -158,12 +203,17 @@ final class SafetySnapshotService { /// **파괴적 동작** — 호출 전 사용자 명시적 동의 필요. func restore(completion: @escaping (RestoreOutcome) -> Void) { container.performBackgroundTask { [weak self] context in - guard let self else { return } + guard let self else { + return + } let outcome: RestoreOutcome do { let data = try Data(contentsOf: self.snapshotURL) let snapshot = try UserCollectionSnapshot.from(data: data) try Self.wipeExistingCollection(in: context) + #if DEBUG + try self.beforeApplyingSnapshotForTesting?(context) + #endif try snapshot.apply(to: context) try context.save() os_log(.error, log: .default, @@ -173,6 +223,7 @@ final class SafetySnapshotService { } catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError { outcome = .noSnapshot } catch { + context.rollback() os_log(.error, log: .default, "🛟 SafetySnapshot restore FAILED: %{public}@", error.localizedDescription) @@ -189,14 +240,10 @@ final class SafetySnapshotService { "UserCollectionEntity" ] for name in entityNames { - let request = NSFetchRequest(entityName: name) - let delete = NSBatchDeleteRequest(fetchRequest: request) - delete.resultType = .resultTypeObjectIDs - if let result = try context.execute(delete) as? NSBatchDeleteResult, - let objectIDs = result.result as? [NSManagedObjectID], !objectIDs.isEmpty { - let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: objectIDs] - NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context]) - } + let request = NSFetchRequest(entityName: name) + request.includesPropertyValues = false + let objects = try context.fetch(request) + objects.forEach(context.delete) } } @@ -230,7 +277,9 @@ final class SafetySnapshotService { } private func readMetadataFromFile() -> Metadata? { - guard snapshotExists else { return nil } + guard snapshotExists else { + return nil + } do { let data = try Data(contentsOf: snapshotURL) let snapshot = try UserCollectionSnapshot.from(data: data) diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Extension/String+extension.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Extension/String+extension.swift index b9304e78..7160d7f7 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Extension/String+extension.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Extension/String+extension.swift @@ -27,7 +27,7 @@ extension String { var isChosung: Bool { var isChosung = false for char in self { - if 0 < hangul.filter({ $0.contains(char)}).count { + if hangul.contains(where: { $0.contains(char) }) { isChosung = true } else { isChosung = false diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewControllers/ItemsViewController.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewControllers/ItemsViewController.swift index 1bd5d797..9a32c2e3 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewControllers/ItemsViewController.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewControllers/ItemsViewController.swift @@ -429,7 +429,7 @@ extension ItemsViewController { let allSelectAction = UIAction( title: Menu.allSelect.title, image: UIImage(systemName: "text.badge.checkmark") - ) { [weak self] action in + ) { [weak self] _ in guard let owner = self else { return } @@ -447,7 +447,7 @@ extension ItemsViewController { let resetAction = UIAction( title: Menu.reset.title, image: UIImage(systemName: "arrow.counterclockwise") - ) { [weak self] action in + ) { [weak self] _ in guard let owner = self else { return } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewModels/ItemsReactor.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewModels/ItemsReactor.swift index 6b3b9956..63d371d5 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewModels/ItemsReactor.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewModels/ItemsReactor.swift @@ -318,7 +318,7 @@ final class ItemsReactor: Reactor { } } - private func setUpUserItem() -> Observable<[Item]> { + private func setUpUserItem() -> Observable<[Item]> { switch mode { case .all: return Items.shared.itemList diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/CategoryRow.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/CategoryRow.swift index 9c9104eb..2e3832e5 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/CategoryRow.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/CategoryRow.swift @@ -30,10 +30,6 @@ final class CategoryRow: UITableViewCell { itemCountLabel.text = nil } - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - } - func setUp(iconName: String, title: String, itemCount: Int) { iconImage.image = UIImage(named: iconName) titleLabel.text = title diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/ItemSeasonView.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/ItemSeasonView.swift index 12e03d8a..49c88f9a 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/ItemSeasonView.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/ItemSeasonView.swift @@ -73,7 +73,7 @@ final class ItemSeasonView: UIView { } private func setUpCalendar(months: [Int]) { - if backgroundStackView.arrangedSubviews.last as? CalendarView != nil { + if backgroundStackView.arrangedSubviews.last is CalendarView { backgroundStackView.arrangedSubviews.last?.removeFromSuperview() } let calendarView = CalendarView(months: months) diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift index f09a992a..73f1e35b 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift @@ -14,7 +14,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var appCoordinator: AppCoordinator? private var isAppSetup = false + private var hasShownSyncProtectionNotice = false private var importObserver: NSObjectProtocol? + private var pendingFirstImportCompletionReason: CoreDataStorage.FirstImportWaitCompletionReason? func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { @@ -22,6 +24,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } window = UIWindow(windowScene: windowScene) + if AppEnvironment.isUnitTesting { + window?.rootViewController = UIViewController() + window?.makeKeyAndVisible() + return + } + os_log(.info, log: .default, "🚀 App launch — checking fresh install") let isFresh = CoreDataStorage.shared.isFreshInstall() os_log(.info, log: .default, "🚀 isFreshInstall = %{public}@", isFresh ? "true" : "false") @@ -30,7 +38,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { CoreDataStorage.shared.markWaitingForFirstImport() showSplashScreen() window?.makeKeyAndVisible() - waitForCloudKitImport(timeout: 10) { [weak self] in + waitForCloudKitImport(timeout: 10) { [weak self] reason in + self?.pendingFirstImportCompletionReason = reason self?.setupApp() } } else { @@ -43,7 +52,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func setupApp() { isAppSetup = true - CoreDataStorage.shared.clearWaitingForFirstImport() + if let reason = pendingFirstImportCompletionReason { + CoreDataStorage.shared.completeFirstImportWait(reason: reason) + pendingFirstImportCompletionReason = nil + } else { + CoreDataStorage.shared.clearWaitingForFirstImport() + } CoreDataStorage.shared.logSyncDiagnostics(phase: "Pre-setup") appCoordinator = AppCoordinator() @@ -122,11 +136,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.rootViewController = CloudSyncSplashViewController() } - private func waitForCloudKitImport(timeout: TimeInterval, completion: @escaping () -> Void) { + private func waitForCloudKitImport( + timeout: TimeInterval, + completion: @escaping (CoreDataStorage.FirstImportWaitCompletionReason) -> Void + ) { var hasCompleted = false // hasCompleted 접근을 main queue로 한정하여 race condition 방지 - let complete: (String) -> Void = { [weak self] reason in + let complete: (CoreDataStorage.FirstImportWaitCompletionReason) -> Void = { [weak self] reason in DispatchQueue.main.async { guard !hasCompleted else { return @@ -137,16 +154,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { NotificationCenter.default.removeObserver(observer) self?.importObserver = nil } - os_log(.info, log: .default, "🚀 CloudKit wait finished (%{public}@) — launching app", reason) - completion() + os_log(.info, log: .default, "🚀 CloudKit wait finished (%{public}@) — launching app", "\(reason)") + completion(reason) } } // iCloud 계정 확인 — 미로그인이면 Import 대기 불필요 CoreDataStorage.shared.checkiCloudAccountStatus { status in - if status != .available { - os_log(.info, log: .default, "🚀 iCloud not available (status=%d) — skipping wait", status.rawValue) - complete("no-icloud") + if let reason = Self.firstImportWaitCompletionReason(for: status) { + os_log(.info, log: .default, + "🚀 iCloud wait completed from account status=%d reason=%{public}@", + status.rawValue, "\(reason)") + complete(reason) } } @@ -155,11 +174,26 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { object: nil, queue: .main ) { _ in - complete("import-arrived") + complete(.importArrived) } DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { - complete("timeout") + complete(.timeout) + } + } + + static func firstImportWaitCompletionReason( + for status: CKAccountStatus + ) -> CoreDataStorage.FirstImportWaitCompletionReason? { + switch status { + case .available: + return nil + case .noAccount, .restricted: + return .noICloud + case .couldNotDetermine, .temporarilyUnavailable: + return .timeout + @unknown default: + return .timeout } } @@ -268,6 +302,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return } DispatchQueue.main.async { [weak self] in + guard let owner = self else { + return + } + let message: String switch reason { case "quota_exceeded": @@ -275,12 +313,22 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { case "not_authenticated": message = "iCloud is not signed in. Data will be saved locally only.".localized default: - return + // 반복 sync 실패로 컬렉션 생성이 보호 모드에 들어가 빈 화면이 유지되는 상태라면, + // 세션당 한 번 보호 중임을 안내한다 (조용한 빈 컬렉션 방지). + guard owner.isAppSetup, + !owner.hasShownSyncProtectionNotice, + CoreDataStorage.shared.isAwaitingCloudDataWithoutCollection else { + return + } + + owner.hasShownSyncProtectionNotice = true + // swiftlint:disable:next line_length + message = "iCloud sync keeps failing. Creating new data is paused to protect your existing iCloud data. Please check your iCloud sign-in and storage.".localized } let alert = UIAlertController(title: "iCloud".localized, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK".localized, style: .default)) - self?.presentAlert(alert) + owner.presentAlert(alert) } } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift new file mode 100644 index 00000000..85cb4431 --- /dev/null +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift @@ -0,0 +1,20 @@ +// +// AppEnvironment.swift +// Animal-Crossing-Wiki +// +// Created by Ari on 2026/06/04. +// + +import Foundation + +enum AppEnvironment { + /// 단위 테스트 실행 여부. + /// + /// 테스트 스킴(`Project.swift`)이 `IS_UNIT_TESTING=1`을 주입하므로 환경 변수만으로 판정한다. + /// `NSClassFromString("XCTestCase")` 같은 런타임 probe는 (1) XCTest를 링크하는 향후 UI 테스트에서 + /// 앱이 빈 화면으로 부팅되고, (2) 릴리스 바이너리에 XCTest가 새어 들어갈 경우 크래시 리포팅/애널리틱스가 + /// 조용히 비활성화될 수 있어 사용하지 않는다. + static var isUnitTesting: Bool { + ProcessInfo.processInfo.environment["IS_UNIT_TESTING"] == "1" + } +} diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift index 63a4c577..b4030ad7 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift @@ -7,6 +7,7 @@ import Foundation import OSLog +import FirebaseCore import FirebaseCrashlytics import FirebaseAnalytics @@ -42,6 +43,7 @@ enum Log { static let waiting = "waiting" static let importing = "importing" static let reset = "reset" + static let timedOut = "timed_out" static let recoveryGrace = "recovery_grace" } @@ -55,6 +57,7 @@ enum Log { static let hasEverHadUC = "sync_has_ever_had_uc" static let isFreshInstall = "sync_is_fresh_install" static let isWaitingForFirstImport = "sync_waiting_first_import" + static let isFirstImportTimedOut = "sync_first_import_timed_out" static let isImportInProgress = "sync_import_in_progress" static let isSyncResetInProgress = "sync_reset_in_progress" static let isWithinRecoveryGracePeriod = "sync_within_recovery_grace" @@ -65,9 +68,22 @@ enum Log { // MARK: - Internal - private static let crashlytics = Crashlytics.crashlytics() private static let analyticsStringLimit = 100 + private static var isFirebaseConfigured: Bool { + guard !AppEnvironment.isUnitTesting else { + return false + } + return FirebaseApp.app() != nil + } + + private static var crashlytics: Crashlytics? { + guard isFirebaseConfigured else { + return nil + } + return Crashlytics.crashlytics() + } + private static func truncate(_ message: String) -> String { guard message.count > analyticsStringLimit else { return message @@ -86,9 +102,9 @@ enum Log { // verbose/debug는 Analytics 쿼터 보호를 위해 DEBUG 빌드에서만 Analytics로 전송된다. private static func emit(level: String, symbol: String, osLogType: OSLogType, message: String, sendToAnalytics: Bool) { - crashlytics.log("[\(level.uppercased())] \(message)") + crashlytics?.log("[\(level.uppercased())] \(message)") os_log(osLogType, log: .default, "%{public}@ %{public}@", symbol, message) - guard sendToAnalytics else { + guard sendToAnalytics, isFirebaseConfigured else { return } Analytics.logEvent("log_\(level)", parameters: [Param.message: truncate(message)]) @@ -130,8 +146,11 @@ enum Log { var info = userInfo info[NSLocalizedDescriptionKey] = reason let nsError = NSError(domain: "Log.\(name)", code: 0, userInfo: info) - crashlytics.record(error: nsError) + crashlytics?.record(error: nsError) os_log(.error, log: .default, "❗️ non-fatal: %{public}@ — %{public}@", name, reason) + guard isFirebaseConfigured else { + return + } Analytics.logEvent("log_error", parameters: [ Param.errorName: truncate(name), Param.reason: truncate(reason) @@ -141,28 +160,34 @@ enum Log { // MARK: - Analytics static func event(_ event: Event, parameters: [String: Any] = [:]) { - Analytics.logEvent(event.rawValue, parameters: parameters) - crashlytics.log("[EVENT] \(event.rawValue) \(parameters)") os_log(.info, log: .default, "📈 %{public}@", event.rawValue) + guard isFirebaseConfigured else { + return + } + Analytics.logEvent(event.rawValue, parameters: parameters) + crashlytics?.log("[EVENT] \(event.rawValue) \(parameters)") } /// 사용자 탭/클릭 추적. Firebase Analytics의 `select_content` 스키마로 기록. static func click(_ name: String, parameters: [String: Any] = [:]) { + os_log(.info, log: .default, "👆 click=%{public}@", name) + guard isFirebaseConfigured else { + return + } var params = parameters params[AnalyticsParameterItemID] = name params[AnalyticsParameterContentType] = "click" Analytics.logEvent(AnalyticsEventSelectContent, parameters: params) - os_log(.info, log: .default, "👆 click=%{public}@", name) } // MARK: - Context (Crashlytics custom keys) static func setContext(_ key: String, _ value: Any?) { guard let value else { - crashlytics.setCustomValue("", forKey: key) + crashlytics?.setCustomValue("", forKey: key) return } - crashlytics.setCustomValue(value, forKey: key) + crashlytics?.setCustomValue(value, forKey: key) } /// 엔티티 카운트와 sync 플래그를 한 번에 custom keys로 전송. @@ -174,6 +199,7 @@ enum Log { var hasEverHadUC: Bool var isFreshInstall: Bool? var isWaitingForFirstImport: Bool + var isFirstImportTimedOut: Bool var isImportInProgress: Bool var isSyncResetInProgress: Bool var isWithinRecoveryGracePeriod: Bool @@ -191,6 +217,7 @@ enum Log { setContext(Key.isFreshInstall, isFreshInstall) } setContext(Key.isWaitingForFirstImport, snapshot.isWaitingForFirstImport) + setContext(Key.isFirstImportTimedOut, snapshot.isFirstImportTimedOut) setContext(Key.isImportInProgress, snapshot.isImportInProgress) setContext(Key.isSyncResetInProgress, snapshot.isSyncResetInProgress) setContext(Key.isWithinRecoveryGracePeriod, snapshot.isWithinRecoveryGracePeriod) diff --git a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift new file mode 100644 index 00000000..fbdf71d3 --- /dev/null +++ b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift @@ -0,0 +1,499 @@ +import XCTest +import CoreData +import RxSwift +@testable import ACNH_wiki + +final class CoreDataStorageICloudResetTests: XCTestCase { + + /// 테스트마다 격리된 UserDefaults suite. `.standard`(실제 앱 suite)를 건드리지 않아 + /// 실기기에서 `make test`를 돌려도 production 앱의 known-user/recovery 플래그를 오염시키지 않는다. + private var testSuiteName: String! + private var testDefaults: UserDefaults! + + override func setUp() { + super.setUp() + testSuiteName = "CoreDataStorageICloudResetTests.\(UUID().uuidString)" + testDefaults = UserDefaults(suiteName: testSuiteName) + } + + override func tearDown() { + testDefaults.removePersistentDomain(forName: testSuiteName) + testDefaults = nil + testSuiteName = nil + super.tearDown() + } + + func testRecoveryGraceDoesNotCreateEmptyUserCollectionForKnownUser() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let existingUserCollection = try insertUserCollection(in: context) + try context.save() + + _ = try storage.getUserCollection(context) + XCTAssertTrue(storage.hasEverHadUserCollection) + + context.delete(existingUserCollection) + try context.save() + storage.markRecoveryInitiated() + + XCTAssertThrowsError(try storage.getUserCollection(context)) { error in + assertNotFound(error) + } + XCTAssertTrue(context.insertedObjects.isEmpty) + XCTAssertEqual(try userCollectionCount(in: context), 0) + } + + func testFirstImportTimeoutDoesNotCreateUserCollectionBeforeImportArrives() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + + XCTAssertThrowsError(try storage.getUserCollection(context)) { error in + assertNotFound(error) + } + XCTAssertTrue(context.insertedObjects.isEmpty) + XCTAssertEqual(try userCollectionCount(in: context), 0) + } + + func testNoICloudPathStillAllowsFreshLocalUserCollection() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .noICloud) + + let userCollection = try storage.getUserCollection(context) + XCTAssertFalse(userCollection.isDeleted) + XCTAssertEqual(context.insertedObjects.count, 1) + } + + func testTimeoutAfterAlreadyObservedImportDoesNotKeepTimeoutSuppression() throws { + let storage = try makeStorage() + + storage.lastSuccessfulImportDate = Date() + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + + XCTAssertFalse(storage.isFirstImportTimedOut) + XCTAssertFalse(storage.shouldSuppressDataCreation) + } + + func testAppFlowFetchTasksDuringRecoveryGraceDoesNotCreateEmptyLocalDataWithoutICloudAccount() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let existingUserCollection = try insertUserCollection(in: context) + try context.save() + + _ = try storage.getUserCollection(context) + XCTAssertTrue(storage.hasEverHadUserCollection) + + context.delete(existingUserCollection) + try context.save() + storage.markRecoveryInitiated() + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + assertReadErrorWrappingNotFound(result) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 0) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 0) + } + + func testAppFlowFetchTasksAfterFirstImportTimeoutDoesNotCreateEmptyLocalDataWithoutICloudAccount() throws { + let storage = try makeStorage() + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + assertReadErrorWrappingNotFound(result) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 0) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 0) + } + + func testFailedCloudImportAfterTimeoutKeepsSuppressionAndDoesNotCreateEmptyData() throws { + let storage = try makeStorage() + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + storage.markImportInProgressForTesting() + storage.finishCloudImportForTesting(succeeded: false) + + XCTAssertTrue(storage.isFirstImportTimedOut) + XCTAssertFalse(storage.isImportInProgress) + XCTAssertTrue(storage.shouldSuppressDataCreation) + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + assertReadErrorWrappingNotFound(result) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 0) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 0) + } + + func testFailedCloudImportDuringSyncResetKeepsResetSuppressionAndDoesNotCreateEmptyData() throws { + let storage = try makeStorage() + + storage.markSyncResetInProgressForTesting() + storage.markImportInProgressForTesting() + storage.finishCloudImportForTesting(succeeded: false) + + XCTAssertTrue(storage.isSyncResetInProgress) + XCTAssertFalse(storage.isImportInProgress) + XCTAssertTrue(storage.shouldSuppressDataCreation) + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + assertReadErrorWrappingNotFound(result) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 0) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 0) + } + + func testSuccessfulCloudImportClearsResetAndTimeoutSuppression() throws { + let storage = try makeStorage() + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + storage.markSyncResetInProgressForTesting() + storage.markImportInProgressForTesting() + storage.finishCloudImportForTesting(succeeded: true) + + XCTAssertFalse(storage.isFirstImportTimedOut) + XCTAssertFalse(storage.isImportInProgress) + XCTAssertFalse(storage.isSyncResetInProgress) + } + + func testTransientICloudAccountStatusesKeepFirstImportUnknown() { + XCTAssertNil(SceneDelegate.firstImportWaitCompletionReason(for: .available)) + XCTAssertEqual(SceneDelegate.firstImportWaitCompletionReason(for: .noAccount), .noICloud) + XCTAssertEqual(SceneDelegate.firstImportWaitCompletionReason(for: .restricted), .noICloud) + XCTAssertEqual(SceneDelegate.firstImportWaitCompletionReason(for: .temporarilyUnavailable), .timeout) + XCTAssertEqual(SceneDelegate.firstImportWaitCompletionReason(for: .couldNotDetermine), .timeout) + } + + func testSafetySnapshotWriteUsesBackgroundSafeProtectionAndExcludesBackup() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + try insertUserCollection(in: context) + try context.save() + + let snapshotDirectory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: snapshotDirectory) } + var appliedProtection: FileProtectionType? + let service = SafetySnapshotService( + containerProvider: { storage.persistentContainer }, + snapshotDirectoryProvider: { snapshotDirectory }, + fileAttributeSetter: { _, attributes in + appliedProtection = attributes[.protectionKey] as? FileProtectionType + } + ) + + service.flushNow() + + XCTAssertTrue(FileManager.default.fileExists(atPath: service.snapshotURL.path)) + let resourceValues = try service.snapshotURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) + XCTAssertEqual(resourceValues.isExcludedFromBackup, true) + XCTAssertEqual(appliedProtection, .completeUntilFirstUserAuthentication) + } + + func testSafetySnapshotRestoreFailureAfterWipeRollsBackExistingCollection() throws { + enum InjectedRestoreFailure: Error { + case failure + } + + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + try insertUserCollection(in: context) + try context.save() + + let snapshotDirectory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: snapshotDirectory) } + let snapshot = try UserCollectionSnapshot.dump(from: context) + let snapshotURL = snapshotDirectory.appendingPathComponent("local_safety_snapshot.plist") + try snapshot.toData().write(to: snapshotURL) + + let service = SafetySnapshotService( + containerProvider: { storage.persistentContainer }, + snapshotDirectoryProvider: { snapshotDirectory } + ) + service.beforeApplyingSnapshotForTesting = { _ in throw InjectedRestoreFailure.failure } + + let expectation = expectation(description: "restore completes") + service.restore { outcome in + guard case .failed(let error) = outcome else { + XCTFail("Expected restore failure after injected error, got \(outcome)") + expectation.fulfill() + return + } + XCTAssertTrue(error is InjectedRestoreFailure) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 2) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 1) + } + + func testAppFlowFetchTasksNoICloudFreshUserStillCreatesLocalDefaults() throws { + let storage = try makeStorage() + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .noICloud) + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + guard case .success(let tasks) = result else { + XCTFail("Expected fetchTasks to succeed for no-iCloud fresh user, got \(result)") + return + } + XCTAssertEqual(tasks.count, DailyTask.tasks.count) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 1) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), DailyTask.tasks.count) + } + + func testManualConsolidationDuringPostImportGracePeriodKeepsTransientOrphans() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let userCollection = try insertUserCollection(in: context) + try insertDailyTask(in: context, linkedTo: userCollection) + try insertDailyTask(in: context, linkedTo: nil) + try context.save() + + // 성공 import 직후 — CloudKit relationship 해소 대기용 grace period 활성 + storage.finishCloudImportForTesting(succeeded: true) + + waitForManualConsolidation(of: storage) + + // grace period 내에는 일시적 orphan을 삭제하지 않아야 한다 + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 2) + } + + func testManualConsolidationOutsideGracePeriodStillDeletesOrphans() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let userCollection = try insertUserCollection(in: context) + try insertDailyTask(in: context, linkedTo: userCollection) + try insertDailyTask(in: context, linkedTo: nil) + try context.save() + + waitForManualConsolidation(of: storage) + + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 1) + } + + func testSuccessfulImportClearsRecoveryGraceOnceCollectionRestored() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + try insertUserCollection(in: context) + try context.save() + storage.markRecoveryInitiated() + XCTAssertTrue(storage.isWithinRecoveryGracePeriod) + + storage.finishCloudImportForTesting(succeeded: true) + + // 정리는 background context에서 수행되므로 폴링으로 대기 + let deadline = Date().addingTimeInterval(5) + while storage.isWithinRecoveryGracePeriod && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + XCTAssertFalse(storage.isWithinRecoveryGracePeriod) + } + + func testFailedImportKeepsRecoveryGrace() throws { + let storage = try makeStorage() + + storage.markRecoveryInitiated() + storage.finishCloudImportForTesting(succeeded: false) + + XCTAssertTrue(storage.isWithinRecoveryGracePeriod) + } + + func testSuccessfulImportWithoutCollectionKeepsRecoveryGrace() throws { + let storage = try makeStorage() + + storage.markRecoveryInitiated() + storage.finishCloudImportForTesting(succeeded: true) + + // UC가 복구되지 않았다면 grace 타임스탬프는 유지되어야 한다 + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + XCTAssertTrue(storage.isWithinRecoveryGracePeriod) + } + + func testSyncProtectionStateDetectsSuppressedEmptyCollection() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + XCTAssertFalse(storage.isAwaitingCloudDataWithoutCollection) + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + XCTAssertTrue(storage.isAwaitingCloudDataWithoutCollection) + + // UC가 생기면 더 이상 보호 모드 안내 대상이 아니다 + try insertUserCollection(in: context) + try context.save() + XCTAssertFalse(storage.isAwaitingCloudDataWithoutCollection) + } +} + +// MARK: - Helpers + +extension CoreDataStorageICloudResetTests { + + private func makeStorage() throws -> CoreDataStorage { + let model = try makeManagedObjectModel() + let container = NSPersistentCloudKitContainer(name: "CoreDataStorage", managedObjectModel: model) + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + description.shouldAddStoreAsynchronously = false + container.persistentStoreDescriptions = [description] + + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + } + if let loadError { + throw loadError + } + + return CoreDataStorage(testingPersistentContainer: container, userDefaults: testDefaults) + } + + private func makeManagedObjectModel() throws -> NSManagedObjectModel { + let bundle = Bundle(for: CoreDataStorage.self) + guard let modelURL = bundle.url(forResource: "CoreDataStorage", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) else { + throw TestError.modelNotFound + } + return model + } + + @discardableResult + private func insertUserCollection( + in context: NSManagedObjectContext, + userInfo: UserInfo = UserInfo() + ) throws -> UserCollectionEntity { + guard let object = NSEntityDescription.insertNewObject( + forEntityName: "UserCollectionEntity", + into: context + ) as? UserCollectionEntity else { + throw TestError.entityCastFailed + } + object.name = userInfo.name + object.islandName = userInfo.islandName + object.islandFruit = userInfo.islandFruit.imageName + object.hemisphere = userInfo.hemisphere.rawValue.capitalized + object.islandReputation = Int16(userInfo.islandReputation) + return object + } + + @discardableResult + private func insertDailyTask( + in context: NSManagedObjectContext, + linkedTo userCollection: UserCollectionEntity? + ) throws -> DailyTaskEntity { + guard let object = NSEntityDescription.insertNewObject( + forEntityName: "DailyTaskEntity", + into: context + ) as? DailyTaskEntity else { + throw TestError.entityCastFailed + } + object.id = UUID() + object.name = "task" + object.setValue(userCollection, forKey: "userCollection") + return object + } + + private func waitForManualConsolidation(of storage: CoreDataStorage) { + let expectation = expectation(description: "consolidation") + storage.consolidateUserCollectionsManually { + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + + private func userCollectionCount(in context: NSManagedObjectContext) throws -> Int { + let request = UserCollectionEntity.fetchRequest() + return try context.count(for: request) + } + + private func entityCount(_ entityName: String, in storage: CoreDataStorage) throws -> Int { + let context = storage.persistentContainer.newBackgroundContext() + var count = 0 + var caughtError: Error? + + context.performAndWait { + let request = NSFetchRequest(entityName: entityName) + do { + count = try context.count(for: request) + } catch { + caughtError = error + } + } + + if let caughtError { + throw caughtError + } + return count + } + + private func waitForFetchTasks(using storage: CoreDataDailyTaskStorage) -> Result<[DailyTask], Error> { + let expectation = expectation(description: "fetchTasks") + var result: Result<[DailyTask], Error>? + + let disposable = storage.fetchTasks().subscribe( + onSuccess: { tasks in + result = .success(tasks) + expectation.fulfill() + }, + onFailure: { error in + result = .failure(error) + expectation.fulfill() + } + ) + + wait(for: [expectation], timeout: 10) + disposable.dispose() + return result ?? .failure(TestError.timeout) + } + + private func assertNotFound(_ error: Error, file: StaticString = #filePath, line: UInt = #line) { + guard case CoreDataStorageError.notFound = error else { + XCTFail("Expected CoreDataStorageError.notFound, got \(error)", file: file, line: line) + return + } + } + + private func assertReadErrorWrappingNotFound( + _ result: Result<[DailyTask], Error>, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case .failure(let error) = result else { + XCTFail("Expected fetchTasks to fail, got \(result)", file: file, line: line) + return + } + guard case CoreDataStorageError.readError(let underlying) = error else { + XCTFail("Expected CoreDataStorageError.readError, got \(error)", file: file, line: line) + return + } + assertNotFound(underlying, file: file, line: line) + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private enum TestError: Error { + case modelNotFound + case entityCastFailed + case timeout + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 40482fbd..7e0f5d0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,10 +27,11 @@ UIKit + RxSwift + ReactorKit. 단일 App 모듈, Tuist 빌드 시스템. ## Build Commands ```bash -mise install # Tuist 4.115.1 설치 +mise install # Tuist 4.152.0 설치 mise x -- tuist install # SPM 의존성 설치 mise x -- tuist generate --no-open # Xcode 프로젝트 생성 mise x -- tuist build # CLI 빌드 +make test # 핵심 유닛 테스트 실행 swiftlint --config .swiftlint.yml # 린트 검사 swiftlint --config .swiftlint.yml --fix # 자동 수정 make validate # 아키텍처 + 패턴 검증 diff --git a/Makefile b/Makefile index c4acb233..245b9cc8 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,13 @@ # Usage: # make — Show available targets # make setup — One-time project bootstrap -# make ci — Full CI pipeline (lint + validate + build) +# make ci — Full CI pipeline (lint + validate + test + build) # # ============================================================================ .DEFAULT_GOAL := help -.PHONY: setup build lint lint-fix validate-arch validate-patterns validate-docs validate ci hooks help +.PHONY: setup build test lint lint-fix validate-arch validate-patterns validate-docs validate ci hooks help # ---------------------------------------------------------------------------- # Setup @@ -39,6 +39,15 @@ hooks: ## Configure git to use .githooks/ directory build: ## Build the project with Tuist mise x -- tuist build +test: ## Run focused unit tests + xcodebuild \ + -workspace Animal-Crossing-Wiki.xcworkspace \ + -scheme ACNH-wiki \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -configuration Debug \ + -only-testing:ACNH-wikiTests/CoreDataStorageICloudResetTests \ + test + # ---------------------------------------------------------------------------- # Lint # ---------------------------------------------------------------------------- @@ -68,7 +77,7 @@ validate: validate-arch validate-patterns validate-docs ## Run all validations ( # CI # ---------------------------------------------------------------------------- -ci: lint validate build ## Full CI check: lint + validate + build +ci: lint validate test build ## Full CI check: lint + validate + test + build # ---------------------------------------------------------------------------- # Help diff --git a/Tuist/Package.swift b/Tuist/Package.swift index c0963db2..1d7658ad 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -18,12 +18,7 @@ import PackageDescription "ReactorKit": .framework, "RxCocoaRuntime" : .framework, "RxRelay" : .framework - ], - baseSettings: .settings( - base: [ - "EXCLUDED_ARCHS[sdk=iphonesimulator*]": "arm64" - ] - ) + ] ) #endif diff --git a/docs/features/icloud-sync.md b/docs/features/icloud-sync.md index 58526bc1..bed52d6e 100644 --- a/docs/features/icloud-sync.md +++ b/docs/features/icloud-sync.md @@ -38,6 +38,7 @@ Device A CloudKit Server Device B | `ToastManager.swift` | 전용 UIWindow 기반 토스트 매니저. 레퍼런스 카운팅, 타임아웃, 백그라운드 dismiss | | `ToastView.swift` | Import 상태 토스트 UI (캡슐형 디자인, ActivityIndicator + Label, slide 애니메이션) | | `CloudSyncSplashViewController.swift` | 신규 설치 시 CloudKit Import 대기 스플래시 화면 | +| `SafetySnapshotService.swift` | CloudKit purge/reset에 대비한 로컬 안전 스냅샷 작성 및 수동 복원 | ## Data Sync Flow @@ -107,7 +108,12 @@ setUpUserCollection() → BehaviorRelay.accept() → UI 자동 갱신 | `.networkFailure` / `.networkUnavailable` | 로그 기록 (자동 재시도 대기) | | Export 134301 (merge error) | `retryExportAfterMergeError()` — 최대 3회 지수 백오프 재시도 (5s, 10s, 15s) | | Change Token Expired (CKError 21) | sync reset 감지 → orphan cleanup/UC 생성 억제 (아래 참조) | -| 기타 | `os_log(.error)` 기록 | +| 기타 | `os_log(.error)` 기록 + 보호 모드 안내 (아래) | + +**Sync 보호 모드 안내**: sync 실패가 반복되어 UC가 없는데 생성이 억제된 상태 +(`isAwaitingCloudDataWithoutCollection == true`)라면, `SceneDelegate.handleCloudSyncError`가 +세션당 한 번 "데이터 보호를 위해 새 데이터 생성을 보류 중" Alert를 표시한다. +사용자가 조용한 빈 컬렉션 화면에 갇히는 것을 방지하기 위한 안내. ### Change Token Expired 대응 @@ -147,27 +153,35 @@ markWaitingForFirstImport() ← UC 생성 억제 플래그 CloudSyncSplashViewController 표시 ↓ waitForCloudKitImport(timeout: 10) - ├── import-arrived → setupApp() - ├── no-icloud → setupApp() - └── timeout → setupApp() - ↓ - setupApp() → clearWaitingForFirstImport() - (모든 경로에서 플래그 해제 보장) + ├── import-arrived → setupApp() → completeFirstImportWait(.importArrived) + ├── no-icloud → setupApp() → completeFirstImportWait(.noICloud) + └── timeout → setupApp() → completeFirstImportWait(.timeout) ``` +`timeout`은 "CloudKit에 데이터가 없다"는 의미가 아니라 "아직 확인하지 못했다"는 의미로 처리한다. +따라서 앱은 열리지만 `isFirstImportTimedOut`을 유지하여 빈 `UserCollectionEntity`와 기본 DailyTask 생성을 계속 억제한다. +이후 Import 이벤트가 실제로 성공하면 `handleCloudKitEvent()`가 timeout 상태를 해제하고 Path-B가 데이터를 다시 로드한다. +Import가 에러로 종료되면 CloudKit 데이터 유무가 여전히 불명확하므로 timeout/reset 억제 상태를 유지한다. +단, timeout 콜백보다 먼저 Import 성공 이벤트가 이미 관측된 경우에는 timeout 상태를 남기지 않는다. + +계정 없는 회귀 테스트는 `CoreDataStorageICloudResetTests`에서 관리한다. 이 테스트는 실제 iCloud 로그인 없이 +로컬 store와 sync 플래그를 조작한 뒤 앱 초기화 경로의 `CoreDataDailyTaskStorage.fetchTasks()`까지 실행하여, +애매한 동기화 상태에서 빈 `UserCollectionEntity`나 기본 `DailyTaskEntity`가 생성되면 실패한다. + ## UC Duplication Prevention **문제**: 신규 설치 시 로컬 UC 생성 → CloudKit Import로 기존 UC 도착 → UC 2개 존재 (영구 중복) **해결**: 다중 억제 플래그 + 기존 유저 보호 -`getUserCollection()`에서 UC가 없을 때 새 UC 생성을 억제하는 5가지 조건: +`getUserCollection()`에서 UC가 없을 때 새 UC 생성을 억제하는 6가지 조건: 1. `isWaitingForFirstImport` — 신규 설치 시 Import 완료 전 2. `isImportInProgress` — Import가 진행 중 (timeout 후에도 import가 끝나지 않은 경우) 3. `isSyncResetInProgress` — Change Token Expired 후 re-import 대기 -4. `_firstImportCompletedAt` grace period — 첫 Import 완료 후 120초간 UC 생성 유예 -5. `hasEverHadUserCollection` — 기존 유저 보호 (아래 참조) +4. `isFirstImportTimedOut` — 첫 Import 대기가 timeout됐지만 CloudKit 데이터 유무가 아직 불명확 +5. `_firstImportCompletedAt` grace period — 첫 Import 완료 후 120초간 UC 생성 유예 +6. `hasEverHadUserCollection` — 기존 유저 보호 (아래 참조) 모든 Storage 호출은 `.notFound` 에러를 graceful하게 처리 (`try?` → nil, do-catch → `os_log`). Import 완료 후 Path-B(`setUpUserCollection`)가 재실행되어 데이터가 정상 로드됨. @@ -183,17 +197,19 @@ Import 완료 후 Path-B(`setUpUserCollection`)가 재실행되어 데이터가 - `performCloudKitRecovery()`에서도 플래그 유지 (복구 = 기존 유저) **`shouldSuppressDataCreation` 통합 프로퍼티**: DailyTask 등 외부 Storage에서도 기본값 생성 억제 판단에 사용: -- `isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress` 중 하나라도 true +- `isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress || isFirstImportTimedOut` 중 하나라도 true - `isWithinGracePeriod` — 첫 Import 완료 후 `gracePeriodSeconds` (120초) 내 - 주의: `hasEverHadUserCollection`은 포함하지 않음 — `getUserCollection()`에서만 사용 (포함 시 DailyTask 자동 생성 영구 차단) -**기존 중복 정리**: `consolidateUserCollections()` — 앱 시작/Import 완료 시 자동 실행 (5초 지연, DispatchWorkItem으로 중복 방지): +**기존 중복 정리**: `consolidateUserCollectionsManually()` — 설정 화면에서 사용자가 명시적으로 실행: - UC가 2개 이상이면 관계(relationships)가 가장 많은 UC 보존 - 나머지 UC의 자식 엔티티를 보존 UC로 `reassignRelationships` - 고아 UC 삭제 → CloudKit Export로 iCloud에서도 정리 **Orphan Cleanup 안전장치** (`cleanupOrphanedEntities()`): - Import 또는 sync reset 진행 중에는 실행하지 않음 (relationship이 아직 해소되지 않았을 수 있음) +- 첫 Import 완료 후 grace period(120초) 내에도 실행하지 않음 — CloudKit이 relationship을 + 비동기로 해소하는 동안 일시적 orphan을 실제 orphan으로 오판해 삭제하는 것을 방지 - UC가 0개이면 실행하지 않음 (orphan 판단 기준 자체 없음) - 전체 레코드가 모두 orphan이면 삭제하지 않음 (데이터 유실 방지) - Count-first 최적화: 삭제 전 수량만 확인하여 불필요한 객체 로딩 방지 @@ -237,8 +253,9 @@ Import 완료 후 Path-B(`setUpUserCollection`)가 재실행되어 데이터가 5. `NSPersistentCloudKitContainer`가 CloudKit에서 전체 데이터 자동 import **Recovery Grace Period (10분)**: -- 재시작 후 CloudKit import가 지연되거나 실패해도 앱이 사용 가능 상태가 되도록 보장 -- `getUserCollection()`에서 `hasEverHadUserCollection == true`이더라도 grace 기간 내에는 UC 신규 생성 허용 +- 재시작 후 CloudKit import가 지연되거나 실패한 상태를 UI/로그에서 구분하기 위한 표시용 플래그 +- `getUserCollection()`은 grace 기간 안에서도 `hasEverHadUserCollection == true`이면 UC 신규 생성을 막음 +- 빈 UC를 만들면 CloudKit으로 빈 데이터가 Export되어 기존 iCloud 데이터를 오염시킬 수 있으므로 허용하지 않음 - 10분 경과 또는 정상 import 완료 시 플래그 자동 정리 **관련 파일**: @@ -248,6 +265,15 @@ Import 완료 후 Path-B(`setUpUserCollection`)가 재실행되어 데이터가 - `DashboardCoordinator.showRecoveryResultAlert()` - `Localizable.strings` (ko/en) — 복구 관련 문자열 +### Local Safety Snapshot + +`SafetySnapshotService`는 UC 그래프를 `local_safety_snapshot.plist`로 유지한다. +CloudKit import, remote change, sync reset 직전에는 최신 로컬 상태를 스냅샷으로 남겨 iOS가 Core Data store를 purge해도 사용자가 수동 복원할 수 있게 한다. + +- 스냅샷 파일은 첫 잠금 해제 후 백그라운드 CloudKit flush에서도 갱신될 수 있도록 `completeUntilFirstUserAuthentication` 보호 등급으로 저장한다. +- 파일은 기기/iCloud 백업에서 제외하여 Core Data 원본과 별도로 장기 보관되지 않게 한다. +- 복원은 `wipeExistingCollection → snapshot.apply → context.save()`를 단일 context rollback 경계에 묶는다. 중간 실패 시 기존 로컬 컬렉션 삭제가 저장되지 않는다. + ## Manual Consolidation (중복/고아 데이터 정리) **3.2.4부터 자동 consolidation 제거됨** — 로컬 데이터가 의도치 않게 삭제되는 버그로 인해, @@ -318,7 +344,7 @@ Crashlytics(세션 breadcrumb + custom keys + 비치명 에러)와 Analytics(집 | `sync_recovery_triggered` | `performCloudKitRecovery` 성공 — 사용자가 설정에서 복원 실행 | | `sync_orphan_cleanup` | `cleanupOrphanedEntities`가 실제로 레코드 삭제 (entity/count 파라미터) | | `sync_uc_consolidated` | 중복 UC가 통합됨 (uc_total/kept_relationships) | -| `sync_uc_created` | 새 UC 생성 (path: fresh_user \| recovery_grace) | +| `sync_uc_created` | 새 UC 생성 (path: fresh_user) | | `sync_uc_creation_suppressed` | UC 생성이 억제됨 (reason: sync_in_progress \| grace_period) | | `sync_token_expired` | `NSCloudKitMirroringDelegateWillReset` 감지 | | `sync_user_collection_missing` | **핵심 증상**: hasEverHadUC=true인데 UC=0 | @@ -327,7 +353,7 @@ Crashlytics(세션 breadcrumb + custom keys + 비치명 에러)와 Analytics(집 ### Crashlytics Custom Keys (세션 스냅샷) `logSyncDiagnostics(phase:throttled:)` 호출 시 os_log 진단 + `Log.snapshot` 갱신을 한 번의 background fetch로 수행. -Import 종료, UC 생성(recovery grace), UC missing 시점에 자동 전송. UC missing처럼 즉시 컨텍스트가 필요한 +Import 종료, UC missing 시점에 자동 전송. UC missing처럼 즉시 컨텍스트가 필요한 경우 `throttled: false` 로 호출하여 5초 throttle을 우회. - `sync_uc_count` / `sync_item_count` / `sync_task_count` / `sync_villager_count` diff --git a/docs/features/music-player.md b/docs/features/music-player.md index 5d0e68b1..dbd49bde 100644 --- a/docs/features/music-player.md +++ b/docs/features/music-player.md @@ -61,5 +61,5 @@ PlayerViewController UI 업데이트 ## 주의사항 -- 별도 Coordinator를 만들지 말 것 → [gotchas.md](../gotchas.md) #10 +- 별도 Coordinator를 만들지 말 것 → [gotchas.md](../gotchas.md) #11 - `PlayerReactor`의 coordinator 타입은 `AppCoordinator` diff --git a/docs/gotchas.md b/docs/gotchas.md index c7b4b35d..093f940a 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -73,10 +73,15 @@ Dashboard 폴더 안이지만 **앱 전체에서 사용**됨. 이동하지 말 ## 9. Tuist 버전 -`.mise.toml`에 `tuist = "4.115.1"` 지정. 항상 `mise x -- tuist` 접두어로 명령어 실행. +`.mise.toml`에 `tuist = "4.152.0"` 지정. 항상 `mise x -- tuist` 접두어로 명령어 실행. 직접 `tuist` 호출 시 다른 버전이 실행될 수 있음. -## 10. MusicPlayer에는 Coordinator가 없음 +## 10. Apple Silicon simulator는 arm64 필요 + +Xcode 26.5의 iOS simulator runtime은 arm64 destination으로 잡힌다. +`EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64` 설정을 추가하면 실제 simulator가 destination 목록에서 사라져 테스트가 실행되지 않을 수 있음. + +## 11. MusicPlayer에는 Coordinator가 없음 `PlayerViewController`는 **AppCoordinator**가 직접 관리. 탭바 위에 오버레이로 표시되며 `showMusicPlayer()`, `minimize()`, `maximize()`, `removePlayerViewController()`로 제어. diff --git a/docs/guides/build-and-run.md b/docs/guides/build-and-run.md index c7b4e8c9..4373b6c7 100644 --- a/docs/guides/build-and-run.md +++ b/docs/guides/build-and-run.md @@ -4,7 +4,7 @@ - macOS, Xcode 16+ - Mise (toolchain manager) -- Tuist 4.115.1 (`.mise.toml`에 고정) +- Tuist 4.152.0 (`.mise.toml`에 고정) ## Setup @@ -38,10 +38,17 @@ xcodebuild \ -destination generic/platform="iOS Simulator" \ -configuration Debug \ build + +# 특정 유닛 테스트 실행 +make test ``` > 설정 변경 후 반드시 `mise x -- tuist generate` 재실행 +Apple Silicon + Xcode 26.5 환경에서는 iOS 26.5 simulator runtime을 설치하고 arm64 simulator로 빌드한다. +`EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64`를 되살리면 Xcode가 실제 simulator destination을 찾지 못하고 +`Any iOS Simulator Device` placeholder만 표시할 수 있다. + ## SwiftLint ```bash @@ -66,7 +73,7 @@ swiftlint --config .swiftlint.yml --fix | 항목 | 값 | |------|---| | 최소 길이 (warning) | **2자** | -| 최소 길이 (error) | **4자** | +| 최소 길이 (error) | — | | 최대 길이 | 40자 (기본값) | | 예외 허용 | `a`, `b` | @@ -183,9 +190,12 @@ make ci |------|---| | Trigger | PR to `develop` 또는 PR 코멘트 `/build` | | Runner | `macos-26` | -| Xcode | 26.1.1 | +| Xcode | 26.5 | | Swift | 6.2 | +CI는 `develop-build.yml`에서 빌드 후 `ACNH-wikiTests/CoreDataStorageICloudResetTests`를 실행하여 +iCloud 동기화 초기화 회귀 테스트를 PR 체크에 포함한다. + ## Project Config Files | File | 역할 | diff --git a/docs/iCloud-data-loss-fix-summary.md b/docs/iCloud-data-loss-fix-summary.md index 73c8f47a..b76c6a3f 100644 --- a/docs/iCloud-data-loss-fix-summary.md +++ b/docs/iCloud-data-loss-fix-summary.md @@ -123,13 +123,14 @@ iOS에는 **"CloudKit 동기화 토큰이 만료됐다"** 고 판단되면 **우 ## 🛡️ 사용자 보호 강화 포인트 -### "영구적으로 앱이 잠기는 문제" 방지 +### "빈 데이터가 iCloud로 다시 올라가는 문제" 방지 기존에는 "iCloud에서 복원"을 누르고 앱 재시작 후, iCloud 네트워크가 느리면 -**앱이 아무 데이터도 못 보여주고 무한 대기**하는 경우가 있었습니다. +로컬 `UserCollection`이 0개인 상태를 새 사용자로 오판해 빈 데이터를 만들 수 있었습니다. -이제는 복원 시작 후 **10분 동안 유예 시간**을 두어, -iCloud 복구가 늦어지더라도 앱이 정상적으로 열리도록 했습니다. +이제는 복원 시작 후 **10분 동안 복구 유예 상태를 기록**하되, +그 기간에도 기존 사용자(`hasEverHadUserCollection == true`)라면 빈 `UserCollection` 생성을 막습니다. +iCloud 복구가 늦어지면 앱은 빈 데이터를 내보내지 않고, CloudKit Import 또는 로컬 백업 복원을 기다립니다. ### "실수 방지"를 위한 2단 확인 @@ -155,7 +156,7 @@ iCloud 복구가 늦어지더라도 앱이 정상적으로 열리도록 했습 | iOS가 로컬을 지웠는데 iCloud 복구 실패 | ❌ **데이터 유실** | ✅ 로컬 백업에서 복원 가능 | | iCloud 동기화 도중 우리 앱이 실수로 삭제 | ❌ **데이터 유실** | ✅ 삭제 경로가 막혔음 | | 사용자가 직접 복원 버튼 누름 | ⚠️ 1단 확인 | ✅ 2단 확인 + 경고 문구 | -| 복원 후 재시작 시 iCloud 느림 | ❌ 앱 잠김 | ✅ 10분 유예 후 정상 동작 | +| 복원 후 재시작 시 iCloud 느림 | ❌ 앱 잠김 또는 빈 데이터 생성 | ✅ 빈 데이터 Export 차단 + 복구/백업 대기 | ---