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
12 changes: 11 additions & 1 deletion .github/workflows/develop-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion Animal-Crossing-Wiki/Configurations/Base.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion Animal-Crossing-Wiki/Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "오늘의 할일";
Expand Down
4 changes: 4 additions & 0 deletions Animal-Crossing-Wiki/Projects/App/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

AppAppearance.setUpAppearance()

if AppEnvironment.isUnitTesting {
return true
}

FirebaseApp.configure()
Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true)

Expand Down
Original file line number Diff line number Diff line change
@@ -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, Error>) -> 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))
}
}
}
}
Loading
Loading