Skip to content

Commit e37b4a8

Browse files
committed
fix cloudkit initial settings, settings sync and dev fallback fix
Signed-off-by: Joseph Mattiello <git@joemattiello.com>
1 parent 7b4f82a commit e37b4a8

File tree

9 files changed

+290
-55
lines changed

9 files changed

+290
-55
lines changed

PVLibrary/Sources/PVLibrary/Cloud Sync/iCloud/CloudKit/CloudKitRomsSyncer.swift

Lines changed: 75 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -631,48 +631,40 @@ public class CloudKitRomsSyncer: NSObject, RomsSyncing {
631631
}
632632

633633
return try await runOnQueue { [self] in
634+
let desiredKeys = [
635+
CloudKitSchema.ROMFields.md5,
636+
CloudKitSchema.ROMFields.title,
637+
CloudKitSchema.ROMFields.systemIdentifier,
638+
CloudKitSchema.ROMFields.fileSize,
639+
CloudKitSchema.ROMFields.originalFilename,
640+
CloudKitSchema.ROMFields.originalArtworkURL,
641+
CloudKitSchema.ROMFields.customArtworkURL,
642+
CloudKitSchema.ROMFields.isDeleted,
643+
CloudKitSchema.ROMFields.fileData,
644+
CloudKitSchema.ROMFields.isArchive,
645+
CloudKitSchema.ROMFields.relatedFilenames,
646+
CloudKitSchema.ROMFields.customArtworkAsset
647+
]
648+
634649
let downloadStartTime = Date()
635650
var lastProgressUpdate = Date()
636651
var lastProgress: Double = 0
637652

638-
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<CKRecord?, Error>) in
639-
let op = CKFetchRecordsOperation(recordIDs: [recordID])
640-
641-
// Include all fields including assets
642-
op.desiredKeys = [
643-
CloudKitSchema.ROMFields.md5,
644-
CloudKitSchema.ROMFields.title,
645-
CloudKitSchema.ROMFields.systemIdentifier,
646-
CloudKitSchema.ROMFields.fileSize,
647-
CloudKitSchema.ROMFields.originalFilename,
648-
CloudKitSchema.ROMFields.originalArtworkURL,
649-
CloudKitSchema.ROMFields.customArtworkURL,
650-
CloudKitSchema.ROMFields.isDeleted,
651-
CloudKitSchema.ROMFields.fileData,
652-
CloudKitSchema.ROMFields.isArchive,
653-
CloudKitSchema.ROMFields.relatedFilenames,
654-
CloudKitSchema.ROMFields.customArtworkAsset
655-
]
656-
657-
// Track download progress with speed calculation
658-
op.perRecordProgressBlock = { [expectedSize] _, progress in
653+
let makeProgressBlock: () -> ((CKRecord.ID, Double) -> Void) = {
654+
return { [expectedSize] _, progress in
659655
let now = Date()
660656
let elapsed = now.timeIntervalSince(downloadStartTime)
661657

662-
// Calculate speed string
663658
var speedString: String? = nil
664659
if elapsed > 0.5 && progress > 0.01 {
665660
if let totalSize = expectedSize, totalSize > 0 {
666-
// Calculate based on known file size
667661
let downloadedBytes = Double(totalSize) * progress
668662
let bytesPerSecond = downloadedBytes / elapsed
669663
speedString = Self.formatSpeed(bytesPerSecond)
670664
} else {
671-
// Estimate based on progress rate
672665
let progressDelta = progress - lastProgress
673666
let timeDelta = now.timeIntervalSince(lastProgressUpdate)
674667
if timeDelta > 0.1 && progressDelta > 0 {
675-
// Rough estimate assuming ~10MB average ROM
676668
let estimatedBytesPerSecond = (progressDelta / timeDelta) * 10_000_000
677669
speedString = Self.formatSpeed(estimatedBytesPerSecond)
678670
}
@@ -685,38 +677,73 @@ public class CloudKitRomsSyncer: NSObject, RomsSyncing {
685677
DLOG("[SYNC] Download progress: \(Int(progress * 100))% \(speedString ?? "")")
686678
progressHandler?(progress, speedString)
687679
}
680+
}
688681

689-
var fetched: CKRecord?
690-
op.perRecordResultBlock = { _, result in
691-
if case let .success(r) = result { fetched = r }
692-
}
682+
/// Fetch from a specific database, returning nil for "not found" errors.
683+
func fetchFrom(_ db: CKDatabase) async throws -> CKRecord? {
684+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<CKRecord?, Error>) in
685+
let op = CKFetchRecordsOperation(recordIDs: [recordID])
686+
op.desiredKeys = desiredKeys
687+
op.perRecordProgressBlock = makeProgressBlock()
693688

694-
op.fetchRecordsCompletionBlock = { _, error in
695-
if let error = error as? CKError {
696-
if error.code == .unknownItem {
697-
continuation.resume(returning: nil)
698-
} else if error.code == .partialFailure,
699-
let partialErrors = error.partialErrorsByItemID,
700-
partialErrors.count == 1,
701-
let (_, partialError) = partialErrors.first,
702-
let partialCKError = partialError as? CKError,
703-
partialCKError.code == .unknownItem {
704-
continuation.resume(returning: nil)
705-
} else {
689+
var fetched: CKRecord?
690+
op.perRecordResultBlock = { _, result in
691+
if case let .success(r) = result { fetched = r }
692+
}
693+
694+
op.fetchRecordsCompletionBlock = { _, error in
695+
if let error = error as? CKError {
696+
if error.code == .unknownItem {
697+
continuation.resume(returning: nil)
698+
} else if error.code == .partialFailure,
699+
let partialErrors = error.partialErrorsByItemID,
700+
partialErrors.count == 1,
701+
let (_, partialError) = partialErrors.first,
702+
let partialCKError = partialError as? CKError,
703+
partialCKError.code == .unknownItem {
704+
continuation.resume(returning: nil)
705+
} else {
706+
continuation.resume(throwing: CloudSyncError.cloudKitError(error))
707+
}
708+
} else if let error = error {
706709
continuation.resume(throwing: CloudSyncError.cloudKitError(error))
710+
} else {
711+
continuation.resume(returning: fetched)
707712
}
708-
} else if let error = error {
709-
continuation.resume(throwing: CloudSyncError.cloudKitError(error))
710-
} else {
711-
continuation.resume(returning: fetched)
712713
}
714+
715+
op.qualityOfService = .userInitiated
716+
db.add(op)
713717
}
718+
}
714719

715-
// Set quality of service for faster downloads
716-
op.qualityOfService = .userInitiated
720+
// Try primary database first
721+
if let record = try await fetchFrom(self.database) {
722+
return record
723+
}
717724

718-
self.database.add(op)
725+
// Try fallback databases (e.g. dev container in production/TestFlight builds)
726+
for fallbackDB in fallbackDatabases {
727+
do {
728+
if let record = try await fetchFrom(fallbackDB) {
729+
DLOG("Fetched record with progress from fallback container: \(record.recordID.recordName)")
730+
return record
731+
}
732+
} catch let syncError as CloudSyncError {
733+
// Unwrap CloudSyncError to check for badContainer
734+
if case .cloudKitError(let inner) = syncError,
735+
let ckError = inner as? CKError, ckError.code == .badContainer {
736+
DLOG("Fallback container not accessible (badContainer) — disabling for session")
737+
iCloudConstants.invalidateFallbackContainers()
738+
break
739+
}
740+
DLOG("Fallback fetch with progress failed: \(syncError.localizedDescription)")
741+
} catch {
742+
DLOG("Fallback fetch with progress failed: \(error.localizedDescription)")
743+
}
719744
}
745+
746+
return nil
720747
}
721748
}
722749

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//
2+
// iCloudSettingsSync.swift
3+
// PVSettings
4+
//
5+
// Bridges key user preferences with NSUbiquitousKeyValueStore so they
6+
// survive app reinstalls (UserDefaults is deleted on uninstall, iCloud KVS is not).
7+
//
8+
9+
import Foundation
10+
import Defaults
11+
12+
/// Syncs critical user preferences to NSUbiquitousKeyValueStore so they persist
13+
/// across app reinstalls. On first launch after reinstall, restores values from
14+
/// iCloud KVS back into UserDefaults (Defaults library).
15+
public enum iCloudSettingsSync {
16+
17+
/// Key used to detect a fresh install (set after first successful sync).
18+
private static let sentinelKey = "org.provenance.settings.iCloudKVS.initialized"
19+
20+
// MARK: - Public API
21+
22+
/// Call once at app launch (e.g. in AppDelegate or App.init).
23+
/// Restores settings from iCloud KVS if this is a fresh install,
24+
/// then starts observing local changes to push them to KVS.
25+
public static func setup() {
26+
let kvs = NSUbiquitousKeyValueStore.default
27+
28+
// Pull latest from iCloud
29+
kvs.synchronize()
30+
31+
// Detect fresh install: sentinel key missing from UserDefaults
32+
let isFirstLaunch = !UserDefaults.standard.bool(forKey: sentinelKey)
33+
34+
if isFirstLaunch {
35+
restoreFromKVS(kvs)
36+
UserDefaults.standard.set(true, forKey: sentinelKey)
37+
}
38+
39+
// Push current values to KVS (in case this is an upgrade or first install)
40+
pushToKVS(kvs)
41+
42+
// Observe external changes from other devices
43+
NotificationCenter.default.addObserver(
44+
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
45+
object: kvs,
46+
queue: .main
47+
) { notification in
48+
handleExternalChange(notification)
49+
}
50+
51+
// Observe local Defaults changes to push to KVS
52+
startObservingDefaults()
53+
}
54+
55+
// MARK: - Restore
56+
57+
private static func restoreFromKVS(_ kvs: NSUbiquitousKeyValueStore) {
58+
// iCloudSync
59+
if let syncValue = kvs.object(forKey: "iCloudSync") as? Bool {
60+
Defaults[.iCloudSync] = syncValue
61+
}
62+
63+
// iCloudSyncMode (stored as String raw value)
64+
if let modeRaw = kvs.object(forKey: "iCloudSyncMode") as? String,
65+
let mode = iCloudSyncMode(rawValue: modeRaw) {
66+
Defaults[.iCloudSyncMode] = mode
67+
}
68+
}
69+
70+
// MARK: - Push
71+
72+
private static func pushToKVS(_ kvs: NSUbiquitousKeyValueStore) {
73+
kvs.set(Defaults[.iCloudSync], forKey: "iCloudSync")
74+
kvs.set(Defaults[.iCloudSyncMode].rawValue, forKey: "iCloudSyncMode")
75+
kvs.synchronize()
76+
}
77+
78+
// MARK: - External change handler
79+
80+
private static func handleExternalChange(_ notification: Notification) {
81+
guard let userInfo = notification.userInfo,
82+
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else { return }
83+
84+
// Only handle server changes and initial sync
85+
guard reason == NSUbiquitousKeyValueStoreServerChange ||
86+
reason == NSUbiquitousKeyValueStoreInitialSyncChange else { return }
87+
88+
guard let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else { return }
89+
90+
let kvs = NSUbiquitousKeyValueStore.default
91+
for key in changedKeys {
92+
switch key {
93+
case "iCloudSync":
94+
if let value = kvs.object(forKey: key) as? Bool {
95+
Defaults[.iCloudSync] = value
96+
}
97+
case "iCloudSyncMode":
98+
if let raw = kvs.object(forKey: key) as? String,
99+
let mode = iCloudSyncMode(rawValue: raw) {
100+
Defaults[.iCloudSyncMode] = mode
101+
}
102+
default:
103+
break
104+
}
105+
}
106+
}
107+
108+
// MARK: - Local observation
109+
110+
nonisolated(unsafe) private static var observations: [any Defaults.Observation] = []
111+
112+
private static func startObservingDefaults() {
113+
let kvs = NSUbiquitousKeyValueStore.default
114+
115+
observations.append(
116+
Defaults.observe(.iCloudSync, options: []) { change in
117+
kvs.set(change.newValue, forKey: "iCloudSync")
118+
kvs.synchronize()
119+
}
120+
)
121+
122+
observations.append(
123+
Defaults.observe(.iCloudSyncMode, options: []) { change in
124+
kvs.set(change.newValue.rawValue, forKey: "iCloudSyncMode")
125+
kvs.synchronize()
126+
}
127+
)
128+
}
129+
}

PVUI/Sources/PVSwiftUI/App Delegate/PVAppDelegate.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ public final class PVAppDelegate: UIResponder, UIApplicationDelegate, Observable
366366

367367
public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
368368
ILOG("PVAppDelegate: Application did finish launching")
369+
370+
// Restore critical user preferences from iCloud KVS (survives reinstalls)
371+
iCloudSettingsSync.setup()
372+
369373
RealmConfiguration.setDefaultRealmConfig()
370374

371375
// Register MetricKit subscriber to capture hang / crash diagnostics passively

PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesView.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ struct ConsoleGamesView: SwiftUI.View {
5454
@Default(.showRecentGames) var showRecentGames: Bool
5555
@Default(.showSearchbar) var showSearchbar: Bool
5656
@Default(.unsupportedCores) var unsupportedCores: Bool
57+
@Default(.iCloudSync) var iCloudSyncEnabled: Bool
5758

5859
// Modal state for log viewer and system status
5960
@State private var showLogViewer = false
@@ -381,6 +382,8 @@ struct ConsoleGamesView: SwiftUI.View {
381382

382383
contentlessSetupGuide
383384

385+
cloudSyncBanner
386+
384387
gamesScrollView
385388

386389
biosesView
@@ -1707,5 +1710,16 @@ extension ConsoleGamesView {
17071710
}
17081711
)
17091712
}
1713+
1714+
/// Compact banner shown above the game list when cloud sync is off but cloud data exists.
1715+
/// Only visible for Plus/sideloaded users who could enable sync.
1716+
@ViewBuilder
1717+
private var cloudSyncBanner: some View {
1718+
if !iCloudSyncEnabled && CloudSyncUpsellView.detectCachedCloudData() {
1719+
cloudSyncUpsell()
1720+
.padding(.horizontal, 8)
1721+
.padding(.vertical, 4)
1722+
}
1723+
}
17101724
}
17111725
#endif

PVUI/Sources/PVSwiftUI/Home/HomeView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ struct HomeView: SwiftUI.View {
3636
@Default(.showSearchbar) private var showSearchbar
3737
@Default(.showFavorites) private var showFavorites
3838
@Default(.showAutoSavesInRecents) private var showAutoSavesInRecents
39+
@Default(.iCloudSync) private var iCloudSyncEnabled
3940

4041
// Import status view properties
4142
@State private var showImportStatusView = false
@@ -194,6 +195,10 @@ struct HomeView: SwiftUI.View {
194195
if bootupStateManager.isBootupCompleted && isLibraryCompletelyEmpty {
195196
cloudSyncUpsell()
196197
.padding(.horizontal)
198+
} else if !iCloudSyncEnabled && CloudSyncUpsellView.detectCachedCloudData() {
199+
cloudSyncUpsell()
200+
.padding(.horizontal)
201+
.padding(.vertical, 4)
197202
}
198203
continuesSection()
199204
.id("section_continues")

0 commit comments

Comments
 (0)