Skip to content

Commit 3251ebf

Browse files
committed
cloud sync initial sync tvos logic fixes
Signed-off-by: Joseph Mattiello <git@joemattiello.com>
1 parent 7bb74fd commit 3251ebf

3 files changed

Lines changed: 82 additions & 32 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import PVRealm
1313
import RealmSwift
1414
import Combine
1515
import PVFileSystem
16+
import PVSettings
17+
import Defaults
1618
import RxSwift
1719
import RxCocoa
1820

@@ -216,6 +218,13 @@ public actor CloudKitInitialSyncer {
216218
return 0
217219
}
218220

221+
// metadataOnly: this device is a download-only consumer (e.g. tvOS).
222+
// Skip the initial upload sync — all local files came from CloudKit.
223+
if !forceSync && Defaults[.cloudKitSyncContentType] == .metadataOnly {
224+
ILOG("⏭️ [performInitialSync] Skipping — metadataOnly mode (download-only device)")
225+
return 0
226+
}
227+
219228
// Check if sync is needed (unless forced)
220229
if !forceSync {
221230
DLOG("🔍 [performInitialSync] Checking if initial sync is needed...")
@@ -879,6 +888,13 @@ public actor CloudKitInitialSyncer {
879888
/// - Parameter forceSync: If true, upload all files regardless of existing records
880889
/// - Returns: Dictionary mapping directory names to sync counts
881890
private func syncAllNonDatabaseFiles(forceSync: Bool = false) async -> [String: Int] {
891+
// metadataOnly: skip uploading non-database files (battery saves, screenshots, skins).
892+
// On tvOS fresh install these files came from CloudKit — re-uploading wastes bandwidth.
893+
if !forceSync && Defaults[.cloudKitSyncContentType] == .metadataOnly {
894+
DLOG("Skipping non-database file upload — metadataOnly mode")
895+
return [:]
896+
}
897+
882898
DLOG("Syncing all non-database files to CloudKit...")
883899

884900
do {

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

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import CloudKit
99
import os
1010
import RxSwift
1111
import RealmSwift
12+
import PVSettings
13+
import Defaults
1214

1315
/// Save states syncer for all OS's using CloudKit
1416
public class CloudKitSaveStatesSyncer: CloudKitSyncer, SaveStatesSyncing {
@@ -949,6 +951,19 @@ public class CloudKitSaveStatesSyncer: CloudKitSyncer, SaveStatesSyncing {
949951

950952
Task {
951953
defer { self.isLoadAllInFlight.withLock { $0 = false } }
954+
955+
// metadataOnly: use the lightweight metadata sync instead of full
956+
// conflict resolution, artwork caching, and filesystem probes.
957+
if Defaults[.cloudKitSyncContentType] == .metadataOnly {
958+
let syncLog = CloudSyncManager.syncLog
959+
syncLog.event(.start, item: "save/loadAll", status: .skipped, detail: "metadataOnly — using lightweight sync")
960+
let count = await self.syncMetadataOnly()
961+
syncLog.event(.complete, item: "save/loadAll", status: .ok, detail: "metadataOnly processed \(count) records")
962+
await iterationComplete?()
963+
observer(.completed)
964+
return
965+
}
966+
952967
do {
953968
let syncLog = CloudSyncManager.syncLog
954969
CloudSyncManager.syncLog.event(.start, item: "save/loadAll", status: .inProgress)
@@ -1138,16 +1153,24 @@ public class CloudKitSaveStatesSyncer: CloudKitSyncer, SaveStatesSyncing {
11381153

11391154
var targetSaveState: PVSaveState?
11401155
var needsRomDownload = false
1156+
let isMetadataOnly = Defaults[.cloudKitSyncContentType] == .metadataOnly
11411157

11421158
// Step 2: Handle existing or create new (async work)
11431159
if let existingSaveState = existingSaveState {
1144-
syncLog.event(.sync, item: "save/\(existingSaveState.id)", status: .inProgress, detail: "handling existing")
1145-
await self.refreshLocalDownloadState(for: existingSaveState)
1146-
await self.handleSaveStateConflict(existingSaveState, cloudRecord: record, preferredCoreID: coreHint.coreID, preferredCoreVersion: coreHint.coreVersion)
1147-
targetSaveState = existingSaveState
1148-
// If the game isn't downloaded locally, flag for ROM download
1149-
if existingSaveState.game?.isDownloaded == false {
1150-
needsRomDownload = true
1160+
if isMetadataOnly {
1161+
// metadataOnly: skip conflict resolution and filesystem probes,
1162+
// just ensure cloudRecordID is linked.
1163+
syncLog.event(.sync, item: "save/\(existingSaveState.id)", status: .ok, detail: "metadataOnly — skipping conflict resolution")
1164+
targetSaveState = existingSaveState
1165+
} else {
1166+
syncLog.event(.sync, item: "save/\(existingSaveState.id)", status: .inProgress, detail: "handling existing")
1167+
await self.refreshLocalDownloadState(for: existingSaveState)
1168+
await self.handleSaveStateConflict(existingSaveState, cloudRecord: record, preferredCoreID: coreHint.coreID, preferredCoreVersion: coreHint.coreVersion)
1169+
targetSaveState = existingSaveState
1170+
// If the game isn't downloaded locally, flag for ROM download
1171+
if existingSaveState.game?.isDownloaded == false {
1172+
needsRomDownload = true
1173+
}
11511174
}
11521175

11531176
// Ensure cloudRecordID is set if it wasn't before
@@ -1167,10 +1190,14 @@ public class CloudKitSaveStatesSyncer: CloudKitSyncer, SaveStatesSyncing {
11671190
}
11681191
} else if let newSaveState = await self.createSaveStateFromCloudRecord(record, game: frozenGame, originalID: originalSaveStateID, preferredCoreID: coreHint.coreID, preferredCoreVersion: coreHint.coreVersion) {
11691192
syncLog.event(.download, item: "save/\(newSaveState.id)", status: .ok, detail: "created new save state")
1170-
await self.markSaveStateForDownload(newSaveState, cloudRecord: record)
1193+
if !isMetadataOnly {
1194+
await self.markSaveStateForDownload(newSaveState, cloudRecord: record)
1195+
}
11711196
targetSaveState = newSaveState
11721197
// New save states from cloud need the ROM downloaded
1173-
needsRomDownload = true
1198+
if !isMetadataOnly {
1199+
needsRomDownload = true
1200+
}
11741201
}
11751202
else if frozenGame == nil {
11761203
// Defer processing until ROM metadata lands to avoid losing this record on fresh installs.
@@ -1184,7 +1211,9 @@ public class CloudKitSaveStatesSyncer: CloudKitSyncer, SaveStatesSyncing {
11841211
to: targetSaveState,
11851212
preferredCoreID: coreHint.coreID,
11861213
preferredCoreVersion: coreHint.coreVersion)
1187-
if record[CloudKitSchema.SaveStateFields.imageAsset] as? CKAsset != nil {
1214+
// Skip artwork caching in metadataOnly mode — avoid I/O on every boot
1215+
if !isMetadataOnly,
1216+
record[CloudKitSchema.SaveStateFields.imageAsset] as? CKAsset != nil {
11881217
await cacheSaveStateArtworkAsset(from: record, for: targetSaveState)
11891218
}
11901219

PVLibrary/Sources/PVLibrary/Cloud Sync/iCloud/CloudSyncManager.swift

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -530,32 +530,37 @@ public class CloudSyncManager {
530530
await self?.checkForMissingROMFiles(force: false)
531531
}
532532

533-
// Proceed with initial sync (uploads) in parallel
534-
updateSyncStatus(.initialSync)
533+
// Enqueue initial sync (uploads) AFTER remote fetch completes.
534+
// On a fresh install all local data came from CloudKit, so once
535+
// fetchRemoteChanges finishes every Realm object already has a
536+
// cloudRecordID and isInitialSyncNeeded() returns false — avoiding
537+
// thousands of redundant per-file CloudKit queries.
538+
enqueueMetadataWork { [weak self] in
539+
guard let self else { return }
540+
self.updateSyncStatus(.initialSync)
535541

536-
var hasErrors = false
537-
var lastError: Error?
542+
var hasErrors = false
543+
var lastError: Error?
538544

539-
// Perform initial sync (this checks if needed internally unless forced)
540-
do {
541-
let syncCount = await CloudKitInitialSyncer.shared?.performInitialSync(forceSync: false)
542-
DLOG("CloudKit initial sync completed - potentially uploaded \(syncCount) new records.")
543-
} catch {
544-
syncLog.event(.sync, item: "sync/initial", status: .failed, detail: error.localizedDescription)
545-
hasErrors = true
546-
lastError = error
547-
await errorHandler.handle(error: error)
548-
}
545+
do {
546+
let syncCount = await CloudKitInitialSyncer.shared?.performInitialSync(forceSync: false)
547+
DLOG("CloudKit initial sync completed - potentially uploaded \(syncCount ?? 0) new records.")
548+
} catch {
549+
CloudSyncManager.syncLog.event(.sync, item: "sync/initial", status: .failed, detail: error.localizedDescription)
550+
hasErrors = true
551+
lastError = error
552+
await self.errorHandler.handle(error: error)
553+
}
549554

550-
// Update status based on results (do not cancel ongoing remote fetch)
551-
if hasErrors {
552-
syncLog.event(.sync, item: "sync/cloudkit", status: .failed, detail: "Sync completed with errors")
553-
if let error = lastError {
554-
updateSyncStatus(.error(CloudSyncError.cloudKitError(error)))
555+
if hasErrors {
556+
CloudSyncManager.syncLog.event(.sync, item: "sync/cloudkit", status: .failed, detail: "Sync completed with errors")
557+
if let error = lastError {
558+
self.updateSyncStatus(.error(CloudSyncError.cloudKitError(error)))
559+
}
560+
} else {
561+
self.updateSyncStatus(.idle)
562+
DLOG("Initial sync phase completed successfully.")
555563
}
556-
} else if syncStatus != .error(CloudSyncError.cloudKitError(lastError ?? CloudSyncError.unknown)) {
557-
updateSyncStatus(.idle)
558-
DLOG("Initial sync phase completed successfully (remote fetch may still be in progress).")
559564
}
560565
}
561566

0 commit comments

Comments
 (0)