Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,19 +101,23 @@ final class CoreDataStorage {

/// 한 번이라도 UserCollectionEntity가 존재했는지 여부 (UserDefaults 기반, 메모리 캐싱)
/// 이 플래그가 true인데 UC가 0개면, 빈 UC 자동 생성 대신 .notFound를 throw
private let _hasEverHadUserCollectionCached = OSAllocatedUnfairLock(
initialState: UserDefaults.standard.bool(forKey: CoreDataStorage.hasEverHadUserCollectionKey)
)
/// 초기값은 init에서 주입된 `userDefaults`로부터 읽는다.
private let _hasEverHadUserCollectionCached: OSAllocatedUnfairLock<Bool>
private(set) var hasEverHadUserCollection: Bool {
get { _hasEverHadUserCollectionCached.withLock { $0 } }
set {
_hasEverHadUserCollectionCached.withLock { cached in
// UserDefaults I/O는 unfair lock 임계영역 밖에서 수행한다 (잠재적 재진입 트랩 회피).
let didChange = _hasEverHadUserCollectionCached.withLock { cached -> Bool in
guard cached != newValue else {
return
return false
}
cached = newValue
UserDefaults.standard.set(newValue, forKey: Self.hasEverHadUserCollectionKey)
return true
}
guard didChange else {
return
}
userDefaults.set(newValue, forKey: Self.hasEverHadUserCollectionKey)
}
}

Expand All @@ -133,25 +137,25 @@ final class CoreDataStorage {

/// 복구 시작 시각 기록 — 재시작 후 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 내인지 확인 — 상태 표시에만 사용하고, UC 생성 허용에는 사용하지 않는다.
var isWithinRecoveryGracePeriod: Bool {
let timestamp = UserDefaults.standard.double(forKey: Self.recoveryInitiatedAtKey)
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
Expand Down Expand Up @@ -190,8 +194,19 @@ final class CoreDataStorage {

private let injectedPersistentContainer: NSPersistentCloudKitContainer?

private init(persistentContainer: NSPersistentCloudKitContainer? = nil) {
/// 동기화 영속 플래그(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 = {
Expand Down Expand Up @@ -284,13 +299,16 @@ final class CoreDataStorage {
/// Fresh install 첫 CloudKit import 대기 종료.
/// timeout은 데이터 없음의 증거가 아니므로 UC 생성을 계속 억제한다.
func completeFirstImportWait(reason: FirstImportWaitCompletionReason) {
isWaitingForFirstImport = false
// 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)")
}

Expand Down Expand Up @@ -466,6 +484,8 @@ final class CoreDataStorage {
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
}
Expand Down Expand Up @@ -631,12 +651,13 @@ extension CoreDataStorage {
}

persistentContainer.performBackgroundTask { [weak self] context in
guard let self else {
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

Expand All @@ -655,14 +676,15 @@ 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개 이상일 때만 상세 진단 (중복 탐지)
Expand Down Expand Up @@ -1039,9 +1061,13 @@ extension CoreDataStorage {
}

#if DEBUG
// MARK: - Testing
extension CoreDataStorage {
convenience init(testingPersistentContainer: NSPersistentCloudKitContainer) {
self.init(persistentContainer: testingPersistentContainer)
convenience init(
testingPersistentContainer: NSPersistentCloudKitContainer,
userDefaults: UserDefaults = .standard
) {
self.init(persistentContainer: testingPersistentContainer, userDefaults: userDefaults)
}

func markImportInProgressForTesting() {
Expand All @@ -1055,11 +1081,6 @@ extension CoreDataStorage {
func finishCloudImportForTesting(succeeded: Bool) {
finishCloudImport(succeeded: succeeded)
}

static func resetPersistentSyncFlagsForTesting() {
UserDefaults.standard.removeObject(forKey: hasEverHadUserCollectionKey)
UserDefaults.standard.removeObject(forKey: recoveryInitiatedAtKey)
}
}
#endif

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ final class SafetySnapshotService {
private let containerProvider: () -> NSPersistentContainer
private let snapshotDirectoryProvider: () -> URL?
private let fileAttributeSetter: (URL, [FileAttributeKey: Any]) throws -> Void
private let beforeApplyingSnapshot: ((NSManagedObjectContext) throws -> Void)?

#if DEBUG
/// 테스트 전용 fault-injection seam. `restore()`가 기존 데이터를 wipe한 직후,
/// 스냅샷을 적용하기 직전에 호출한다. production 빌드에는 컴파일되지 않으므로
/// 복원 경로에 죽은 코드가 남지 않는다.
var beforeApplyingSnapshotForTesting: ((NSManagedObjectContext) throws -> Void)?
#endif

init(
containerProvider: @escaping () -> NSPersistentContainer = { CoreDataStorage.shared.persistentContainer },
Expand All @@ -61,13 +67,11 @@ final class SafetySnapshotService {
},
fileAttributeSetter: @escaping (URL, [FileAttributeKey: Any]) throws -> Void = { url, attributes in
try FileManager.default.setAttributes(attributes, ofItemAtPath: url.path)
},
beforeApplyingSnapshot: ((NSManagedObjectContext) throws -> Void)? = nil
}
) {
self.containerProvider = containerProvider
self.snapshotDirectoryProvider = snapshotDirectoryProvider
self.fileAttributeSetter = fileAttributeSetter
self.beforeApplyingSnapshot = beforeApplyingSnapshot
}

private var container: NSPersistentContainer {
Expand Down Expand Up @@ -178,9 +182,12 @@ final class SafetySnapshotService {
do {
try url.setResourceValues(values)
} catch {
os_log(.error, log: .default,
"🛟 SafetySnapshot resource value update failed: %{public}@",
error.localizedDescription)
// 백업 제외(isExcludedFromBackup) 회귀를 Crashlytics에서 관측할 수 있도록 비치명 에러로 보고한다.
// 파일 보호 등급은 write 옵션 + setAttributes로 이미 적용되므로 암호화 경계 자체는 유지된다.
Log.error(
name: "SafetySnapshotBackupExclusion",
reason: error.localizedDescription
)
}
}

Expand All @@ -204,7 +211,9 @@ final class SafetySnapshotService {
let data = try Data(contentsOf: self.snapshotURL)
let snapshot = try UserCollectionSnapshot.from(data: data)
try Self.wipeExistingCollection(in: context)
try self.beforeApplyingSnapshot?(context)
#if DEBUG
try self.beforeApplyingSnapshotForTesting?(context)
#endif
try snapshot.apply(to: context)
try context.save()
os_log(.error, log: .default,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@
// AppEnvironment.swift
// Animal-Crossing-Wiki
//
// Created by Codex on 2026/06/04.
// 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"
|| NSClassFromString("XCTestCase") != nil
}
}
21 changes: 13 additions & 8 deletions Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,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"
Expand Down Expand Up @@ -159,22 +160,24 @@ enum Log {
// MARK: - Analytics

static func event(_ event: Event, parameters: [String: Any] = [:]) {
if isFirebaseConfigured {
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"
if isFirebaseConfigured {
Analytics.logEvent(AnalyticsEventSelectContent, parameters: params)
}
os_log(.info, log: .default, "👆 click=%{public}@", name)
Analytics.logEvent(AnalyticsEventSelectContent, parameters: params)
}

// MARK: - Context (Crashlytics custom keys)
Expand All @@ -196,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
Expand All @@ -213,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ import RxSwift

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()
CoreDataStorage.resetPersistentSyncFlagsForTesting()
testSuiteName = "CoreDataStorageICloudResetTests.\(UUID().uuidString)"
testDefaults = UserDefaults(suiteName: testSuiteName)
}

override func tearDown() {
CoreDataStorage.resetPersistentSyncFlagsForTesting()
testDefaults.removePersistentDomain(forName: testSuiteName)
testDefaults = nil
testSuiteName = nil
super.tearDown()
}

Expand Down Expand Up @@ -209,9 +217,9 @@ final class CoreDataStorageICloudResetTests: XCTestCase {

let service = SafetySnapshotService(
containerProvider: { storage.persistentContainer },
snapshotDirectoryProvider: { snapshotDirectory },
beforeApplyingSnapshot: { _ in throw InjectedRestoreFailure.failure }
snapshotDirectoryProvider: { snapshotDirectory }
)
service.beforeApplyingSnapshotForTesting = { _ in throw InjectedRestoreFailure.failure }

let expectation = expectation(description: "restore completes")
service.restore { outcome in
Expand Down Expand Up @@ -246,6 +254,8 @@ final class CoreDataStorageICloudResetTests: XCTestCase {
}
}

// MARK: - Helpers

extension CoreDataStorageICloudResetTests {

private func makeStorage() throws -> CoreDataStorage {
Expand All @@ -264,7 +274,7 @@ extension CoreDataStorageICloudResetTests {
throw loadError
}

return CoreDataStorage(testingPersistentContainer: container)
return CoreDataStorage(testingPersistentContainer: container, userDefaults: testDefaults)
}

private func makeManagedObjectModel() throws -> NSManagedObjectModel {
Expand Down
Loading