mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
The two answers to a deleted zone were sharing one lifecycle, and only one of them deserved it. A re-seed only adds, so it is held open until the engine has actually serialized the work and redone for as long as it has not. A purge removes, and replaying it removes more than it was asked to — the account added while the app kept running, the category switched back on, the reset that runs its own upload afterwards. It is done the moment its durable half is, and the comment that called both idempotent now says which is which. The note itself was never scoped to an identity. A marker written under one Apple account replayed against the next one's namespace, answering a wipe that never happened there, and a purge would have taken that account's staged records and its outbox with it. The account change forgets it now, the way it forgets everything else it holds. Retiring the marker trusted whichever state update arrived next, which is not the same as one that carries the work: an update handed over before the recovery registered anything can still reach the main actor after it, describing a state in which none of it exists. Deliveries are counted where the engine hands them over, and only a later one can retire what was armed. A refused re-seed waited for the next launch. The accounts it could not speak for announce themselves when they load, and the recovery is answered then — outside the callback tree that announcement comes from. A purge left two ways back for what it dropped: a pending zone save would have recreated the zone on the next send, and the pre-engine pending-change keys would have been rediscovered on a later launch. It clears both, and it now runs after everything that puts work into the engine rather than before. The account half fences before it destroys, like its upload twin, so a purge arriving during the identity reload gap answers with nothing rather than with somebody else's records. And the reset that promises a re-upload is refused while sync is off, where nothing would have performed one. Re-entering the engine from inside its own callback is what started all of this, and nothing in the code said so. A task-local marks the callback tree and the two entry points assert on it in debug builds — a plain Task inherits it, which is the mistake; Task.detached escapes it, which is the fix.
3258 lines
150 KiB
Swift
3258 lines
150 KiB
Swift
//
|
|
// CloudKitSyncEngine.swift
|
|
// Yattee
|
|
//
|
|
// Main CloudKit sync engine using CKSyncEngine for robust iCloud sync.
|
|
//
|
|
|
|
import CloudKit
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
// MARK: - Deferred Playlist Item
|
|
|
|
/// Tracks deferred playlist items with retry counts for persistent recovery.
|
|
/// When playlist items arrive from CloudKit before their parent playlist,
|
|
/// they are stored in this structure and persisted to UserDefaults.
|
|
struct DeferredPlaylistItem: Codable {
|
|
/// Serialized CKRecord data.
|
|
let recordData: Data
|
|
|
|
/// The playlist ID this item belongs to.
|
|
let playlistID: String
|
|
|
|
/// The playlist item ID.
|
|
let itemID: String
|
|
|
|
/// Number of retry attempts.
|
|
var retryCount: Int
|
|
|
|
/// When this item was first deferred.
|
|
let firstDeferredAt: Date
|
|
|
|
init(record: CKRecord, playlistID: UUID, itemID: UUID) throws {
|
|
self.recordData = try NSKeyedArchiver.archivedData(withRootObject: record, requiringSecureCoding: true)
|
|
self.playlistID = playlistID.uuidString
|
|
self.itemID = itemID.uuidString
|
|
self.retryCount = 0
|
|
self.firstDeferredAt = Date()
|
|
}
|
|
|
|
func toCKRecord() throws -> CKRecord? {
|
|
return try NSKeyedUnarchiver.unarchivedObject(ofClass: CKRecord.self, from: recordData)
|
|
}
|
|
}
|
|
|
|
/// Result of applying a remote record.
|
|
private enum ApplyRecordResult {
|
|
/// Record was successfully applied.
|
|
case success
|
|
/// Record was deferred (playlist item without parent playlist).
|
|
case deferred(playlistID: UUID, itemID: UUID)
|
|
/// Record processing failed (logged but not fatal).
|
|
case failed
|
|
}
|
|
|
|
/// Whether the current task is running inside a `CKSyncEngineDelegate`
|
|
/// callback.
|
|
///
|
|
/// CloudKit traps, in release builds, on a call back into the engine from
|
|
/// inside one of its own event callbacks — `fetchChanges()` or `sendChanges()`
|
|
/// — and reads "inside the callback" from exactly this kind of task-local, so a
|
|
/// plain `Task { }` spawned there inherits the trap while a `Task.detached`
|
|
/// escapes it. That is what killed the app a second after the push in the S14
|
|
/// campaign, and it is invisible until it happens on someone's device.
|
|
enum CloudKitDelegateScope {
|
|
@TaskLocal static var isHandlingEvent = false
|
|
}
|
|
|
|
/// Main CloudKit sync engine that manages bidirectional sync between local SwiftData and iCloud.
|
|
@MainActor
|
|
@Observable
|
|
final class CloudKitSyncEngine: @unchecked Sendable {
|
|
// MARK: - Singleton Reference
|
|
|
|
/// Weak reference to the current sync engine instance, used by AppDelegate to forward push notifications.
|
|
static weak var current: CloudKitSyncEngine?
|
|
|
|
// MARK: - State
|
|
|
|
/// Whether CloudKit is usable in this installation. False when the app was
|
|
/// re-signed without the iCloud container entitlement (sideloading); all
|
|
/// sync functionality is disabled then and the CloudKit objects stay nil.
|
|
let isCloudKitAvailable = CloudKitAvailability.isAvailable
|
|
|
|
private let container: CKContainer?
|
|
private let database: CKDatabase?
|
|
private let zoneManager: CloudKitZoneManager?
|
|
private var recordMapper: CloudKitRecordMapper
|
|
private var conflictResolver: CloudKitConflictResolver
|
|
private var syncEngine: CKSyncEngine?
|
|
|
|
/// Whether sync is currently in progress.
|
|
var isSyncing = false
|
|
|
|
/// Whether currently receiving changes from CloudKit.
|
|
var isReceivingChanges = false
|
|
|
|
/// Last successful sync timestamp.
|
|
var lastSyncDate: Date?
|
|
|
|
/// Current sync error, if any.
|
|
var syncError: Error?
|
|
|
|
/// Pending changes queued before the sync engine is ready; flushed into
|
|
/// the engine state as soon as it is created.
|
|
private var pendingChangesBuffer: [CKSyncEngine.PendingRecordZoneChange] = []
|
|
|
|
/// Conflict-resolved records awaiting upload, keyed by record name.
|
|
/// These carry the server change tag from the conflict error and take
|
|
/// precedence over freshly materialized records at send time.
|
|
private var conflictResolvedRecords: [String: CKRecord] = [:]
|
|
|
|
/// The CloudKit zone all records live in (constant name, never changes).
|
|
private let zone = RecordType.createZone()
|
|
|
|
/// Debounce timer for batching changes.
|
|
private var debounceTimer: Timer?
|
|
|
|
/// Debounce delay in seconds.
|
|
private let debounceDelay: TimeInterval = 5.0
|
|
|
|
/// Guard against re-entrant setupSyncEngine calls (e.g. from .accountChange during setup).
|
|
private var isSettingUpEngine = false
|
|
|
|
/// Timer for periodic foreground polling (fallback when push notifications don't arrive).
|
|
private var foregroundPollTimer: Timer?
|
|
|
|
/// Foreground polling interval in seconds (3 minutes).
|
|
private let foregroundPollInterval: TimeInterval = 180
|
|
|
|
/// Retry tracking: recordName -> retry count
|
|
private var retryCount: [String: Int] = [:]
|
|
|
|
/// Maximum number of retry attempts for conflict resolution.
|
|
private let maxRetryAttempts = 5
|
|
|
|
// MARK: - Account Identity Tracking
|
|
|
|
/// UserDefaults key for storing the current iCloud account's record ID.
|
|
private let accountRecordIDKey = "cloudKitAccountRecordID"
|
|
|
|
/// UserDefaults key for CloudKit sync state.
|
|
private let syncStateKey = "cloudKitSyncState"
|
|
|
|
/// Durable note that the zone was deleted on the server and this device has
|
|
/// not finished answering it.
|
|
private let zoneRecovery = CloudKitZoneRecoveryMarker()
|
|
|
|
/// Counts state updates as the engine hands them over, before they reach
|
|
/// the main actor — see ``ZoneRecoveryDeliveryCounter``.
|
|
private nonisolated let stateUpdateDeliveries = ZoneRecoveryDeliveryCounter()
|
|
|
|
/// Whether the last answer to a zone deletion could not speak for the
|
|
/// accounts, and is owed a second attempt once they arrive.
|
|
///
|
|
/// Distinct from the marker still being pending: a re-seed that registered
|
|
/// everything keeps its marker until the engine serializes the work, and
|
|
/// redoing *that* would only disarm it and start over.
|
|
private var zoneRecoveryAwaitsAccounts = false
|
|
|
|
/// UserDefaults key for the record schema version this app last synced with.
|
|
private let lastSchemaVersionKey = "cloudKitLastSchemaVersion"
|
|
|
|
/// Legacy UserDefaults key for pending save record names (pre state-driven
|
|
/// engine). Only read once for migration, then removed.
|
|
private let pendingSaveRecordNamesKey = "cloudKitPendingSaveRecordNames"
|
|
|
|
/// Legacy UserDefaults key for pending delete record names (pre state-driven
|
|
/// engine). Only read once for migration, then removed.
|
|
private let pendingDeleteRecordNamesKey = "cloudKitPendingDeleteRecordNames"
|
|
|
|
// MARK: - Deferred Playlist Item Management
|
|
|
|
/// UserDefaults key for persisting deferred playlist items.
|
|
private let deferredPlaylistItemsKey = "cloudKitDeferredPlaylistItems"
|
|
|
|
/// Deferred playlist items waiting for their parent playlists to arrive.
|
|
private var deferredPlaylistItems: [DeferredPlaylistItem] = []
|
|
|
|
/// Maximum number of retry attempts before creating a placeholder playlist.
|
|
private let maxDeferralRetries = 5
|
|
|
|
// MARK: - UI-Friendly State
|
|
|
|
/// Current account status (cached, refreshed periodically)
|
|
private(set) var accountStatus: CKAccountStatus = .couldNotDetermine
|
|
|
|
/// Initial upload progress (nil when not doing initial upload)
|
|
private(set) var uploadProgress: UploadProgress?
|
|
|
|
/// Whether newer schema versions were encountered that require app update.
|
|
private(set) var hasNewerSchemaRecords = false
|
|
|
|
/// Computed sync status for UI display
|
|
var syncStatus: SyncStatus {
|
|
if let error = syncError {
|
|
return .error(error)
|
|
}
|
|
if isSyncing {
|
|
return .syncing
|
|
}
|
|
if pendingChangesCount == 0 {
|
|
return .upToDate
|
|
}
|
|
return .pending(count: pendingChangesCount)
|
|
}
|
|
|
|
/// Pending changes count (mirrors the sync engine state so the UI can observe it).
|
|
private(set) var pendingChangesCount = 0
|
|
|
|
/// User-friendly sync status text
|
|
var syncStatusText: String {
|
|
// Show "Receiving changes..." when downloading from other devices
|
|
if isReceivingChanges {
|
|
return "Receiving changes..."
|
|
}
|
|
|
|
switch syncStatus {
|
|
case .syncing:
|
|
return "Syncing..."
|
|
case .upToDate:
|
|
return "Up to Date"
|
|
case .pending(let count):
|
|
return "\(count) change\(count == 1 ? "" : "s") pending"
|
|
case .error:
|
|
return "Error"
|
|
}
|
|
}
|
|
|
|
/// User-friendly account status text
|
|
var accountStatusText: String {
|
|
switch accountStatus {
|
|
case .available:
|
|
return "iCloud Account Active"
|
|
case .noAccount:
|
|
return "No iCloud Account"
|
|
case .restricted:
|
|
return "iCloud Restricted"
|
|
case .couldNotDetermine:
|
|
return "Checking iCloud..."
|
|
case .temporarilyUnavailable:
|
|
return "iCloud Temporarily Unavailable"
|
|
@unknown default:
|
|
return "Unknown iCloud Status"
|
|
}
|
|
}
|
|
|
|
// MARK: - Dependencies
|
|
|
|
private weak var dataManager: DataManager?
|
|
private weak var settingsManager: SettingsManager?
|
|
private weak var instancesManager: InstancesManager?
|
|
|
|
/// Player controls layout service for importing/removing presets from CloudKit.
|
|
/// Set after initialization to use shared instance instead of creating new ones.
|
|
weak var playerControlsLayoutService: PlayerControlsLayoutService?
|
|
|
|
/// Drives the account category: accounts, migration markers, deletion
|
|
/// tombstones and the selected data context. Set after initialization, and
|
|
/// nil in tests that only exercise the SwiftData categories.
|
|
var accountSync: AccountSyncCoordinator? {
|
|
didSet { wireZoneRecoveryRetry() }
|
|
}
|
|
|
|
/// Asks the accounts to say when they become readable again, so a recovery
|
|
/// that could not speak for them is answered as soon as they arrive.
|
|
///
|
|
/// Without this the marker simply waits: the accounts finish loading, and
|
|
/// nothing looks at it again until the next `setupSyncEngine()` — which,
|
|
/// on a device left running, may be the next launch.
|
|
private func wireZoneRecoveryRetry() {
|
|
accountSync?.onAccountsBecameReadable = { [weak self] in
|
|
// Only a recovery that was *refused*, never one that is merely
|
|
// waiting for the engine to serialize what it registered. This
|
|
// notification fires for every account edit, and redoing an armed
|
|
// re-seed on each one would disarm it and start it over.
|
|
guard let self, self.zoneRecoveryAwaitsAccounts else { return }
|
|
|
|
// Detached, not a plain `Task`: the notification behind this is
|
|
// posted from inside the sync-engine delegate's callback tree when
|
|
// fetched account records are applied, and an unstructured task
|
|
// would inherit that scope and carry the recovery back into it.
|
|
Task.detached { [weak self] in
|
|
await self?.resumeZoneRecoveryIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
dataManager: DataManager? = nil,
|
|
settingsManager: SettingsManager? = nil,
|
|
instancesManager: InstancesManager? = nil
|
|
) {
|
|
self.dataManager = dataManager
|
|
self.settingsManager = settingsManager
|
|
self.instancesManager = instancesManager
|
|
|
|
// Initialize CloudKit. CKContainer(identifier:) fatally traps when the
|
|
// entitlement is missing (re-signed/sideloaded builds), so it must not
|
|
// be constructed at all in that case.
|
|
if CloudKitAvailability.isAvailable {
|
|
let container = CKContainer(identifier: AppIdentifiers.iCloudContainer)
|
|
let database = container.privateCloudDatabase
|
|
self.container = container
|
|
self.database = database
|
|
self.zoneManager = CloudKitZoneManager(database: database)
|
|
} else {
|
|
LoggingService.shared.logCloudKit("iCloud container entitlement missing (re-signed build) - CloudKit sync unavailable")
|
|
self.container = nil
|
|
self.database = nil
|
|
self.zoneManager = nil
|
|
}
|
|
|
|
// Initialize with temporary zone (will be updated in setupSyncEngine)
|
|
let tempZone = RecordType.createZone()
|
|
self.recordMapper = CloudKitRecordMapper(zone: tempZone)
|
|
self.conflictResolver = CloudKitConflictResolver()
|
|
|
|
// Make this instance accessible for push notification forwarding
|
|
CloudKitSyncEngine.current = self
|
|
|
|
// Setup sync engine after initialization complete - only if sync is enabled
|
|
Task {
|
|
if settingsManager?.iCloudSyncEnabled == true {
|
|
await setupSyncEngine()
|
|
} else {
|
|
LoggingService.shared.logCloudKit("CloudKit sync disabled at startup, skipping engine setup")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Dynamic Enable/Disable
|
|
|
|
/// Enables CloudKit sync. Creates CKSyncEngine and starts syncing.
|
|
func enable() async {
|
|
guard isCloudKitAvailable else {
|
|
LoggingService.shared.logCloudKit("CloudKit unavailable in this installation, cannot enable sync")
|
|
return
|
|
}
|
|
guard syncEngine == nil else {
|
|
LoggingService.shared.logCloudKit("Sync engine already enabled")
|
|
return
|
|
}
|
|
LoggingService.shared.logCloudKit("Enabling CloudKit sync...")
|
|
await setupSyncEngine()
|
|
}
|
|
|
|
/// Disables CloudKit sync. Tears down CKSyncEngine.
|
|
func disable() {
|
|
guard syncEngine != nil else {
|
|
LoggingService.shared.logCloudKit("Sync engine already disabled")
|
|
return
|
|
}
|
|
LoggingService.shared.logCloudKit("Disabling CloudKit sync...")
|
|
|
|
debounceTimer?.invalidate()
|
|
debounceTimer = nil
|
|
foregroundPollTimer?.invalidate()
|
|
foregroundPollTimer = nil
|
|
pendingChangesBuffer.removeAll()
|
|
conflictResolvedRecords.removeAll()
|
|
retryCount.removeAll()
|
|
deferredPlaylistItems.removeAll()
|
|
// The account outbox, staging and bootstrap flag are deliberately left
|
|
// alone: they are the durable record of what still has to happen, and
|
|
// re-enabling sync must resume rather than start over.
|
|
accountSync?.detach()
|
|
syncEngine = nil
|
|
updatePendingCount()
|
|
isSyncing = false
|
|
isReceivingChanges = false
|
|
syncError = nil
|
|
hasNewerSchemaRecords = false
|
|
|
|
LoggingService.shared.logCloudKit("CloudKit sync disabled")
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupSyncEngine() async {
|
|
// Guard: prevent re-entrant calls (e.g. .accountChange firing during setup)
|
|
guard !isSettingUpEngine else {
|
|
LoggingService.shared.logCloudKit("setupSyncEngine already in progress, skipping re-entrant call")
|
|
return
|
|
}
|
|
isSettingUpEngine = true
|
|
defer { isSettingUpEngine = false }
|
|
|
|
// Guard: only setup if sync is enabled
|
|
guard settingsManager?.iCloudSyncEnabled == true else {
|
|
LoggingService.shared.logCloudKit("setupSyncEngine called but sync is disabled, skipping")
|
|
return
|
|
}
|
|
|
|
guard let container, let database, let zoneManager else {
|
|
LoggingService.shared.logCloudKit("CloudKit unavailable in this installation, skipping engine setup")
|
|
return
|
|
}
|
|
|
|
do {
|
|
// Check iCloud account status
|
|
let status = try await container.accountStatus()
|
|
self.accountStatus = status // Cache for UI
|
|
guard status == .available else {
|
|
let error = CloudKitError.iCloudNotAvailable(status: status)
|
|
syncError = error
|
|
LoggingService.shared.logCloudKitError("iCloud not available", error: error)
|
|
return
|
|
}
|
|
|
|
// Check for account change before setting up sync
|
|
let accountChanged = await checkAndHandleAccountChange()
|
|
|
|
// Create zone
|
|
try await zoneManager.createZoneIfNeeded()
|
|
|
|
// Reinitialize mapper with actual zone
|
|
let zone = await zoneManager.getZone()
|
|
self.recordMapper = CloudKitRecordMapper(zone: zone)
|
|
|
|
// Records written by a newer app version fail to parse and are only
|
|
// re-delivered on a full fetch, so a schema upgrade must invalidate the
|
|
// persisted state once to pick them up.
|
|
invalidateSyncStateAfterSchemaUpgradeIfNeeded()
|
|
|
|
// Resume from persisted sync state so launches fetch only changes since
|
|
// the last session instead of replaying the entire zone change history.
|
|
// Starts with nil (full fetch) on first sync, account change, manual
|
|
// refresh, or schema upgrade.
|
|
let savedState = loadSyncState()
|
|
LoggingService.shared.logCloudKit(savedState != nil
|
|
? "Creating CKSyncEngine with persisted state (incremental fetch)"
|
|
: "Creating CKSyncEngine with fresh state (full fetch)")
|
|
|
|
let config = CKSyncEngine.Configuration(
|
|
database: database,
|
|
stateSerialization: savedState,
|
|
delegate: self
|
|
)
|
|
|
|
let engine = CKSyncEngine(config)
|
|
self.syncEngine = engine
|
|
LoggingService.shared.logCloudKit("CKSyncEngine created")
|
|
|
|
// Flush changes queued while the engine was not ready
|
|
if !pendingChangesBuffer.isEmpty {
|
|
engine.state.add(pendingRecordZoneChanges: pendingChangesBuffer)
|
|
pendingChangesBuffer.removeAll()
|
|
}
|
|
|
|
// Accounts activate before the first sync: the bootstrap fetch has
|
|
// to complete before anything is uploaded, or a device upgrading
|
|
// from the previous release would publish its unmerged local list
|
|
// over records it has not seen yet.
|
|
accountSync?.attach(
|
|
database: database,
|
|
discardingCachedRecords: { [weak self] in
|
|
self?.discardConflictResolvedAccountRecords()
|
|
}
|
|
) { [weak self] changes in
|
|
self?.addPendingChanges(changes)
|
|
}
|
|
await accountSync?.activate()
|
|
|
|
if accountChanged {
|
|
LoggingService.shared.logCloudKit("CloudKit sync engine initialized with new account (fresh sync state)")
|
|
} else {
|
|
LoggingService.shared.logCloudKit("CloudKit sync engine initialized successfully")
|
|
// One-time migration of pending changes persisted by the old
|
|
// array-based queue (pre state-driven engine)
|
|
migrateLegacyPendingChanges(into: engine)
|
|
}
|
|
|
|
loadDeferredItems()
|
|
updatePendingCount()
|
|
|
|
// A zone deletion noticed in an earlier run may not have been
|
|
// answered in full — the marker outlives whatever interrupted it,
|
|
// and this is the first point where the engine exists and the
|
|
// accounts are loaded, outside any delegate callback.
|
|
//
|
|
// Deliberately last: everything above puts work *into* the engine —
|
|
// the buffered changes, the legacy pending-change migration, the
|
|
// deferred playlist items — and a resumed purge has to be able to
|
|
// drop what it is not allowed to resend, including all of that.
|
|
await resumeZoneRecoveryIfNeeded()
|
|
|
|
// Perform initial sync
|
|
await sync()
|
|
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to setup sync engine", error: error)
|
|
syncError = error
|
|
}
|
|
}
|
|
|
|
/// Checks if the iCloud account has changed and handles it by clearing sync state.
|
|
/// - Returns: `true` if account changed and sync state was cleared, `false` otherwise.
|
|
private func checkAndHandleAccountChange() async -> Bool {
|
|
guard let container else { return false }
|
|
do {
|
|
// Fetch the current user's record ID using async wrapper
|
|
let currentUserRecordID = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<CKRecord.ID, Error>) in
|
|
container.fetchUserRecordID { recordID, error in
|
|
if let error = error {
|
|
continuation.resume(throwing: error)
|
|
} else if let recordID = recordID {
|
|
continuation.resume(returning: recordID)
|
|
} else {
|
|
continuation.resume(throwing: NSError(domain: "CloudKitSyncEngine", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch user record ID"]))
|
|
}
|
|
}
|
|
}
|
|
let currentRecordName = currentUserRecordID.recordName
|
|
|
|
// Get the stored account ID (if any)
|
|
let storedRecordName = UserDefaults.standard.string(forKey: accountRecordIDKey)
|
|
|
|
if let storedID = storedRecordName {
|
|
if storedID != currentRecordName {
|
|
// Account changed! Clear sync state to prevent conflicts
|
|
LoggingService.shared.logCloudKit(
|
|
"iCloud account changed detected: \(storedID.prefix(8))... -> \(currentRecordName.prefix(8))... - clearing sync state"
|
|
)
|
|
clearSyncStateForAccountChange()
|
|
|
|
// Store new account ID
|
|
UserDefaults.standard.set(currentRecordName, forKey: accountRecordIDKey)
|
|
return true
|
|
} else {
|
|
LoggingService.shared.logCloudKit("iCloud account unchanged: \(currentRecordName.prefix(8))...")
|
|
return false
|
|
}
|
|
} else {
|
|
// First time setup - store the account ID
|
|
LoggingService.shared.logCloudKit("First iCloud account recorded: \(currentRecordName.prefix(8))...")
|
|
UserDefaults.standard.set(currentRecordName, forKey: accountRecordIDKey)
|
|
return false
|
|
}
|
|
} catch {
|
|
// If we can't fetch the user record ID, log and continue without account tracking
|
|
LoggingService.shared.logCloudKitError("Failed to fetch user record ID for account tracking", error: error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Clears sync state when account changes to ensure fresh sync with new account.
|
|
private func clearSyncStateForAccountChange() {
|
|
// Clear CKSyncEngine state
|
|
UserDefaults.standard.removeObject(forKey: syncStateKey)
|
|
|
|
// Clear pending changes (they belong to the old account context)
|
|
pendingChangesBuffer.removeAll()
|
|
conflictResolvedRecords.removeAll()
|
|
retryCount.removeAll()
|
|
|
|
// Clear deferred playlist items (they belong to the old account context)
|
|
clearDeferredItems()
|
|
|
|
// The zone a recovery was answering belonged to the account being left.
|
|
// Replayed against the next one's namespace it answers a wipe that
|
|
// never happened there — and a pending purge would drop that identity's
|
|
// staged accounts and its outbox on the way in.
|
|
zoneRecovery.forget()
|
|
zoneRecoveryAwaitsAccounts = false
|
|
|
|
// Clear any existing sync engine
|
|
syncEngine = nil
|
|
updatePendingCount()
|
|
|
|
// Reset newer schema warning
|
|
hasNewerSchemaRecords = false
|
|
|
|
LoggingService.shared.logCloudKit("Cleared sync state for account change")
|
|
}
|
|
|
|
/// Clears persisted sync state after an app update that raised the record
|
|
/// schema version. Records from newer schema versions are rejected by the
|
|
/// mapper and are not re-delivered by an incremental fetch, so the first
|
|
/// launch that understands them must do one full re-fetch.
|
|
private func invalidateSyncStateAfterSchemaUpgradeIfNeeded() {
|
|
let storedVersion = Int64(UserDefaults.standard.integer(forKey: lastSchemaVersionKey))
|
|
let currentVersion = CloudKitRecordMapper.currentSchemaVersion
|
|
guard storedVersion < currentVersion else { return }
|
|
|
|
if UserDefaults.standard.data(forKey: syncStateKey) != nil {
|
|
UserDefaults.standard.removeObject(forKey: syncStateKey)
|
|
LoggingService.shared.logCloudKit("Schema version upgraded (\(storedVersion) -> \(currentVersion)), cleared sync state for full re-fetch")
|
|
}
|
|
UserDefaults.standard.set(Int(currentVersion), forKey: lastSchemaVersionKey)
|
|
}
|
|
|
|
/// Loads persisted sync state from disk.
|
|
private func loadSyncState() -> CKSyncEngine.State.Serialization? {
|
|
guard let data = UserDefaults.standard.data(forKey: syncStateKey) else {
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
let decoder = PropertyListDecoder()
|
|
return try decoder.decode(CKSyncEngine.State.Serialization.self, from: data)
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to load sync state", error: error)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Saves sync state to disk.
|
|
private func saveSyncState(_ serialization: CKSyncEngine.State.Serialization) {
|
|
do {
|
|
let encoder = PropertyListEncoder()
|
|
let data = try encoder.encode(serialization)
|
|
UserDefaults.standard.set(data, forKey: syncStateKey)
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to save sync state", error: error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Whether CloudKit sync is enabled in settings.
|
|
var isSyncEnabled: Bool {
|
|
settingsManager?.iCloudSyncEnabled ?? false
|
|
}
|
|
|
|
// MARK: - Category Sync Helpers
|
|
|
|
/// Whether subscription sync is enabled (master toggle + category toggle).
|
|
private var canSyncSubscriptions: Bool {
|
|
isSyncEnabled && (settingsManager?.syncSubscriptions ?? true)
|
|
}
|
|
|
|
/// Whether playback history sync is enabled (master toggle + category toggle).
|
|
private var canSyncPlaybackHistory: Bool {
|
|
isSyncEnabled && (settingsManager?.syncPlaybackHistory ?? true)
|
|
}
|
|
|
|
/// Whether bookmark sync is enabled (master toggle + category toggle).
|
|
private var canSyncBookmarks: Bool {
|
|
isSyncEnabled && (settingsManager?.syncBookmarks ?? true)
|
|
}
|
|
|
|
/// Whether playlist sync is enabled (master toggle + category toggle).
|
|
private var canSyncPlaylists: Bool {
|
|
isSyncEnabled && (settingsManager?.syncPlaylists ?? true)
|
|
}
|
|
|
|
/// Whether search history sync is enabled (master toggle + category toggle).
|
|
private var canSyncSearchHistory: Bool {
|
|
isSyncEnabled && (settingsManager?.syncSearchHistory ?? true)
|
|
}
|
|
|
|
/// Whether controls presets sync is enabled (master toggle + settings category toggle).
|
|
private var canSyncControlsPresets: Bool {
|
|
isSyncEnabled && (settingsManager?.syncSettings ?? true)
|
|
}
|
|
|
|
/// Whether account sync is enabled. Accounts ride the instances category:
|
|
/// they describe logins on those instances, and the two are useless apart.
|
|
var canSyncAccounts: Bool {
|
|
isSyncEnabled && (settingsManager?.syncInstances ?? true)
|
|
}
|
|
|
|
/// Fails the build's own tests, and a developer's run, if the engine is
|
|
/// about to be re-entered from inside one of its delegate callbacks.
|
|
///
|
|
/// Release behaviour is unchanged: CloudKit's own trap is what users would
|
|
/// meet, and adding a second one here would only change which line the
|
|
/// crash names. This is here so the trap is met by whoever wrote the call
|
|
/// instead — a plain `Task { }` in a delegate path is enough to inherit it,
|
|
/// and nothing about the call site says so.
|
|
private nonisolated func assertNotInsideDelegateEvent(_ call: String) {
|
|
#if DEBUG
|
|
if CloudKitDelegateScope.isHandlingEvent {
|
|
assertionFailure(
|
|
"\(call) was reached from inside a CKSyncEngine delegate callback. "
|
|
+ "Register changes in the engine's state instead, or escape the callback with Task.detached."
|
|
)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Manually trigger a full sync.
|
|
func sync() async {
|
|
assertNotInsideDelegateEvent("sync()")
|
|
|
|
guard isSyncEnabled, let syncEngine else {
|
|
LoggingService.shared.logCloudKit("Sync skipped: sync disabled or engine not ready")
|
|
return
|
|
}
|
|
|
|
isSyncing = true
|
|
syncError = nil
|
|
|
|
do {
|
|
// Fetch remote changes first
|
|
LoggingService.shared.logCloudKit("Fetching remote changes...")
|
|
try await syncEngine.fetchChanges()
|
|
let pendingCount = syncEngine.state.pendingRecordZoneChanges.count
|
|
LoggingService.shared.logCloudKit("fetchChanges() completed (\(pendingCount) pending changes)")
|
|
|
|
// Then send local changes
|
|
if pendingCount > 0 {
|
|
LoggingService.shared.logCloudKit("Sending \(pendingCount) pending changes...")
|
|
try await syncEngine.sendChanges()
|
|
}
|
|
|
|
lastSyncDate = Date()
|
|
settingsManager?.updateLastSyncTime()
|
|
|
|
LoggingService.shared.logCloudKit("Sync completed successfully")
|
|
|
|
} catch {
|
|
// Always log for debugging
|
|
LoggingService.shared.logCloudKitError("Sync failed", error: error)
|
|
|
|
// Only show user-actionable errors in UI (filter out auto-resolved conflicts)
|
|
if shouldShowError(error) {
|
|
syncError = error
|
|
} else {
|
|
syncError = nil
|
|
}
|
|
|
|
handleSyncError(error)
|
|
}
|
|
|
|
updatePendingCount()
|
|
isSyncing = false
|
|
}
|
|
|
|
/// Queue a subscription for sync (debounced).
|
|
func queueSubscriptionSave(_ subscription: Subscription) {
|
|
guard canSyncSubscriptions else { return }
|
|
|
|
let recordID = recordMapper.toCKRecord(subscription: subscription).recordID
|
|
addPendingChanges([.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued subscription save: \(subscription.channelID)")
|
|
}
|
|
|
|
/// Queue a subscription deletion for sync (debounced).
|
|
func queueSubscriptionDelete(channelID: String, scope: SourceScope) {
|
|
guard canSyncSubscriptions else { return }
|
|
|
|
let recordID = SyncableRecordType.subscription(channelID: channelID, scope: scope).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued subscription delete: \(channelID)")
|
|
}
|
|
|
|
/// Upload all existing local subscriptions to CloudKit (for initial sync).
|
|
/// Call this when enabling iCloud sync for the first time.
|
|
func uploadAllLocalSubscriptions() async {
|
|
guard enqueueAllLocalSubscriptions() else { return }
|
|
// Trigger immediate sync (no debounce for initial upload)
|
|
await sync()
|
|
}
|
|
|
|
/// Registers every local subscription for upload without sending.
|
|
///
|
|
/// The registration half of `uploadAllLocalSubscriptions()`, for callers
|
|
/// that must not re-enter the engine — a sync-engine delegate callback may
|
|
/// mutate engine state but may not ask the engine to fetch or send.
|
|
/// - Returns: whether anything was registered.
|
|
@discardableResult
|
|
private func enqueueAllLocalSubscriptions() -> Bool {
|
|
guard isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return false
|
|
}
|
|
|
|
// Update progress
|
|
uploadProgress?.currentCategory = "Subscriptions"
|
|
|
|
LoggingService.shared.logCloudKit("Starting initial bulk upload of local subscriptions...")
|
|
|
|
// Get all local subscriptions
|
|
let subscriptions = dataManager.subscriptions()
|
|
|
|
guard !subscriptions.isEmpty else {
|
|
LoggingService.shared.logCloudKit("No local subscriptions to upload")
|
|
return false
|
|
}
|
|
|
|
// Track total operations
|
|
uploadProgress?.totalOperations += subscriptions.count
|
|
|
|
// Register all record IDs with the sync engine; records are
|
|
// materialized from local data at send time.
|
|
let changes = subscriptions.map { subscription in
|
|
CKSyncEngine.PendingRecordZoneChange.saveRecord(recordMapper.toCKRecord(subscription: subscription).recordID)
|
|
}
|
|
addPendingChanges(changes, debounce: false)
|
|
|
|
LoggingService.shared.logCloudKit("Queued \(subscriptions.count) existing subscriptions for initial upload")
|
|
return true
|
|
}
|
|
|
|
// MARK: - WatchEntry Sync Operations
|
|
|
|
/// Whether a watch entry is eligible for iCloud sync.
|
|
/// Local-folder media plays from a per-device path, so syncing its watch progress
|
|
/// to other devices (where the file is not reachable) is wasted noise.
|
|
private func shouldSyncWatchEntry(sourceRawValue: String, externalExtractor: String?) -> Bool {
|
|
!(sourceRawValue == "extracted" && externalExtractor == MediaFile.localFolderProvider)
|
|
}
|
|
|
|
private func shouldSyncWatchEntry(_ watchEntry: WatchEntry) -> Bool {
|
|
shouldSyncWatchEntry(sourceRawValue: watchEntry.sourceRawValue, externalExtractor: watchEntry.externalExtractor)
|
|
}
|
|
|
|
/// Queue a watch entry for sync (debounced).
|
|
func queueWatchEntrySave(_ watchEntry: WatchEntry) {
|
|
guard canSyncPlaybackHistory else { return }
|
|
guard shouldSyncWatchEntry(watchEntry) else {
|
|
LoggingService.shared.logCloudKit("Skipping local-folder watch entry: \(watchEntry.videoID)")
|
|
return
|
|
}
|
|
|
|
let recordID = recordMapper.toCKRecord(watchEntry: watchEntry).recordID
|
|
addPendingChanges([.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued watch entry save: \(watchEntry.videoID)")
|
|
}
|
|
|
|
/// Queue a watch entry deletion for sync (debounced).
|
|
func queueWatchEntryDelete(videoID: String, scope: SourceScope) {
|
|
guard canSyncPlaybackHistory else { return }
|
|
|
|
let recordID = SyncableRecordType.watchEntry(videoID: videoID, scope: scope).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued watch entry delete: \(videoID)")
|
|
}
|
|
|
|
/// Upload all existing local watch history to CloudKit (for initial sync).
|
|
/// Call this when enabling iCloud sync for the first time.
|
|
func uploadAllLocalWatchHistory() async {
|
|
guard enqueueAllLocalWatchHistory() else { return }
|
|
// Trigger immediate sync (no debounce for initial upload)
|
|
await sync()
|
|
}
|
|
|
|
/// Registers all local watch history for upload without sending.
|
|
/// - Returns: whether anything was registered.
|
|
@discardableResult
|
|
private func enqueueAllLocalWatchHistory() -> Bool {
|
|
guard isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return false
|
|
}
|
|
|
|
// Update progress
|
|
uploadProgress?.currentCategory = "Watch History"
|
|
|
|
LoggingService.shared.logCloudKit("Starting initial bulk upload of local watch history...")
|
|
|
|
// Get all local watch history (limit to reasonable amount)
|
|
let watchHistory = dataManager.watchHistory(limit: 5000)
|
|
.filter { shouldSyncWatchEntry($0) }
|
|
|
|
guard !watchHistory.isEmpty else {
|
|
LoggingService.shared.logCloudKit("No local watch history to upload")
|
|
return false
|
|
}
|
|
|
|
// Track total operations
|
|
uploadProgress?.totalOperations += watchHistory.count
|
|
|
|
let changes = watchHistory.map { entry in
|
|
CKSyncEngine.PendingRecordZoneChange.saveRecord(recordMapper.toCKRecord(watchEntry: entry).recordID)
|
|
}
|
|
addPendingChanges(changes, debounce: false)
|
|
|
|
LoggingService.shared.logCloudKit("Queued \(watchHistory.count) existing watch entries for initial upload")
|
|
return true
|
|
}
|
|
|
|
// MARK: - Bookmark Sync Operations
|
|
|
|
/// Queue a bookmark for sync (debounced).
|
|
func queueBookmarkSave(_ bookmark: Bookmark) {
|
|
guard canSyncBookmarks else { return }
|
|
|
|
let recordID = recordMapper.toCKRecord(bookmark: bookmark).recordID
|
|
addPendingChanges([.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued bookmark save: \(bookmark.videoID)")
|
|
}
|
|
|
|
/// Queue a bookmark deletion for sync (debounced).
|
|
func queueBookmarkDelete(videoID: String, scope: SourceScope) {
|
|
guard canSyncBookmarks else { return }
|
|
|
|
let recordID = SyncableRecordType.bookmark(videoID: videoID, scope: scope).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued bookmark delete: \(videoID)")
|
|
}
|
|
|
|
/// Upload all existing local bookmarks to CloudKit (for initial sync).
|
|
/// Call this when enabling iCloud sync for the first time.
|
|
func uploadAllLocalBookmarks() async {
|
|
guard enqueueAllLocalBookmarks() else { return }
|
|
// Trigger immediate sync (no debounce for initial upload)
|
|
await sync()
|
|
}
|
|
|
|
/// Registers all local bookmarks for upload without sending.
|
|
/// - Returns: whether anything was registered.
|
|
@discardableResult
|
|
private func enqueueAllLocalBookmarks() -> Bool {
|
|
guard isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return false
|
|
}
|
|
|
|
// Update progress
|
|
uploadProgress?.currentCategory = "Bookmarks"
|
|
|
|
LoggingService.shared.logCloudKit("Starting initial bulk upload of local bookmarks...")
|
|
|
|
// Get all local bookmarks
|
|
let bookmarks = dataManager.bookmarks()
|
|
|
|
guard !bookmarks.isEmpty else {
|
|
LoggingService.shared.logCloudKit("No local bookmarks to upload")
|
|
return false
|
|
}
|
|
|
|
// Track total operations
|
|
uploadProgress?.totalOperations += bookmarks.count
|
|
|
|
let changes = bookmarks.map { bookmark in
|
|
CKSyncEngine.PendingRecordZoneChange.saveRecord(recordMapper.toCKRecord(bookmark: bookmark).recordID)
|
|
}
|
|
addPendingChanges(changes, debounce: false)
|
|
|
|
LoggingService.shared.logCloudKit("Queued \(bookmarks.count) existing bookmarks for initial upload")
|
|
return true
|
|
}
|
|
|
|
// MARK: - Playlist Sync Operations
|
|
|
|
/// Queue a playlist for sync (debounced).
|
|
func queuePlaylistSave(_ playlist: LocalPlaylist) {
|
|
guard canSyncPlaylists else { return }
|
|
|
|
var changes: [CKSyncEngine.PendingRecordZoneChange] = [
|
|
.saveRecord(SyncableRecordType.localPlaylist(id: playlist.id).recordID(in: zone))
|
|
]
|
|
|
|
// Also queue all items
|
|
for item in playlist.items ?? [] {
|
|
changes.append(.saveRecord(SyncableRecordType.localPlaylistItem(id: item.id).recordID(in: zone)))
|
|
}
|
|
|
|
addPendingChanges(changes)
|
|
LoggingService.shared.logCloudKit("Queued playlist save: \(playlist.title)")
|
|
}
|
|
|
|
/// Queue a playlist deletion for sync (debounced).
|
|
func queuePlaylistDelete(playlistID: UUID) {
|
|
guard canSyncPlaylists else { return }
|
|
|
|
let recordID = SyncableRecordType.localPlaylist(id: playlistID).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued playlist delete: \(playlistID)")
|
|
}
|
|
|
|
/// Queue a playlist item deletion for sync (debounced).
|
|
func queuePlaylistItemDelete(itemID: UUID) {
|
|
guard canSyncPlaylists else { return }
|
|
|
|
let recordID = SyncableRecordType.localPlaylistItem(id: itemID).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued playlist item delete: \(itemID)")
|
|
}
|
|
|
|
/// Upload all existing local playlists to CloudKit (for initial sync).
|
|
func uploadAllLocalPlaylists() async {
|
|
guard enqueueAllLocalPlaylists() else { return }
|
|
await sync()
|
|
}
|
|
|
|
/// Registers all local playlists and their items for upload without sending.
|
|
/// - Returns: whether anything was registered.
|
|
@discardableResult
|
|
private func enqueueAllLocalPlaylists() -> Bool {
|
|
guard isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return false
|
|
}
|
|
|
|
// Update progress
|
|
uploadProgress?.currentCategory = "Playlists"
|
|
|
|
LoggingService.shared.logCloudKit("Starting initial bulk upload of local playlists...")
|
|
|
|
let playlists = dataManager.playlists()
|
|
guard !playlists.isEmpty else {
|
|
LoggingService.shared.logCloudKit("No local playlists to upload")
|
|
return false
|
|
}
|
|
|
|
// Register all playlists and their items with the sync engine
|
|
var changes: [CKSyncEngine.PendingRecordZoneChange] = []
|
|
for playlist in playlists {
|
|
changes.append(.saveRecord(SyncableRecordType.localPlaylist(id: playlist.id).recordID(in: zone)))
|
|
|
|
// Add all items
|
|
for item in playlist.items ?? [] {
|
|
changes.append(.saveRecord(SyncableRecordType.localPlaylistItem(id: item.id).recordID(in: zone)))
|
|
}
|
|
}
|
|
addPendingChanges(changes, debounce: false)
|
|
|
|
// Track total operations (playlists + items)
|
|
let totalItems = playlists.reduce(0) { $0 + ($1.items?.count ?? 0) }
|
|
uploadProgress?.totalOperations += playlists.count + totalItems
|
|
|
|
LoggingService.shared.logCloudKit("Queued \(playlists.count) existing playlists for initial upload")
|
|
return true
|
|
}
|
|
|
|
// MARK: - SearchHistory Sync Operations
|
|
|
|
/// Queue a search history entry for sync (debounced).
|
|
func queueSearchHistorySave(_ searchHistory: SearchHistory) {
|
|
guard canSyncSearchHistory else { return }
|
|
|
|
let recordID = SyncableRecordType.searchHistory(id: searchHistory.id).recordID(in: zone)
|
|
addPendingChanges([.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued search history save: \(searchHistory.query)")
|
|
}
|
|
|
|
/// Queue a search history entry deletion for sync (debounced).
|
|
func queueSearchHistoryDelete(id: UUID) {
|
|
guard canSyncSearchHistory else { return }
|
|
|
|
let recordID = SyncableRecordType.searchHistory(id: id).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued search history delete: \(id)")
|
|
}
|
|
|
|
/// Upload all existing local search history to CloudKit (for initial sync).
|
|
func uploadAllLocalSearchHistory() async {
|
|
guard enqueueAllLocalSearchHistory() else { return }
|
|
await sync()
|
|
}
|
|
|
|
/// Registers all local search history for upload without sending.
|
|
/// - Returns: whether anything was registered.
|
|
@discardableResult
|
|
private func enqueueAllLocalSearchHistory() -> Bool {
|
|
guard isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return false
|
|
}
|
|
|
|
// Update progress
|
|
uploadProgress?.currentCategory = "Search History"
|
|
|
|
LoggingService.shared.logCloudKit("Starting initial bulk upload of search history...")
|
|
|
|
let searchHistory = dataManager.allSearchHistory()
|
|
guard !searchHistory.isEmpty else {
|
|
LoggingService.shared.logCloudKit("No local search history to upload")
|
|
return false
|
|
}
|
|
|
|
// Track total operations
|
|
uploadProgress?.totalOperations += searchHistory.count
|
|
|
|
let changes = searchHistory.map { entry in
|
|
CKSyncEngine.PendingRecordZoneChange.saveRecord(SyncableRecordType.searchHistory(id: entry.id).recordID(in: zone))
|
|
}
|
|
addPendingChanges(changes, debounce: false)
|
|
|
|
LoggingService.shared.logCloudKit("Queued \(searchHistory.count) search history entries for initial upload")
|
|
return true
|
|
}
|
|
|
|
// MARK: - RecentChannel Sync Operations
|
|
|
|
/// Queue a recent channel for sync (debounced).
|
|
func queueRecentChannelSave(_ recentChannel: RecentChannel) {
|
|
guard canSyncSearchHistory else { return }
|
|
|
|
let recordID = recordMapper.toCKRecord(recentChannel: recentChannel).recordID
|
|
addPendingChanges([.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued recent channel save: \(recentChannel.channelID)")
|
|
}
|
|
|
|
/// Queue a recent channel deletion for sync (debounced).
|
|
func queueRecentChannelDelete(channelID: String, scope: SourceScope) {
|
|
guard canSyncSearchHistory else { return }
|
|
|
|
let recordID = SyncableRecordType.recentChannel(channelID: channelID, scope: scope).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued recent channel delete: \(channelID)")
|
|
}
|
|
|
|
/// Upload all existing recent channels to CloudKit (for initial sync).
|
|
func uploadAllRecentChannels() async {
|
|
guard enqueueAllRecentChannels() else { return }
|
|
await sync()
|
|
}
|
|
|
|
/// Registers all recent channels for upload without sending.
|
|
/// - Returns: whether anything was registered.
|
|
@discardableResult
|
|
private func enqueueAllRecentChannels() -> Bool {
|
|
guard isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return false
|
|
}
|
|
|
|
// Update progress
|
|
uploadProgress?.currentCategory = "Recent Channels"
|
|
|
|
LoggingService.shared.logCloudKit("Starting initial bulk upload of recent channels...")
|
|
|
|
let recentChannels = dataManager.allRecentChannels()
|
|
guard !recentChannels.isEmpty else {
|
|
LoggingService.shared.logCloudKit("No recent channels to upload")
|
|
return false
|
|
}
|
|
|
|
// Track total operations
|
|
uploadProgress?.totalOperations += recentChannels.count
|
|
|
|
let changes = recentChannels.map { channel in
|
|
CKSyncEngine.PendingRecordZoneChange.saveRecord(recordMapper.toCKRecord(recentChannel: channel).recordID)
|
|
}
|
|
addPendingChanges(changes, debounce: false)
|
|
|
|
LoggingService.shared.logCloudKit("Queued \(recentChannels.count) recent channels for initial upload")
|
|
return true
|
|
}
|
|
|
|
// MARK: - RecentPlaylist Sync Operations
|
|
|
|
/// Queue a recent playlist for sync (debounced).
|
|
func queueRecentPlaylistSave(_ recentPlaylist: RecentPlaylist) {
|
|
guard canSyncSearchHistory else { return }
|
|
|
|
let recordID = recordMapper.toCKRecord(recentPlaylist: recentPlaylist).recordID
|
|
addPendingChanges([.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued recent playlist save: \(recentPlaylist.playlistID)")
|
|
}
|
|
|
|
/// Queue a recent playlist deletion for sync (debounced).
|
|
func queueRecentPlaylistDelete(playlistID: String, scope: SourceScope) {
|
|
guard canSyncSearchHistory else { return }
|
|
|
|
let recordID = SyncableRecordType.recentPlaylist(playlistID: playlistID, scope: scope).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued recent playlist delete: \(playlistID)")
|
|
}
|
|
|
|
/// Upload all existing recent playlists to CloudKit (for initial sync).
|
|
func uploadAllRecentPlaylists() async {
|
|
guard enqueueAllRecentPlaylists() else { return }
|
|
await sync()
|
|
}
|
|
|
|
/// Registers all recent playlists for upload without sending.
|
|
/// - Returns: whether anything was registered.
|
|
@discardableResult
|
|
private func enqueueAllRecentPlaylists() -> Bool {
|
|
guard isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return false
|
|
}
|
|
|
|
// Update progress
|
|
uploadProgress?.currentCategory = "Recent Playlists"
|
|
|
|
LoggingService.shared.logCloudKit("Starting initial bulk upload of recent playlists...")
|
|
|
|
let recentPlaylists = dataManager.allRecentPlaylists()
|
|
guard !recentPlaylists.isEmpty else {
|
|
LoggingService.shared.logCloudKit("No recent playlists to upload")
|
|
return false
|
|
}
|
|
|
|
// Track total operations
|
|
uploadProgress?.totalOperations += recentPlaylists.count
|
|
|
|
let changes = recentPlaylists.map { playlist in
|
|
CKSyncEngine.PendingRecordZoneChange.saveRecord(recordMapper.toCKRecord(recentPlaylist: playlist).recordID)
|
|
}
|
|
addPendingChanges(changes, debounce: false)
|
|
|
|
LoggingService.shared.logCloudKit("Queued \(recentPlaylists.count) recent playlists for initial upload")
|
|
return true
|
|
}
|
|
|
|
// MARK: - ChannelNotificationSettings Sync Operations
|
|
|
|
/// Queue channel notification settings for sync (debounced).
|
|
func queueChannelNotificationSettingsSave(_ settings: ChannelNotificationSettings) {
|
|
guard canSyncSubscriptions else { return }
|
|
|
|
let recordID = recordMapper.toCKRecord(channelNotificationSettings: settings).recordID
|
|
addPendingChanges([.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued channel notification settings save: \(settings.channelID)")
|
|
}
|
|
|
|
/// Queue channel notification settings deletion for sync (debounced).
|
|
func queueChannelNotificationSettingsDelete(channelID: String, scope: SourceScope) {
|
|
guard canSyncSubscriptions else { return }
|
|
|
|
let recordID = SyncableRecordType.channelNotificationSettings(channelID: channelID, scope: scope).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued channel notification settings delete: \(channelID)")
|
|
}
|
|
|
|
/// Upload all existing channel notification settings to CloudKit (for initial sync).
|
|
func uploadAllLocalChannelNotificationSettings() async {
|
|
guard enqueueAllLocalChannelNotificationSettings() else { return }
|
|
await sync()
|
|
}
|
|
|
|
/// Registers all channel notification settings for upload without sending.
|
|
/// - Returns: whether anything was registered.
|
|
@discardableResult
|
|
private func enqueueAllLocalChannelNotificationSettings() -> Bool {
|
|
guard isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return false
|
|
}
|
|
|
|
// Update progress
|
|
uploadProgress?.currentCategory = "Notification Settings"
|
|
|
|
LoggingService.shared.logCloudKit("Starting initial bulk upload of channel notification settings...")
|
|
|
|
let allSettings = dataManager.allChannelNotificationSettings()
|
|
guard !allSettings.isEmpty else {
|
|
LoggingService.shared.logCloudKit("No channel notification settings to upload")
|
|
return false
|
|
}
|
|
|
|
// Track total operations
|
|
uploadProgress?.totalOperations += allSettings.count
|
|
|
|
let changes = allSettings.map { settings in
|
|
CKSyncEngine.PendingRecordZoneChange.saveRecord(recordMapper.toCKRecord(channelNotificationSettings: settings).recordID)
|
|
}
|
|
addPendingChanges(changes, debounce: false)
|
|
|
|
LoggingService.shared.logCloudKit("Queued \(allSettings.count) channel notification settings for initial upload")
|
|
return true
|
|
}
|
|
|
|
// MARK: - ControlsPreset Sync Operations
|
|
|
|
/// Upload all local controls presets to CloudKit (for initial sync when enabling category).
|
|
func uploadAllLocalControlsPresets() async {
|
|
guard await enqueueAllLocalControlsPresets() else { return }
|
|
await sync()
|
|
}
|
|
|
|
/// Registers all local controls presets for upload without sending.
|
|
/// - Returns: whether anything was registered.
|
|
@discardableResult
|
|
private func enqueueAllLocalControlsPresets() async -> Bool {
|
|
guard canSyncControlsPresets else {
|
|
LoggingService.shared.logCloudKit("Cannot upload controls presets: sync disabled or settings category disabled")
|
|
return false
|
|
}
|
|
|
|
// Update progress
|
|
uploadProgress?.currentCategory = "Controls Presets"
|
|
|
|
LoggingService.shared.logCloudKit("Starting upload of local controls presets...")
|
|
|
|
let layoutService = playerControlsLayoutService ?? PlayerControlsLayoutService()
|
|
let presets = await layoutService.presetsForSync()
|
|
|
|
guard !presets.isEmpty else {
|
|
LoggingService.shared.logCloudKit("No controls presets to upload")
|
|
return false
|
|
}
|
|
|
|
// Track total operations
|
|
uploadProgress?.totalOperations += presets.count
|
|
|
|
let changes = presets.map { preset in
|
|
CKSyncEngine.PendingRecordZoneChange.saveRecord(SyncableRecordType.controlsPreset(id: preset.id).recordID(in: zone))
|
|
}
|
|
addPendingChanges(changes, debounce: false)
|
|
|
|
LoggingService.shared.logCloudKit("Queued \(presets.count) controls presets for upload")
|
|
return true
|
|
}
|
|
|
|
/// Queue a controls preset for sync (debounced).
|
|
func queueControlsPresetSave(_ preset: LayoutPreset) {
|
|
guard canSyncControlsPresets else { return }
|
|
|
|
// Only sync non-built-in presets for current device class
|
|
guard !preset.isBuiltIn, preset.deviceClass == .current else { return }
|
|
|
|
let recordID = SyncableRecordType.controlsPreset(id: preset.id).recordID(in: zone)
|
|
addPendingChanges([.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued controls preset save: \(preset.name)")
|
|
}
|
|
|
|
/// Queue a controls preset deletion for sync (debounced).
|
|
func queueControlsPresetDelete(id: UUID) {
|
|
guard canSyncControlsPresets else { return }
|
|
|
|
let recordID = SyncableRecordType.controlsPreset(id: id).recordID(in: zone)
|
|
addPendingChanges([.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Queued controls preset delete: \(id)")
|
|
}
|
|
|
|
/// Registers pending changes with the sync engine state (or buffers them
|
|
/// until the engine is ready) and schedules a debounced send. The engine
|
|
/// state is persisted via `.stateUpdate` events, so registered changes
|
|
/// survive app termination; records are materialized from local data at
|
|
/// send time in `nextRecordZoneChangeBatch`.
|
|
private func addPendingChanges(_ changes: [CKSyncEngine.PendingRecordZoneChange], debounce: Bool = true) {
|
|
guard !changes.isEmpty else { return }
|
|
|
|
for change in changes {
|
|
switch change {
|
|
case .saveRecord(let recordID), .deleteRecord(let recordID):
|
|
// A fresh local change supersedes any conflict-resolved record
|
|
// awaiting upload; drop it so the next send uses current data.
|
|
conflictResolvedRecords.removeValue(forKey: recordID.recordName)
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
if let syncEngine {
|
|
syncEngine.state.add(pendingRecordZoneChanges: changes)
|
|
} else {
|
|
pendingChangesBuffer.append(contentsOf: changes)
|
|
}
|
|
updatePendingCount()
|
|
|
|
if debounce {
|
|
scheduleDebounceSync()
|
|
}
|
|
}
|
|
|
|
/// Whether a delete for the given record name is pending upload.
|
|
private func hasPendingDelete(recordName: String) -> Bool {
|
|
let matches: (CKSyncEngine.PendingRecordZoneChange) -> Bool = { change in
|
|
if case .deleteRecord(let recordID) = change {
|
|
return recordID.recordName == recordName
|
|
}
|
|
return false
|
|
}
|
|
if pendingChangesBuffer.contains(where: matches) { return true }
|
|
return syncEngine?.state.pendingRecordZoneChanges.contains(where: matches) ?? false
|
|
}
|
|
|
|
/// Refreshes the observable pending count from the engine state.
|
|
private func updatePendingCount() {
|
|
pendingChangesCount = (syncEngine?.state.pendingRecordZoneChanges.count ?? 0) + pendingChangesBuffer.count
|
|
}
|
|
|
|
/// Schedule a debounced sync after changes.
|
|
private func scheduleDebounceSync() {
|
|
debounceTimer?.invalidate()
|
|
debounceTimer = Timer.scheduledTimer(withTimeInterval: debounceDelay, repeats: false) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
// Guard against timer firing after disable() has cleared state
|
|
guard let self, self.debounceTimer != nil else { return }
|
|
await self.sync()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Immediately sync all pending changes without debounce.
|
|
/// Call this when the app is about to enter background to prevent data loss.
|
|
func flushPendingChanges() async {
|
|
debounceTimer?.invalidate()
|
|
debounceTimer = nil
|
|
|
|
updatePendingCount()
|
|
guard pendingChangesCount > 0 else { return }
|
|
|
|
LoggingService.shared.logCloudKit("Flushing \(pendingChangesCount) pending changes before background")
|
|
await sync()
|
|
}
|
|
|
|
/// Handle a remote push notification by fetching changes from CloudKit.
|
|
/// Called from AppDelegate's `didReceiveRemoteNotification`.
|
|
func handleRemoteNotification() {
|
|
guard isSyncEnabled, syncEngine != nil else {
|
|
LoggingService.shared.logCloudKit("handleRemoteNotification: sync engine not ready, ignoring")
|
|
return
|
|
}
|
|
LoggingService.shared.logCloudKit("Handling remote notification — fetching changes")
|
|
Task {
|
|
await fetchRemoteChanges()
|
|
}
|
|
}
|
|
|
|
/// Fetch remote changes from CloudKit. Used when the app returns to foreground
|
|
/// to catch any changes that may have been missed (e.g. dropped push notifications).
|
|
func fetchRemoteChanges() async {
|
|
assertNotInsideDelegateEvent("fetchRemoteChanges()")
|
|
|
|
guard isSyncEnabled, let syncEngine else {
|
|
if !isSyncEnabled {
|
|
LoggingService.shared.logCloudKit("fetchRemoteChanges skipped: sync disabled")
|
|
} else {
|
|
LoggingService.shared.logCloudKit("fetchRemoteChanges skipped: sync engine not ready (nil)")
|
|
}
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit("Fetching remote changes on foreground")
|
|
|
|
do {
|
|
try await syncEngine.fetchChanges()
|
|
lastSyncDate = Date()
|
|
settingsManager?.updateLastSyncTime()
|
|
LoggingService.shared.logCloudKit("Foreground fetch completed")
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Foreground fetch failed", error: error)
|
|
}
|
|
}
|
|
|
|
/// Start periodic foreground polling as a fallback for missed push notifications.
|
|
/// The timer fires every 3 minutes and fetches remote changes.
|
|
func startForegroundPolling() {
|
|
guard foregroundPollTimer == nil else { return }
|
|
LoggingService.shared.logCloudKit("Starting foreground polling (every \(Int(foregroundPollInterval))s)")
|
|
foregroundPollTimer = Timer.scheduledTimer(withTimeInterval: foregroundPollInterval, repeats: true) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
await self?.fetchRemoteChanges()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop periodic foreground polling (e.g. when app enters background).
|
|
func stopForegroundPolling() {
|
|
guard foregroundPollTimer != nil else { return }
|
|
foregroundPollTimer?.invalidate()
|
|
foregroundPollTimer = nil
|
|
LoggingService.shared.logCloudKit("Stopped foreground polling")
|
|
}
|
|
|
|
/// One-time migration of pending changes persisted by the previous
|
|
/// array-based queue. Both saves and deletes are recovered by record
|
|
/// name — save records are materialized from local data at send time.
|
|
private func migrateLegacyPendingChanges(into engine: CKSyncEngine) {
|
|
let defaults = UserDefaults.standard
|
|
let saveNames = defaults.stringArray(forKey: pendingSaveRecordNamesKey) ?? []
|
|
let deleteNames = defaults.stringArray(forKey: pendingDeleteRecordNamesKey) ?? []
|
|
guard !saveNames.isEmpty || !deleteNames.isEmpty else { return }
|
|
|
|
var changes: [CKSyncEngine.PendingRecordZoneChange] = []
|
|
for name in saveNames {
|
|
changes.append(.saveRecord(CKRecord.ID(recordName: name, zoneID: zone.zoneID)))
|
|
}
|
|
for name in deleteNames {
|
|
changes.append(.deleteRecord(CKRecord.ID(recordName: name, zoneID: zone.zoneID)))
|
|
}
|
|
engine.state.add(pendingRecordZoneChanges: changes)
|
|
|
|
defaults.removeObject(forKey: pendingSaveRecordNamesKey)
|
|
defaults.removeObject(forKey: pendingDeleteRecordNamesKey)
|
|
LoggingService.shared.logCloudKit("Migrated \(saveNames.count) legacy pending saves, \(deleteNames.count) legacy pending deletes")
|
|
}
|
|
|
|
// MARK: - Deferred Playlist Item Management
|
|
|
|
/// Load deferred playlist items from UserDefaults.
|
|
private func loadDeferredItems() {
|
|
guard let data = UserDefaults.standard.data(forKey: deferredPlaylistItemsKey) else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let decoder = JSONDecoder()
|
|
deferredPlaylistItems = try decoder.decode([DeferredPlaylistItem].self, from: data)
|
|
if !deferredPlaylistItems.isEmpty {
|
|
LoggingService.shared.logCloudKit("Loaded \(deferredPlaylistItems.count) deferred playlist items from previous session")
|
|
}
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to load deferred playlist items", error: error)
|
|
deferredPlaylistItems = []
|
|
}
|
|
}
|
|
|
|
/// Persist deferred playlist items to UserDefaults.
|
|
private func persistDeferredItems() {
|
|
if deferredPlaylistItems.isEmpty {
|
|
UserDefaults.standard.removeObject(forKey: deferredPlaylistItemsKey)
|
|
return
|
|
}
|
|
|
|
do {
|
|
let encoder = JSONEncoder()
|
|
let data = try encoder.encode(deferredPlaylistItems)
|
|
UserDefaults.standard.set(data, forKey: deferredPlaylistItemsKey)
|
|
LoggingService.shared.logCloudKit("Persisted \(deferredPlaylistItems.count) deferred playlist items")
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to persist deferred playlist items", error: error)
|
|
}
|
|
}
|
|
|
|
/// Clear persisted deferred playlist items.
|
|
private func clearDeferredItems() {
|
|
deferredPlaylistItems.removeAll()
|
|
UserDefaults.standard.removeObject(forKey: deferredPlaylistItemsKey)
|
|
}
|
|
|
|
/// Retry deferred playlist items and create placeholders for items that exceed max retries.
|
|
private func retryDeferredItems(dataManager: DataManager) async {
|
|
guard !deferredPlaylistItems.isEmpty else { return }
|
|
|
|
LoggingService.shared.logCloudKit("Retrying \(deferredPlaylistItems.count) deferred playlist items...")
|
|
|
|
var itemsToKeep: [DeferredPlaylistItem] = []
|
|
var successCount = 0
|
|
|
|
for var deferredItem in deferredPlaylistItems {
|
|
// Drop items whose parent playlist is pending deletion
|
|
let playlistRecordName = "playlist-\(deferredItem.playlistID)"
|
|
if hasPendingDelete(recordName: playlistRecordName) {
|
|
LoggingService.shared.logCloudKit("Dropping deferred item (parent playlist pending delete): \(deferredItem.itemID)")
|
|
continue
|
|
}
|
|
|
|
// Increment retry count
|
|
deferredItem.retryCount += 1
|
|
|
|
// Try to get the CKRecord
|
|
guard let record = try? deferredItem.toCKRecord() else {
|
|
LoggingService.shared.logCloudKitError("Failed to deserialize deferred record", error: NSError(domain: "CloudKitSyncEngine", code: -1, userInfo: nil))
|
|
continue
|
|
}
|
|
|
|
// Try to find the parent playlist
|
|
guard let playlistID = UUID(uuidString: deferredItem.playlistID) else {
|
|
continue
|
|
}
|
|
|
|
if dataManager.playlist(forID: playlistID) != nil {
|
|
// Parent playlist exists - apply the record
|
|
let result = await applyRemoteRecord(record, to: dataManager)
|
|
if case .success = result {
|
|
successCount += 1
|
|
LoggingService.shared.logCloudKit("Successfully applied deferred item on retry \(deferredItem.retryCount): \(deferredItem.itemID)")
|
|
} else if case .deferred = result {
|
|
// Still deferred (shouldn't happen if parent exists, but handle gracefully)
|
|
if deferredItem.retryCount < maxDeferralRetries {
|
|
itemsToKeep.append(deferredItem)
|
|
} else {
|
|
// Create placeholder after max retries even in this edge case
|
|
await createPlaceholderAndAttachItem(record: record, playlistID: playlistID, itemID: UUID(uuidString: deferredItem.itemID), dataManager: dataManager)
|
|
}
|
|
}
|
|
} else if deferredItem.retryCount >= maxDeferralRetries {
|
|
// Max retries exceeded - create placeholder playlist
|
|
LoggingService.shared.logCloudKit("Max retries (\(maxDeferralRetries)) exceeded for item \(deferredItem.itemID) - creating placeholder playlist")
|
|
await createPlaceholderAndAttachItem(record: record, playlistID: playlistID, itemID: UUID(uuidString: deferredItem.itemID), dataManager: dataManager)
|
|
} else {
|
|
// Keep for next retry
|
|
itemsToKeep.append(deferredItem)
|
|
LoggingService.shared.logCloudKit("Keeping item \(deferredItem.itemID) for retry \(deferredItem.retryCount + 1)/\(maxDeferralRetries)")
|
|
}
|
|
}
|
|
|
|
deferredPlaylistItems = itemsToKeep
|
|
LoggingService.shared.logCloudKit("Retry complete: \(successCount) succeeded, \(itemsToKeep.count) still deferred")
|
|
}
|
|
|
|
/// Creates a placeholder playlist and attaches the orphaned item to it.
|
|
private func createPlaceholderAndAttachItem(record: CKRecord, playlistID: UUID, itemID: UUID?, dataManager: DataManager) async {
|
|
// Check if placeholder already exists for this playlist ID
|
|
if let existingPlaylist = dataManager.playlist(forID: playlistID) {
|
|
// Playlist arrived while we were processing - just attach the item
|
|
await attachItemToPlaylist(record: record, playlist: existingPlaylist, dataManager: dataManager)
|
|
return
|
|
}
|
|
|
|
// Create placeholder playlist
|
|
let placeholder = LocalPlaylist(
|
|
id: playlistID,
|
|
title: "Syncing...",
|
|
description: "This playlist is waiting for data from iCloud"
|
|
)
|
|
placeholder.isPlaceholder = true
|
|
|
|
dataManager.insertPlaylist(placeholder)
|
|
LoggingService.shared.logCloudKit("Created placeholder playlist: \(playlistID)")
|
|
|
|
// Attach the item
|
|
await attachItemToPlaylist(record: record, playlist: placeholder, dataManager: dataManager)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
}
|
|
|
|
/// Attaches a playlist item record to an existing playlist.
|
|
private func attachItemToPlaylist(record: CKRecord, playlist: LocalPlaylist, dataManager: DataManager) async {
|
|
do {
|
|
let (item, _) = try recordMapper.toLocalPlaylistItem(from: record)
|
|
item.playlist = playlist
|
|
dataManager.insertPlaylistItem(item)
|
|
LoggingService.shared.logCloudKit("Attached item \(item.id) to playlist \(playlist.id)")
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to attach item to playlist", error: error)
|
|
}
|
|
}
|
|
|
|
/// Refreshes sync by clearing local sync state (tokens) without deleting the CloudKit zone.
|
|
/// This forces CKSyncEngine to re-fetch all changes from scratch on next sync.
|
|
/// Unlike `resetSync()`, this preserves all CloudKit records.
|
|
func refreshSync() async {
|
|
guard isSyncEnabled else {
|
|
LoggingService.shared.logCloudKit("Refresh sync skipped: sync disabled")
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit("Refreshing sync state (clearing local tokens)...")
|
|
|
|
// Clear local sync state so CKSyncEngine fetches everything fresh
|
|
UserDefaults.standard.removeObject(forKey: syncStateKey)
|
|
|
|
// Tear down existing engine
|
|
debounceTimer?.invalidate()
|
|
debounceTimer = nil
|
|
pendingChangesBuffer.removeAll()
|
|
conflictResolvedRecords.removeAll()
|
|
retryCount.removeAll()
|
|
syncEngine = nil
|
|
updatePendingCount()
|
|
|
|
// Reinitialize - with the persisted state cleared above, setup starts
|
|
// from nil state serialization (forces full fetch)
|
|
await setupSyncEngine()
|
|
|
|
LoggingService.shared.logCloudKit("Refresh sync completed")
|
|
}
|
|
|
|
/// Clear all sync state and reset. For testing/debugging only.
|
|
///
|
|
/// Refused while sync is off. Everything after the zone deletion runs
|
|
/// through `setupSyncEngine()` and `performInitialUpload()`, both of which
|
|
/// stop at the same disabled toggle — so with sync off this would delete
|
|
/// the zone, recreate it empty, and then tell the user their local data had
|
|
/// been re-uploaded into it.
|
|
func resetSync() async throws {
|
|
guard isSyncEnabled else {
|
|
LoggingService.shared.logCloudKit("Reset refused: iCloud sync is turned off, nothing would be re-uploaded")
|
|
throw CloudKitError.syncDisabled
|
|
}
|
|
|
|
guard let zoneManager else { return }
|
|
|
|
// Delete zone (and all records)
|
|
try await zoneManager.deleteZone()
|
|
|
|
// Clear pending changes
|
|
pendingChangesBuffer.removeAll()
|
|
conflictResolvedRecords.removeAll()
|
|
retryCount.removeAll()
|
|
syncEngine = nil
|
|
updatePendingCount()
|
|
|
|
// Clear sync state
|
|
UserDefaults.standard.removeObject(forKey: syncStateKey)
|
|
clearDeferredItems()
|
|
|
|
// Recreate zone
|
|
try await zoneManager.createZoneIfNeeded()
|
|
|
|
// Reinitialize sync engine
|
|
await setupSyncEngine()
|
|
|
|
// And put the local data back. The confirmation this action shows says
|
|
// the data "has been re-uploaded"; until now nothing was, and the
|
|
// device performing the reset left the zone as empty as the devices
|
|
// watching it.
|
|
await performInitialUpload()
|
|
|
|
LoggingService.shared.logCloudKit("Sync reset completed")
|
|
}
|
|
|
|
// MARK: - Account Status
|
|
|
|
/// Refreshes the cached iCloud account status
|
|
func refreshAccountStatus() async {
|
|
guard let container else { return }
|
|
do {
|
|
accountStatus = try await container.accountStatus()
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to check account status", error: error)
|
|
accountStatus = .couldNotDetermine
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Filtering
|
|
|
|
/// Determines if an error should be shown to the user in the UI.
|
|
/// Filters out expected conflict errors that are automatically resolved.
|
|
private func shouldShowError(_ error: Error) -> Bool {
|
|
// Unwrap CKError
|
|
guard let ckError = error as? CKError else {
|
|
// Non-CloudKit errors should always be shown
|
|
return true
|
|
}
|
|
|
|
switch ckError.code {
|
|
case .serverRecordChanged:
|
|
// This is a conflict - handled automatically by conflict resolver
|
|
// Only show if message is NOT about "already exists"
|
|
let errorMessage = ckError.localizedDescription.lowercased()
|
|
if errorMessage.contains("already exists") || errorMessage.contains("record to insert") {
|
|
return false // Conflict error - don't show in UI
|
|
}
|
|
return true // Other serverRecordChanged errors - show them
|
|
|
|
case .partialFailure:
|
|
// Check if ALL sub-errors are conflicts
|
|
// If any sub-error is NOT a conflict, show the error
|
|
if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: Error] {
|
|
let hasNonConflictError = partialErrors.values.contains { subError in
|
|
guard let subCKError = subError as? CKError else { return true }
|
|
|
|
// Check if this sub-error is a conflict
|
|
if subCKError.code == .serverRecordChanged {
|
|
let message = subCKError.localizedDescription.lowercased()
|
|
if message.contains("already exists") || message.contains("record to insert") {
|
|
return false // This is a conflict
|
|
}
|
|
}
|
|
return true // This is NOT a conflict
|
|
}
|
|
|
|
// Only show if there's at least one non-conflict error
|
|
return hasNonConflictError
|
|
}
|
|
return true // Can't parse partial errors - show it to be safe
|
|
|
|
default:
|
|
// All other errors should be shown
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - Initial Upload Coordination
|
|
|
|
/// Re-registers every account record for upload.
|
|
///
|
|
/// Used when the zone is being seeded or reseeded — enabling sync, or
|
|
/// recreating the zone after it was deleted elsewhere. The account outbox
|
|
/// normally tracks only what changed, which is not enough when the server
|
|
/// has nothing at all.
|
|
/// Deliberately not gated on the category being enabled: the zone can be
|
|
/// deleted while account sync is off, and marking the records dirty is what
|
|
/// lets re-enabling restore them. The outbox is durable and nothing is sent
|
|
/// while the category is off, so priming it costs nothing.
|
|
/// - Returns: whether the outbox was actually re-primed. It is not when the
|
|
/// accounts in memory belong to another identity, or to none — the
|
|
/// re-seed that asked for this has to be retried rather than considered
|
|
/// done. A build with no account sync at all owes nothing and is done.
|
|
@discardableResult
|
|
func uploadAllAccounts() -> Bool {
|
|
uploadProgress?.currentCategory = "Accounts"
|
|
return accountSync?.uploadEverything() ?? true
|
|
}
|
|
|
|
/// Registers every category of local data for upload, in re-seed order,
|
|
/// without asking the engine to send anything.
|
|
///
|
|
/// This is the half of the initial upload a `CKSyncEngineDelegate` callback
|
|
/// may run: it only mutates engine state, which the engine drains on its
|
|
/// own schedule. Asking the engine to fetch or send from inside one of its
|
|
/// event callbacks is a programmer error CloudKit traps on.
|
|
/// - Returns: whether every category registered what it owed.
|
|
@discardableResult
|
|
private func enqueueFullUpload() async -> Bool {
|
|
var complete = true
|
|
|
|
for category in ReseedCategory.reseedOrder {
|
|
switch category {
|
|
case .accounts:
|
|
// The one category whose registration can be refused, and the
|
|
// one that must not be quietly skipped: it carries the logins,
|
|
// the tombstones and the selected context.
|
|
complete = uploadAllAccounts() && complete
|
|
case .subscriptions:
|
|
enqueueAllLocalSubscriptions()
|
|
case .watchHistory:
|
|
enqueueAllLocalWatchHistory()
|
|
case .bookmarks:
|
|
enqueueAllLocalBookmarks()
|
|
case .playlists:
|
|
enqueueAllLocalPlaylists()
|
|
case .searchHistory:
|
|
enqueueAllLocalSearchHistory()
|
|
case .recentChannels:
|
|
enqueueAllRecentChannels()
|
|
case .recentPlaylists:
|
|
enqueueAllRecentPlaylists()
|
|
case .channelNotificationSettings:
|
|
enqueueAllLocalChannelNotificationSettings()
|
|
case .controlsPresets:
|
|
await enqueueAllLocalControlsPresets()
|
|
}
|
|
}
|
|
|
|
return complete
|
|
}
|
|
|
|
/// Performs full initial upload of all local data with progress tracking
|
|
func performInitialUpload() async {
|
|
guard isSyncEnabled else { return }
|
|
|
|
uploadProgress = UploadProgress(
|
|
currentCategory: "Starting...",
|
|
isComplete: false,
|
|
totalOperations: 0
|
|
)
|
|
|
|
// Register every category first, then send once: nine sends in a row
|
|
// bought nothing but round trips, and the accounts went last, which is
|
|
// how an interrupted upload lost them.
|
|
await enqueueFullUpload()
|
|
await sync()
|
|
|
|
// Mark complete
|
|
uploadProgress = UploadProgress(
|
|
currentCategory: "Complete",
|
|
isComplete: true,
|
|
totalOperations: uploadProgress?.totalOperations ?? 0
|
|
)
|
|
|
|
// Clear after 3 seconds
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(3))
|
|
uploadProgress = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Handling
|
|
|
|
private func handleSyncError(_ error: Error) {
|
|
guard let ckError = error as? CKError else {
|
|
return
|
|
}
|
|
|
|
switch ckError.code {
|
|
case .networkUnavailable, .networkFailure:
|
|
LoggingService.shared.logCloudKit("Network unavailable, will retry when connection returns")
|
|
scheduleRetry(delay: 30)
|
|
|
|
case .quotaExceeded:
|
|
LoggingService.shared.logCloudKitError("CloudKit quota exceeded", error: error)
|
|
syncError = CloudKitError.quotaExceeded
|
|
|
|
case .notAuthenticated:
|
|
LoggingService.shared.logCloudKitError("User not signed into iCloud", error: error)
|
|
syncError = CloudKitError.notAuthenticated
|
|
|
|
case .zoneNotFound:
|
|
// Through the engine's state: this can be reached from inside a
|
|
// delegate callback (`sync()` is called from several), where asking
|
|
// the engine to sync is a trap, and where the zone manager's
|
|
// memo would refuse to recreate anything anyway.
|
|
LoggingService.shared.logCloudKit("Zone not found, asking the engine to recreate it")
|
|
requestZoneRecreation()
|
|
|
|
case .partialFailure:
|
|
LoggingService.shared.logCloudKitError("Partial sync failure", error: error)
|
|
if let partialErrors = ckError.partialErrorsByItemID {
|
|
for (id, itemError) in partialErrors {
|
|
if let itemCKError = itemError as? CKError {
|
|
switch itemCKError.code {
|
|
case .serverRecordChanged:
|
|
// Record already exists - this is OK, we'll fetch it on next sync
|
|
LoggingService.shared.logCloudKit("Record already exists in CloudKit, will merge on next fetch: \(id)")
|
|
|
|
default:
|
|
LoggingService.shared.logCloudKitError("Failed to sync record: \(id)", error: itemError)
|
|
}
|
|
} else {
|
|
LoggingService.shared.logCloudKitError("Failed to sync record: \(id)", error: itemError)
|
|
}
|
|
}
|
|
}
|
|
|
|
default:
|
|
LoggingService.shared.logCloudKitError("CloudKit error: \(ckError.code.rawValue)", error: error)
|
|
}
|
|
}
|
|
|
|
private func scheduleRetry(delay: TimeInterval) {
|
|
// Detached, not a plain `Task`: an unstructured task copies the task
|
|
// locals of whatever created it, and this can be created from inside a
|
|
// sync-engine event callback — where CloudKit traps on a call back into
|
|
// the engine, and reads "inside the callback" from exactly those
|
|
// locals.
|
|
Task.detached { [weak self] in
|
|
try? await Task.sleep(for: .seconds(delay))
|
|
await self?.sync()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CKSyncEngineDelegate
|
|
|
|
extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|
nonisolated func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
|
|
// Marks the whole callback tree, so anything that re-enters the engine
|
|
// from inside it says so in a debug build instead of trapping on
|
|
// someone's device. Inherited by every plain `Task { }` started below
|
|
// it — which is precisely the mistake worth catching — and escaped by
|
|
// `Task.detached`, which is the fix.
|
|
await CloudKitDelegateScope.$isHandlingEvent.withValue(true) {
|
|
await handleDelegateEvent(event)
|
|
}
|
|
}
|
|
|
|
private nonisolated func handleDelegateEvent(_ event: CKSyncEngine.Event) async {
|
|
// Events that require async processing must be awaited directly (not in a fire-and-forget Task)
|
|
// so that CKSyncEngine waits for processing to complete before advancing the sync state token.
|
|
// Otherwise the state is saved before changes are applied, and on next launch CKSyncEngine
|
|
// thinks those changes were already processed.
|
|
switch event {
|
|
case .fetchedRecordZoneChanges(let changes):
|
|
await MainActor.run {
|
|
LoggingService.shared.logCloudKit("Fetched \(changes.modifications.count) modifications, \(changes.deletions.count) deletions")
|
|
}
|
|
await handleFetchedChanges(changes)
|
|
|
|
case .sentRecordZoneChanges(let changes):
|
|
await handleSentRecordZoneChanges(changes)
|
|
|
|
case .fetchedDatabaseChanges(let changes):
|
|
await handleFetchedDatabaseChanges(changes)
|
|
|
|
case .didFetchRecordZoneChanges(let changes):
|
|
// The zone's own result — not didFetchChanges, which carries only
|
|
// the fetch context and fires after failed zone fetches too — is
|
|
// what tells staged account records they may be applied.
|
|
await accountSync?.zoneFetchCompleted(zoneID: changes.zoneID, error: changes.error)
|
|
|
|
case .stateUpdate(let update):
|
|
// Numbered here, before the hop to the main actor: a recovery holds
|
|
// the main actor while it registers its work, so an update handed
|
|
// over before that can still run its body afterwards — carrying a
|
|
// serialization in which none of the work exists.
|
|
let delivery = stateUpdateDeliveries.next()
|
|
await MainActor.run {
|
|
let encoder = PropertyListEncoder()
|
|
let stateSize = (try? encoder.encode(update.stateSerialization))?.count ?? 0
|
|
LoggingService.shared.logCloudKit("Sync state updated and saved (\(stateSize) bytes)")
|
|
saveSyncState(update.stateSerialization)
|
|
// The state just written may be what a zone recovery registered
|
|
// itself into, so the note that it still owes that work can go
|
|
// — and not one moment earlier.
|
|
zoneRecovery.stateDidPersist(deliveredAt: delivery)
|
|
updatePendingCount()
|
|
}
|
|
|
|
default:
|
|
await MainActor.run {
|
|
switch event {
|
|
case .accountChange:
|
|
LoggingService.shared.logCloudKit("iCloud account change event received - reinitializing sync engine")
|
|
Task.detached { [weak self] in
|
|
await self?.setupSyncEngine()
|
|
}
|
|
|
|
case .willFetchChanges:
|
|
LoggingService.shared.logCloudKit("CKSyncEngine will fetch changes (auto-triggered)")
|
|
isReceivingChanges = true
|
|
// Binds everything this fetch delivers to the identity it
|
|
// started under; the Apple account can move mid-fetch.
|
|
accountSync?.fetchDidBegin()
|
|
|
|
case .didFetchChanges:
|
|
LoggingService.shared.logCloudKit("CKSyncEngine finished fetching changes")
|
|
isReceivingChanges = false
|
|
|
|
case .willFetchRecordZoneChanges, .willSendChanges, .didSendChanges, .sentDatabaseChanges:
|
|
break
|
|
|
|
default:
|
|
LoggingService.shared.logCloudKit("Unknown sync engine event")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func nextRecordZoneChangeBatch(
|
|
_ context: CKSyncEngine.SendChangesContext,
|
|
syncEngine: CKSyncEngine
|
|
) async -> CKSyncEngine.RecordZoneChangeBatch? {
|
|
let scope = context.options.scope
|
|
let pendingChanges = syncEngine.state.pendingRecordZoneChanges.filter { scope.contains($0) }
|
|
guard !pendingChanges.isEmpty else { return nil }
|
|
|
|
await MainActor.run {
|
|
LoggingService.shared.logCloudKit("Preparing batch from \(pendingChanges.count) pending changes")
|
|
}
|
|
|
|
return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pendingChanges) { [weak self] recordID in
|
|
await self?.recordToSave(for: recordID, engine: syncEngine)
|
|
}
|
|
}
|
|
|
|
/// Provides the record for a pending save at send time, materialized from
|
|
/// current local data. Returns nil (and removes the pending change) when
|
|
/// the entity no longer exists locally or is no longer syncable.
|
|
private func recordToSave(for recordID: CKRecord.ID, engine: CKSyncEngine) async -> CKRecord? {
|
|
let recordName = recordID.recordName
|
|
|
|
// A conflict-resolved record (carrying the server change tag) takes precedence
|
|
if let resolved = conflictResolvedRecords[recordName] {
|
|
if mayUseResolvedRecord(resolved) {
|
|
return resolved
|
|
}
|
|
|
|
// An account record built under another identity can never become
|
|
// valid here — including one resolved after the identity moved but
|
|
// before the accounts in memory caught up, which the invalidation on
|
|
// the change itself cannot have seen. Dropping it costs at most one
|
|
// more conflict round-trip; serving it would upload another Apple
|
|
// account's data.
|
|
conflictResolvedRecords.removeValue(forKey: recordName)
|
|
}
|
|
|
|
if let record = await materializeRecord(named: recordName) {
|
|
return record
|
|
}
|
|
|
|
engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Skipping save for \(recordName) — no matching local data")
|
|
return nil
|
|
}
|
|
|
|
/// Whether a conflict-resolved record may be cached for retry, and served
|
|
/// when that retry is sent.
|
|
///
|
|
/// This cache is keyed by record name alone, and account record names are
|
|
/// deterministic across Apple accounts — so the name says nothing about
|
|
/// whose record this is, and the record's own stamp has to be asked. Both
|
|
/// ends need the question: the resolution runs asynchronously and can finish
|
|
/// after the identity moved (too late for the invalidation that change
|
|
/// triggers), and the send can come later still. Falling through to
|
|
/// materialization is safe — the coordinator holds the change there, and the
|
|
/// durable outbox re-registers it. Every other category is unaffected:
|
|
/// nothing else here is namespaced by Apple account.
|
|
private func mayUseResolvedRecord(_ record: CKRecord) -> Bool {
|
|
guard AccountRecordName.isAccountCategory(record.recordID.recordName) else { return true }
|
|
return accountSync?.belongsToCurrentIdentity(record) ?? false
|
|
}
|
|
|
|
/// Puts an account record's engine change back when a conflict is abandoned.
|
|
///
|
|
/// The send consumed the pending change, and these paths return without
|
|
/// registering another — but the account outbox is durable and still owes
|
|
/// the record, and unlike the SwiftData categories nothing re-derives it
|
|
/// from a model change. It would sit there until an unrelated edit, an
|
|
/// identity reload or a relaunch happened to re-register it. The coordinator
|
|
/// decides what is actually still owed; a record that is not simply does
|
|
/// nothing here. Other categories keep the existing behaviour.
|
|
private func restoreAccountOutboxIfNeeded(for recordName: String) {
|
|
guard AccountRecordName.isAccountCategory(recordName) else { return }
|
|
accountSync?.enqueueDirtyRecords()
|
|
}
|
|
|
|
/// Drops the account records held from conflict resolution, for when the
|
|
/// identity moves. Called by the coordinator, which is what observes that.
|
|
private func discardConflictResolvedAccountRecords() {
|
|
let stale = conflictResolvedRecords.keys.filter { AccountRecordName.isAccountCategory($0) }
|
|
guard !stale.isEmpty else { return }
|
|
|
|
for recordName in stale {
|
|
conflictResolvedRecords.removeValue(forKey: recordName)
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit(
|
|
"Discarded \(stale.count) conflict-resolved account records after the Apple account changed"
|
|
)
|
|
}
|
|
|
|
// MARK: - Scope-Aware Local Lookups
|
|
|
|
/// Bare-ID lookups can return an entity from a different source scope when
|
|
/// IDs collide across sources; these helpers match on the mapper-derived
|
|
/// record name so the right entity is merged, materialized, or deleted.
|
|
private func localSubscription(matching recordName: String, channelID: String, in dataManager: DataManager) -> Subscription? {
|
|
dataManager.subscriptions(forChannelID: channelID).first {
|
|
recordMapper.toCKRecord(subscription: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localWatchEntry(matching recordName: String, videoID: String, in dataManager: DataManager) -> WatchEntry? {
|
|
dataManager.watchEntries(forVideoID: videoID).first {
|
|
recordMapper.toCKRecord(watchEntry: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localBookmark(matching recordName: String, videoID: String, in dataManager: DataManager) -> Bookmark? {
|
|
dataManager.bookmarks(forVideoID: videoID).first {
|
|
recordMapper.toCKRecord(bookmark: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localRecentChannel(matching recordName: String, channelID: String, in dataManager: DataManager) -> RecentChannel? {
|
|
dataManager.recentChannelEntries(forChannelID: channelID).first {
|
|
recordMapper.toCKRecord(recentChannel: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localRecentPlaylist(matching recordName: String, playlistID: String, in dataManager: DataManager) -> RecentPlaylist? {
|
|
dataManager.recentPlaylistEntries(forPlaylistID: playlistID).first {
|
|
recordMapper.toCKRecord(recentPlaylist: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localChannelNotificationSettings(matching recordName: String, channelID: String, in dataManager: DataManager) -> ChannelNotificationSettings? {
|
|
dataManager.allChannelNotificationSettings(forChannelID: channelID).first {
|
|
recordMapper.toCKRecord(channelNotificationSettings: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
/// Builds a CKRecord for the given record name from current local data.
|
|
/// The record name prefix identifies the entity type; the local entity is
|
|
/// matched on the full scoped record name (a mismatch means it belongs to
|
|
/// a different source scope).
|
|
private func materializeRecord(named recordName: String) async -> CKRecord? {
|
|
// Accounts are not SwiftData models and are built from the accounts
|
|
// store, which also decides whether a record may be sent yet.
|
|
if AccountRecordName.isAccountCategory(recordName) {
|
|
return accountSync?.recordToSave(named: recordName)
|
|
}
|
|
|
|
guard let dataManager else { return nil }
|
|
|
|
if recordName.hasPrefix("sub-") {
|
|
guard canSyncSubscriptions else { return nil }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(4)))
|
|
guard let subscription = localSubscription(matching: recordName, channelID: channelID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(subscription: subscription)
|
|
}
|
|
if recordName.hasPrefix("watch-") {
|
|
guard canSyncPlaybackHistory else { return nil }
|
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(6)))
|
|
guard let entry = localWatchEntry(matching: recordName, videoID: videoID, in: dataManager),
|
|
shouldSyncWatchEntry(entry) else { return nil }
|
|
return recordMapper.toCKRecord(watchEntry: entry)
|
|
}
|
|
if recordName.hasPrefix("bookmark-") {
|
|
guard canSyncBookmarks else { return nil }
|
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(9)))
|
|
guard let bookmark = localBookmark(matching: recordName, videoID: videoID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(bookmark: bookmark)
|
|
}
|
|
if recordName.hasPrefix("recent-channel-") {
|
|
guard canSyncSearchHistory else { return nil }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(15)))
|
|
guard let recentChannel = localRecentChannel(matching: recordName, channelID: channelID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(recentChannel: recentChannel)
|
|
}
|
|
if recordName.hasPrefix("recent-playlist-") {
|
|
guard canSyncSearchHistory else { return nil }
|
|
let playlistID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(16)))
|
|
guard let recentPlaylist = localRecentPlaylist(matching: recordName, playlistID: playlistID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(recentPlaylist: recentPlaylist)
|
|
}
|
|
if recordName.hasPrefix("channel-notif-") {
|
|
guard canSyncSubscriptions else { return nil }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(14)))
|
|
guard let settings = localChannelNotificationSettings(matching: recordName, channelID: channelID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(channelNotificationSettings: settings)
|
|
}
|
|
if recordName.hasPrefix("playlist-") {
|
|
guard canSyncPlaylists,
|
|
let id = UUID(uuidString: String(recordName.dropFirst(9))),
|
|
let playlist = dataManager.playlist(forID: id),
|
|
!playlist.isPlaceholder else { return nil }
|
|
return recordMapper.toCKRecord(playlist: playlist)
|
|
}
|
|
if recordName.hasPrefix("item-") {
|
|
guard canSyncPlaylists,
|
|
let id = UUID(uuidString: String(recordName.dropFirst(5))),
|
|
let item = dataManager.playlistItem(forID: id) else { return nil }
|
|
return recordMapper.toCKRecord(playlistItem: item)
|
|
}
|
|
if recordName.hasPrefix("search-") {
|
|
guard canSyncSearchHistory,
|
|
let id = UUID(uuidString: String(recordName.dropFirst(7))),
|
|
let entry = dataManager.searchHistoryEntry(forID: id) else { return nil }
|
|
return recordMapper.toCKRecord(searchHistory: entry)
|
|
}
|
|
if recordName.hasPrefix("controls-") {
|
|
guard canSyncControlsPresets,
|
|
let id = UUID(uuidString: String(recordName.dropFirst(9))) else { return nil }
|
|
let layoutService = playerControlsLayoutService ?? PlayerControlsLayoutService()
|
|
let presets = await layoutService.presetsForSync()
|
|
guard let preset = presets.first(where: { $0.id == id }) else { return nil }
|
|
return try? recordMapper.toCKRecord(preset: preset)
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit("Cannot materialize record with unknown prefix: \(recordName)")
|
|
return nil
|
|
}
|
|
|
|
/// Handle the result of a sent batch: clean up bookkeeping for successes
|
|
/// and decide per-record how to handle failures.
|
|
private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) async {
|
|
LoggingService.shared.logCloudKit("Sent \(changes.savedRecords.count) saves, \(changes.deletedRecordIDs.count) deletions (\(changes.failedRecordSaves.count) failed saves, \(changes.failedRecordDeletes.count) failed deletes)")
|
|
|
|
for record in changes.savedRecords {
|
|
let recordName = record.recordID.recordName
|
|
conflictResolvedRecords.removeValue(forKey: recordName)
|
|
retryCount.removeValue(forKey: recordName)
|
|
}
|
|
|
|
// Clears the account outbox and releases whatever its ordering held
|
|
// back. Only a confirmation does this — never the attempt.
|
|
accountSync?.didSave(records: changes.savedRecords)
|
|
|
|
for failedSave in changes.failedRecordSaves {
|
|
await handleFailedRecordSave(failedSave)
|
|
}
|
|
|
|
for (recordID, error) in changes.failedRecordDeletes {
|
|
handleFailedRecordDelete(recordID: recordID, error: error)
|
|
}
|
|
|
|
updatePendingCount()
|
|
}
|
|
|
|
/// Handle a failed record save. Retryable failures are re-registered with
|
|
/// the engine state (the engine schedules the retry with proper backoff),
|
|
/// conflicts are merged and re-sent; only genuinely fatal errors drop the
|
|
/// change.
|
|
private func handleFailedRecordSave(_ failedSave: CKSyncEngine.Event.SentRecordZoneChanges.FailedRecordSave) async {
|
|
let record = failedSave.record
|
|
let error = failedSave.error
|
|
let recordID = record.recordID
|
|
let recordName = recordID.recordName
|
|
|
|
switch error.code {
|
|
case .serverRecordChanged:
|
|
guard let serverRecord = error.serverRecord else {
|
|
LoggingService.shared.logCloudKitError("No server record in conflict error for \(recordName)", error: error)
|
|
conflictResolvedRecords.removeValue(forKey: recordName)
|
|
restoreAccountOutboxIfNeeded(for: recordName)
|
|
return
|
|
}
|
|
|
|
let attempts = retryCount[recordName] ?? 0
|
|
guard attempts < maxRetryAttempts else {
|
|
LoggingService.shared.logCloudKitError(
|
|
"Record \(recordName) failed after \(maxRetryAttempts) conflict resolution attempts, giving up",
|
|
error: error
|
|
)
|
|
conflictResolvedRecords.removeValue(forKey: recordName)
|
|
retryCount.removeValue(forKey: recordName)
|
|
restoreAccountOutboxIfNeeded(for: recordName)
|
|
return
|
|
}
|
|
retryCount[recordName] = attempts + 1
|
|
|
|
// Merge the record we tried to send with the server's version.
|
|
// The resolved record keeps the server change tag, so the retry
|
|
// is accepted as an update.
|
|
let resolved = await resolveConflict(local: record, server: serverRecord)
|
|
|
|
// The resolution suspends, and the Apple account can move across
|
|
// that suspension: this handler may have begun for an identity that
|
|
// is no longer the one this cache would serve the retry to. Neither
|
|
// the record nor its retry is this identity's to send.
|
|
guard mayUseResolvedRecord(resolved) else {
|
|
conflictResolvedRecords.removeValue(forKey: recordName)
|
|
retryCount.removeValue(forKey: recordName)
|
|
LoggingService.shared.logCloudKit(
|
|
"Dropping the conflict resolution for \(recordName) — it was built under a different Apple account"
|
|
)
|
|
return
|
|
}
|
|
|
|
conflictResolvedRecords[recordName] = resolved
|
|
syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Resolved \(serverRecord.recordType) conflict for \(recordName), retrying (attempt \(attempts + 1))")
|
|
|
|
case .unknownItem:
|
|
// Record vanished server-side while we held its change tag —
|
|
// drop the cached record and retry as a fresh insert.
|
|
conflictResolvedRecords.removeValue(forKey: recordName)
|
|
|
|
// Unless a deletion tombstone dominates it. Reinserting an account
|
|
// another device deleted is exactly how a queued offline edit
|
|
// resurrects it.
|
|
if let accountSync, !accountSync.shouldRetryAsInsert(recordName: recordName) {
|
|
LoggingService.shared.logCloudKit("Record \(recordName) is deleted, not reinserting")
|
|
return
|
|
}
|
|
|
|
syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Record \(recordName) missing on server, retrying as insert")
|
|
|
|
case .zoneNotFound:
|
|
// Zone was deleted — recreate it and retry the save. The engine
|
|
// saves a pending zone ahead of the records that need it, and
|
|
// unlike the zone manager it has no memo saying it already did.
|
|
syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
requestZoneRecreation()
|
|
LoggingService.shared.logCloudKit("Zone missing for \(recordName), recreating and retrying")
|
|
|
|
case .zoneBusy, .serviceUnavailable, .requestRateLimited, .networkFailure, .networkUnavailable,
|
|
.notAuthenticated, .accountTemporarilyUnavailable, .batchRequestFailed, .limitExceeded:
|
|
// Transient — keep the change pending; the engine retries with backoff
|
|
syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Transient error (\(error.code.rawValue)) for \(recordName), will retry")
|
|
|
|
default:
|
|
conflictResolvedRecords.removeValue(forKey: recordName)
|
|
retryCount.removeValue(forKey: recordName)
|
|
LoggingService.shared.logCloudKitError("Unrecoverable error for \(recordName), dropping change", error: error)
|
|
}
|
|
}
|
|
|
|
/// Handle a failed record deletion. Retryable failures are re-registered
|
|
/// with the engine state; a record already missing on the server is done.
|
|
private func handleFailedRecordDelete(recordID: CKRecord.ID, error: CKError) {
|
|
switch error.code {
|
|
case .unknownItem, .zoneNotFound:
|
|
// Already gone on the server — nothing to do
|
|
break
|
|
|
|
case .zoneBusy, .serviceUnavailable, .requestRateLimited, .networkFailure, .networkUnavailable,
|
|
.notAuthenticated, .accountTemporarilyUnavailable, .batchRequestFailed, .limitExceeded:
|
|
syncEngine?.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
|
|
LoggingService.shared.logCloudKit("Transient error (\(error.code.rawValue)) deleting \(recordID.recordName), will retry")
|
|
|
|
default:
|
|
LoggingService.shared.logCloudKitError("Unrecoverable error deleting \(recordID.recordName), dropping change", error: error)
|
|
}
|
|
}
|
|
|
|
/// Applies type-specific conflict resolution between the record we tried
|
|
/// to send and the server's current version.
|
|
private func resolveConflict(local: CKRecord, server: CKRecord) async -> CKRecord {
|
|
switch server.recordType {
|
|
case RecordType.subscription:
|
|
return await conflictResolver.resolveSubscriptionConflict(local: local, server: server)
|
|
case RecordType.watchEntry:
|
|
return await conflictResolver.resolveWatchEntryConflict(local: local, server: server)
|
|
case RecordType.bookmark:
|
|
return await conflictResolver.resolveBookmarkConflict(local: local, server: server)
|
|
case RecordType.localPlaylist:
|
|
return await conflictResolver.resolveLocalPlaylistConflict(local: local, server: server)
|
|
case RecordType.localPlaylistItem:
|
|
return await conflictResolver.resolveLocalPlaylistItemConflict(local: local, server: server)
|
|
case RecordType.searchHistory:
|
|
return await conflictResolver.resolveSearchHistoryConflict(local: local, server: server)
|
|
case RecordType.recentChannel:
|
|
return await conflictResolver.resolveRecentChannelConflict(local: local, server: server)
|
|
case RecordType.recentPlaylist:
|
|
return await conflictResolver.resolveRecentPlaylistConflict(local: local, server: server)
|
|
case RecordType.controlsPreset:
|
|
return await conflictResolver.resolveLayoutPresetConflict(local: local, server: server)
|
|
case RecordType.channelNotificationSettings:
|
|
return await conflictResolver.resolveChannelNotificationSettingsConflict(local: local, server: server)
|
|
case RecordType.account, RecordType.dataContext,
|
|
RecordType.legacyAccountMigration, RecordType.accountDeletion:
|
|
guard let accountSync else { return server }
|
|
return await accountSync.resolveConflict(local: local, server: server)
|
|
default:
|
|
// Unknown type - use server version (safe fallback)
|
|
LoggingService.shared.logCloudKit("Unknown record type \(server.recordType), using server version")
|
|
return server
|
|
}
|
|
}
|
|
|
|
/// Handle fetched database-level changes (zone creations/deletions).
|
|
///
|
|
/// A remote deletion of our zone — a reset from another device, an iCloud
|
|
/// data purge, an encrypted-data reset — would otherwise leave sync
|
|
/// silently dead against a zone that holds nothing.
|
|
///
|
|
/// Runs inside `handleEvent`, so it may only write to durable storage and
|
|
/// to the engine's state. It must never call `fetchChanges()` or
|
|
/// `sendChanges()`, directly or through anything it awaits: CloudKit traps
|
|
/// on re-entrant engine calls, in release builds, and the trap is what
|
|
/// killed the app on device A in the S14 campaign.
|
|
private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) async {
|
|
LoggingService.shared.logCloudKit("Fetched database changes")
|
|
|
|
guard let deletion = changes.deletions.first(where: { $0.zoneID.zoneName == RecordType.zoneName }) else {
|
|
return
|
|
}
|
|
|
|
await answerZoneDeletion(ZoneDeletionResponse(reason: deletion.reason))
|
|
}
|
|
|
|
/// Answers a deletion of our zone, using nothing but durable storage and
|
|
/// the engine's own state.
|
|
///
|
|
/// The two responses are not equally repeatable, and their markers are
|
|
/// retired on different terms because of it — see ``ZoneRecoveryRetirement``.
|
|
/// A re-seed only adds, so it is safe to run twice and is held open until
|
|
/// the engine has written it down. A purge removes, and running it a second
|
|
/// time would remove work registered in between, so it is retired the
|
|
/// moment its durable half succeeds.
|
|
private func answerZoneDeletion(_ response: ZoneDeletionResponse) async {
|
|
// Durable first. Everything below can be interrupted, and a relaunch
|
|
// that finds the zone recreated and no marker cannot tell a wiped zone
|
|
// from a healthy one — which is how the wipe became permanent on
|
|
// device A.
|
|
zoneRecovery.begin(response)
|
|
|
|
// Cached conflict records hold change tags from the deleted zone
|
|
conflictResolvedRecords.removeAll()
|
|
retryCount.removeAll()
|
|
|
|
switch response {
|
|
case .reseed:
|
|
LoggingService.shared.logCloudKit(
|
|
"Zone '\(RecordType.zoneName)' was deleted remotely - recreating it and re-registering local data"
|
|
)
|
|
// Through the engine, not the zone manager: the manager remembers
|
|
// having created the zone this session and would do nothing, and
|
|
// the engine saves the zone ahead of the records that need it.
|
|
requestZoneRecreation()
|
|
finish(response, ifComplete: await enqueueFullUpload())
|
|
|
|
case .discard:
|
|
LoggingService.shared.logCloudKit(
|
|
"Zone '\(RecordType.zoneName)' was purged by the account owner - dropping what it held, nothing is resent"
|
|
)
|
|
finish(response, ifComplete: discardWhatThePurgedZoneHeld())
|
|
}
|
|
}
|
|
|
|
/// Retires the marker, or arms it to be retired, once the recovery reached
|
|
/// every category.
|
|
///
|
|
/// The account half can refuse while the identity it belongs to has not
|
|
/// loaded, and a recovery that left the accounts out is one that has to be
|
|
/// answered again — by the retry that runs when the accounts arrive, or by
|
|
/// the next launch.
|
|
///
|
|
/// When it did reach everything, *how* the marker goes depends on the
|
|
/// response: a re-seed waits for the engine to serialize the work it
|
|
/// registered, a purge is already done. See ``ZoneRecoveryRetirement``.
|
|
private func finish(_ response: ZoneDeletionResponse, ifComplete complete: Bool) {
|
|
guard complete else {
|
|
zoneRecoveryAwaitsAccounts = true
|
|
LoggingService.shared.logCloudKit(
|
|
"Zone recovery could not reach the account records yet - keeping the marker for the accounts to arrive"
|
|
)
|
|
return
|
|
}
|
|
|
|
zoneRecoveryAwaitsAccounts = false
|
|
|
|
switch response.retirement {
|
|
case .whenStatePersists:
|
|
zoneRecovery.readyToClear(afterDelivery: stateUpdateDeliveries.current)
|
|
case .immediately:
|
|
zoneRecovery.retireNow()
|
|
}
|
|
}
|
|
|
|
/// Re-registers the recovery a previous run recorded but may not have
|
|
/// finished.
|
|
///
|
|
/// Called during setup, and again whenever the accounts this device could
|
|
/// not speak for become readable. Never from inside a delegate callback:
|
|
/// the recovery is safe there, but the retry's trigger is a notification
|
|
/// the delegate tree posts, and the hop out of it is what keeps that true.
|
|
private func resumeZoneRecoveryIfNeeded() async {
|
|
guard let response = zoneRecovery.pending else { return }
|
|
|
|
LoggingService.shared.logCloudKit(
|
|
"A zone deletion from an earlier run was not fully answered (\(response.rawValue)) - answering it now"
|
|
)
|
|
await answerZoneDeletion(response)
|
|
}
|
|
|
|
/// Asks the engine to recreate the zone as part of its next send.
|
|
private func requestZoneRecreation() {
|
|
guard let syncEngine else { return }
|
|
syncEngine.state.add(pendingDatabaseChanges: [.saveZone(zone)])
|
|
}
|
|
|
|
/// Drops everything this device still owed the zone, and everything it had
|
|
/// cached from it.
|
|
///
|
|
/// For the purge reasons only. CloudKit is explicit that data deleted that
|
|
/// way must not be sent back, and a pending change is precisely a record
|
|
/// waiting to be sent. Local data itself is untouched: it is this app's own
|
|
/// store, and the purge was about the copy in iCloud.
|
|
/// - Returns: whether the account category could be cleared too. It cannot
|
|
/// while the accounts in memory belong to another identity, and a purge
|
|
/// half-answered has to be answered again.
|
|
private func discardWhatThePurgedZoneHeld() -> Bool {
|
|
if let syncEngine {
|
|
let pending = syncEngine.state.pendingRecordZoneChanges
|
|
if !pending.isEmpty {
|
|
syncEngine.state.remove(pendingRecordZoneChanges: pending)
|
|
}
|
|
|
|
// A pending `.saveZone` would put the purged zone back on the next
|
|
// send — from an earlier `.zoneNotFound`, or from a re-seed this
|
|
// purge overtook. The zone comes back lazily when the user next
|
|
// changes something, which is a new decision rather than a resend.
|
|
let pendingZones = syncEngine.state.pendingDatabaseChanges
|
|
if !pendingZones.isEmpty {
|
|
syncEngine.state.remove(pendingDatabaseChanges: pendingZones)
|
|
}
|
|
}
|
|
pendingChangesBuffer.removeAll()
|
|
clearDeferredItems()
|
|
|
|
// Written by a build older than the state-driven engine and consumed by
|
|
// `migrateLegacyPendingChanges(into:)`. A resumed purge runs after that
|
|
// migration, so these are normally already spent — but a purge answered
|
|
// before the migration ever ran would otherwise leave them to be
|
|
// rediscovered and sent on a later launch.
|
|
UserDefaults.standard.removeObject(forKey: pendingSaveRecordNamesKey)
|
|
UserDefaults.standard.removeObject(forKey: pendingDeleteRecordNamesKey)
|
|
|
|
let accountsCleared = accountSync?.discardWhatTheZoneHeld() ?? true
|
|
updatePendingCount()
|
|
return accountsCleared
|
|
}
|
|
|
|
/// Handle fetched changes from CloudKit.
|
|
private func handleFetchedChanges(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) async {
|
|
// Staged first, and before any early return: the engine may persist its
|
|
// change token after this event, so anything not written down here is
|
|
// gone for good. Account records are never applied inline — they are
|
|
// applied as a set, tombstones first, once the zone fetch succeeds.
|
|
accountSync?.stageFetched(
|
|
records: changes.modifications.map(\.record),
|
|
deletions: changes.deletions.map(\.recordID)
|
|
)
|
|
|
|
guard let dataManager else { return }
|
|
|
|
// Load persisted deferred items from previous sessions
|
|
loadDeferredItems()
|
|
|
|
// Track newly deferred playlist items from this batch
|
|
var newlyDeferredItems: [(record: CKRecord, playlistID: UUID, itemID: UUID)] = []
|
|
|
|
// Apply modifications - process all incoming records
|
|
for modification in changes.modifications {
|
|
guard !RecordType.accountCategory.contains(modification.record.recordType) else { continue }
|
|
let result = await applyRemoteRecord(modification.record, to: dataManager)
|
|
if case .deferred(let playlistID, let itemID) = result {
|
|
newlyDeferredItems.append((modification.record, playlistID, itemID))
|
|
}
|
|
}
|
|
|
|
// Add newly deferred items to the persistent queue
|
|
for (record, playlistID, itemID) in newlyDeferredItems {
|
|
if let deferredItem = try? DeferredPlaylistItem(record: record, playlistID: playlistID, itemID: itemID) {
|
|
// Check if already in the deferred list (avoid duplicates)
|
|
if !deferredPlaylistItems.contains(where: { $0.itemID == deferredItem.itemID }) {
|
|
deferredPlaylistItems.append(deferredItem)
|
|
LoggingService.shared.logCloudKit("Added item to deferred queue: \(itemID), playlist: \(playlistID)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Retry ALL deferred items (including from previous sessions)
|
|
await retryDeferredItems(dataManager: dataManager)
|
|
|
|
// Persist remaining deferred items for next sync
|
|
persistDeferredItems()
|
|
|
|
// Clean up placeholders whose parent playlist is pending deletion
|
|
let allPlaylists = dataManager.playlists()
|
|
for playlist in allPlaylists where playlist.isPlaceholder {
|
|
let playlistRecordName = "playlist-\(playlist.id.uuidString)"
|
|
if hasPendingDelete(recordName: playlistRecordName) {
|
|
dataManager.deletePlaylist(playlist)
|
|
LoggingService.shared.logCloudKit("Cleaned up placeholder for deleted playlist: \(playlist.id)")
|
|
}
|
|
}
|
|
|
|
// Apply deletions
|
|
for deletion in changes.deletions {
|
|
guard !AccountRecordName.isAccountCategory(deletion.recordID.recordName) else { continue }
|
|
await applyRemoteDeletion(deletion.recordID, to: dataManager)
|
|
}
|
|
|
|
// Update last sync date when receiving changes from other devices
|
|
if !changes.modifications.isEmpty || !changes.deletions.isEmpty {
|
|
lastSyncDate = Date()
|
|
settingsManager?.updateLastSyncTime()
|
|
LoggingService.shared.logCloudKit("Updated lastSyncDate after receiving \(changes.modifications.count) modifications, \(changes.deletions.count) deletions")
|
|
}
|
|
}
|
|
|
|
/// Apply a remote record change to local SwiftData.
|
|
/// Returns the result indicating success, deferral (for playlist items without parent), or failure.
|
|
@discardableResult
|
|
private func applyRemoteRecord(_ record: CKRecord, to dataManager: DataManager) async -> ApplyRecordResult {
|
|
// Skip records that are pending local deletion
|
|
if hasPendingDelete(recordName: record.recordID.recordName) {
|
|
LoggingService.shared.logCloudKit("Skipping incoming record (pending local delete): \(record.recordID.recordName)")
|
|
return .success
|
|
}
|
|
|
|
// For playlist items, also skip if parent playlist is pending deletion
|
|
if record.recordType == RecordType.localPlaylistItem,
|
|
let playlistIDString = record["playlistID"] as? String {
|
|
let playlistRecordName = "playlist-\(playlistIDString)"
|
|
if hasPendingDelete(recordName: playlistRecordName) {
|
|
LoggingService.shared.logCloudKit("Skipping playlist item (parent playlist pending delete): \(record.recordID.recordName)")
|
|
return .success
|
|
}
|
|
}
|
|
|
|
do {
|
|
switch record.recordType {
|
|
case RecordType.subscription:
|
|
guard canSyncSubscriptions else { return .success }
|
|
let subscription = try recordMapper.toSubscription(from: record)
|
|
|
|
// Check if exists locally (same source scope only)
|
|
if let existing = localSubscription(matching: record.recordID.recordName, channelID: subscription.channelID, in: dataManager) {
|
|
let localWasNewer = existing.lastUpdatedAt > subscription.lastUpdatedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(subscription: existing)
|
|
let resolved = await conflictResolver.resolveSubscriptionConflict(local: localRecord, server: record)
|
|
let resolvedSubscription = try recordMapper.toSubscription(from: resolved)
|
|
|
|
// Update existing subscription with resolved data
|
|
existing.name = resolvedSubscription.name
|
|
existing.channelDescription = resolvedSubscription.channelDescription
|
|
existing.subscriberCount = resolvedSubscription.subscriberCount
|
|
existing.avatarURLString = resolvedSubscription.avatarURLString
|
|
existing.bannerURLString = resolvedSubscription.bannerURLString
|
|
existing.isVerified = resolvedSubscription.isVerified
|
|
existing.lastUpdatedAt = resolvedSubscription.lastUpdatedAt
|
|
existing.providerName = resolvedSubscription.providerName
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates after conflict resolution
|
|
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged subscription from iCloud (conflict resolved): \(subscription.channelID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New subscription from iCloud
|
|
dataManager.insertSubscription(subscription)
|
|
|
|
// Post notification to update UI
|
|
let change = SubscriptionChange(addedSubscriptions: [subscription], removedChannelIDs: [])
|
|
NotificationCenter.default.post(
|
|
name: .subscriptionsDidChange,
|
|
object: nil,
|
|
userInfo: [SubscriptionChange.userInfoKey: change]
|
|
)
|
|
|
|
LoggingService.shared.logCloudKit("Added subscription from iCloud: \(subscription.channelID)")
|
|
}
|
|
|
|
case RecordType.watchEntry:
|
|
guard canSyncPlaybackHistory else { return .success }
|
|
let watchEntry = try recordMapper.toWatchEntry(from: record)
|
|
|
|
guard shouldSyncWatchEntry(watchEntry) else {
|
|
LoggingService.shared.logCloudKit("Ignoring incoming local-folder watch entry: \(watchEntry.videoID)")
|
|
return .success
|
|
}
|
|
|
|
// Check if exists locally (same source scope only)
|
|
if let existing = localWatchEntry(matching: record.recordID.recordName, videoID: watchEntry.videoID, in: dataManager) {
|
|
let localWasNewer = existing.updatedAt > watchEntry.updatedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(watchEntry: existing)
|
|
let resolved = await conflictResolver.resolveWatchEntryConflict(local: localRecord, server: record)
|
|
let resolvedEntry = try recordMapper.toWatchEntry(from: resolved)
|
|
|
|
// Update existing watch entry with resolved data
|
|
existing.watchedSeconds = resolvedEntry.watchedSeconds
|
|
existing.isFinished = resolvedEntry.isFinished
|
|
existing.finishedAt = resolvedEntry.finishedAt
|
|
existing.isLive = resolvedEntry.isLive
|
|
existing.updatedAt = resolvedEntry.updatedAt
|
|
|
|
// Update metadata if newer
|
|
existing.title = resolvedEntry.title
|
|
existing.authorName = resolvedEntry.authorName
|
|
existing.authorID = resolvedEntry.authorID
|
|
existing.duration = resolvedEntry.duration
|
|
existing.thumbnailURLString = resolvedEntry.thumbnailURLString
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates after conflict resolution
|
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged watch entry from iCloud (conflict resolved): \(watchEntry.videoID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New watch entry from iCloud
|
|
dataManager.insertWatchEntry(watchEntry)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added watch entry from iCloud: \(watchEntry.videoID)")
|
|
}
|
|
|
|
case RecordType.bookmark:
|
|
guard canSyncBookmarks else { return .success }
|
|
let bookmark = try recordMapper.toBookmark(from: record)
|
|
|
|
// Check if exists locally (same source scope only)
|
|
if let existing = localBookmark(matching: record.recordID.recordName, videoID: bookmark.videoID, in: dataManager) {
|
|
let localWasNewer = existing.createdAt > bookmark.createdAt
|
|
|| (existing.noteModifiedAt ?? .distantPast) > (bookmark.noteModifiedAt ?? .distantPast)
|
|
|| (existing.tagsModifiedAt ?? .distantPast) > (bookmark.tagsModifiedAt ?? .distantPast)
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(bookmark: existing)
|
|
let resolved = await conflictResolver.resolveBookmarkConflict(local: localRecord, server: record)
|
|
let resolvedBookmark = try recordMapper.toBookmark(from: resolved)
|
|
|
|
// Update existing bookmark with resolved data
|
|
existing.title = resolvedBookmark.title
|
|
existing.authorName = resolvedBookmark.authorName
|
|
existing.authorID = resolvedBookmark.authorID
|
|
existing.duration = resolvedBookmark.duration
|
|
existing.thumbnailURLString = resolvedBookmark.thumbnailURLString
|
|
existing.isLive = resolvedBookmark.isLive
|
|
existing.viewCount = resolvedBookmark.viewCount
|
|
existing.publishedAt = resolvedBookmark.publishedAt
|
|
existing.publishedText = resolvedBookmark.publishedText
|
|
existing.note = resolvedBookmark.note
|
|
existing.noteModifiedAt = resolvedBookmark.noteModifiedAt
|
|
existing.tags = resolvedBookmark.tags
|
|
existing.tagsModifiedAt = resolvedBookmark.tagsModifiedAt
|
|
existing.sortOrder = resolvedBookmark.sortOrder
|
|
existing.createdAt = resolvedBookmark.createdAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates after conflict resolution
|
|
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged bookmark from iCloud (conflict resolved): \(bookmark.videoID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New bookmark from iCloud
|
|
dataManager.insertBookmark(bookmark)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added bookmark from iCloud: \(bookmark.videoID)")
|
|
}
|
|
|
|
case RecordType.localPlaylist:
|
|
guard canSyncPlaylists else { return .success }
|
|
let playlist = try recordMapper.toLocalPlaylist(from: record)
|
|
|
|
// Check if exists locally
|
|
if let existing = dataManager.playlist(forID: playlist.id) {
|
|
// Check if this is a placeholder being upgraded to real playlist
|
|
let wasPlaceholder = existing.isPlaceholder
|
|
|
|
if wasPlaceholder {
|
|
// Upgrade placeholder to real playlist - use incoming data directly
|
|
existing.title = playlist.title
|
|
existing.playlistDescription = playlist.playlistDescription
|
|
existing.createdAt = playlist.createdAt
|
|
existing.updatedAt = playlist.updatedAt
|
|
existing.isPlaceholder = false
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Upgraded placeholder to real playlist: \(playlist.title)")
|
|
} else {
|
|
let localWasNewer = existing.updatedAt > playlist.updatedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(playlist: existing)
|
|
let resolved = await conflictResolver.resolveLocalPlaylistConflict(local: localRecord, server: record)
|
|
let resolvedPlaylist = try recordMapper.toLocalPlaylist(from: resolved)
|
|
|
|
// Update existing playlist with resolved data
|
|
existing.title = resolvedPlaylist.title
|
|
existing.playlistDescription = resolvedPlaylist.playlistDescription
|
|
existing.updatedAt = resolvedPlaylist.updatedAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged playlist from iCloud (conflict resolved): \(playlist.title)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
}
|
|
} else {
|
|
// New playlist from iCloud
|
|
dataManager.insertPlaylist(playlist)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added playlist from iCloud: \(playlist.title)")
|
|
}
|
|
|
|
case RecordType.localPlaylistItem:
|
|
guard canSyncPlaylists else { return .success }
|
|
let (item, playlistID) = try recordMapper.toLocalPlaylistItem(from: record)
|
|
|
|
// Check if exists locally
|
|
if let existing = dataManager.playlistItem(forID: item.id) {
|
|
let localWasNewer = existing.addedAt > item.addedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(playlistItem: existing)
|
|
let resolved = await conflictResolver.resolveLocalPlaylistItemConflict(local: localRecord, server: record)
|
|
let (resolvedItem, _) = try recordMapper.toLocalPlaylistItem(from: resolved)
|
|
|
|
// Update existing item with resolved data
|
|
existing.title = resolvedItem.title
|
|
existing.authorName = resolvedItem.authorName
|
|
existing.authorID = resolvedItem.authorID
|
|
existing.duration = resolvedItem.duration
|
|
existing.thumbnailURLString = resolvedItem.thumbnailURLString
|
|
existing.isLive = resolvedItem.isLive
|
|
existing.sortOrder = resolvedItem.sortOrder
|
|
|
|
dataManager.save()
|
|
|
|
LoggingService.shared.logCloudKit("Merged playlist item from iCloud (conflict resolved): \(item.videoID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New item from iCloud - need to find parent playlist
|
|
if let playlistID = playlistID,
|
|
let parentPlaylist = dataManager.playlist(forID: playlistID) {
|
|
item.playlist = parentPlaylist
|
|
dataManager.insertPlaylistItem(item)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added playlist item from iCloud: \(item.videoID)")
|
|
} else if let playlistID = playlistID {
|
|
// Parent playlist not found yet - defer this item
|
|
LoggingService.shared.logCloudKit("Deferring playlist item (parent not yet available): \(item.id)")
|
|
return .deferred(playlistID: playlistID, itemID: item.id)
|
|
} else {
|
|
// No playlist ID - can't process this item
|
|
LoggingService.shared.logCloudKitError("Playlist item has no playlistID", error: NSError(domain: "CloudKitSyncEngine", code: -1, userInfo: [NSLocalizedDescriptionKey: "Playlist item missing playlistID"]))
|
|
return .failed
|
|
}
|
|
}
|
|
|
|
case RecordType.searchHistory:
|
|
guard canSyncSearchHistory else { return .success }
|
|
let searchHistory = try recordMapper.toSearchHistory(from: record)
|
|
|
|
// Check if exists locally
|
|
if let existing = dataManager.searchHistoryEntry(forID: searchHistory.id) {
|
|
let localWasNewer = existing.searchedAt > searchHistory.searchedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(searchHistory: existing)
|
|
let resolved = await conflictResolver.resolveSearchHistoryConflict(local: localRecord, server: record)
|
|
let resolvedHistory = try recordMapper.toSearchHistory(from: resolved)
|
|
|
|
// Update existing search history with resolved data
|
|
existing.query = resolvedHistory.query
|
|
existing.searchedAt = resolvedHistory.searchedAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged search history from iCloud (conflict resolved): \(searchHistory.query)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New search history from iCloud
|
|
dataManager.insertSearchHistory(searchHistory)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added search history from iCloud: \(searchHistory.query)")
|
|
}
|
|
|
|
case RecordType.recentChannel:
|
|
guard canSyncSearchHistory else { return .success }
|
|
let recentChannel = try recordMapper.toRecentChannel(from: record)
|
|
|
|
// Check if exists locally (same source scope only)
|
|
if let existing = localRecentChannel(matching: record.recordID.recordName, channelID: recentChannel.channelID, in: dataManager) {
|
|
let localWasNewer = existing.visitedAt > recentChannel.visitedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(recentChannel: existing)
|
|
let resolved = await conflictResolver.resolveRecentChannelConflict(local: localRecord, server: record)
|
|
let resolvedChannel = try recordMapper.toRecentChannel(from: resolved)
|
|
|
|
// Update existing recent channel with resolved data
|
|
existing.name = resolvedChannel.name
|
|
existing.thumbnailURLString = resolvedChannel.thumbnailURLString
|
|
existing.sourceRawValue = resolvedChannel.sourceRawValue
|
|
existing.instanceURLString = resolvedChannel.instanceURLString
|
|
existing.subscriberCount = resolvedChannel.subscriberCount
|
|
existing.isVerified = resolvedChannel.isVerified
|
|
existing.visitedAt = resolvedChannel.visitedAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged recent channel from iCloud (conflict resolved): \(recentChannel.channelID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New recent channel from iCloud
|
|
dataManager.insertRecentChannel(recentChannel)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added recent channel from iCloud: \(recentChannel.channelID)")
|
|
}
|
|
|
|
case RecordType.recentPlaylist:
|
|
guard canSyncSearchHistory else { return .success }
|
|
let recentPlaylist = try recordMapper.toRecentPlaylist(from: record)
|
|
|
|
// Check if exists locally (same source scope only)
|
|
if let existing = localRecentPlaylist(matching: record.recordID.recordName, playlistID: recentPlaylist.playlistID, in: dataManager) {
|
|
let localWasNewer = existing.visitedAt > recentPlaylist.visitedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(recentPlaylist: existing)
|
|
let resolved = await conflictResolver.resolveRecentPlaylistConflict(local: localRecord, server: record)
|
|
let resolvedPlaylist = try recordMapper.toRecentPlaylist(from: resolved)
|
|
|
|
// Update existing recent playlist with resolved data
|
|
existing.title = resolvedPlaylist.title
|
|
existing.authorName = resolvedPlaylist.authorName
|
|
existing.thumbnailURLString = resolvedPlaylist.thumbnailURLString
|
|
existing.videoCount = resolvedPlaylist.videoCount
|
|
existing.sourceRawValue = resolvedPlaylist.sourceRawValue
|
|
existing.instanceURLString = resolvedPlaylist.instanceURLString
|
|
existing.visitedAt = resolvedPlaylist.visitedAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged recent playlist from iCloud (conflict resolved): \(recentPlaylist.playlistID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New recent playlist from iCloud
|
|
dataManager.insertRecentPlaylist(recentPlaylist)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added recent playlist from iCloud: \(recentPlaylist.playlistID)")
|
|
}
|
|
|
|
case RecordType.channelNotificationSettings:
|
|
guard canSyncSubscriptions else { return .success }
|
|
let settings = try recordMapper.toChannelNotificationSettings(from: record)
|
|
|
|
// Upsert keeps whichever side has the newer updatedAt
|
|
let existingSettings = dataManager.channelNotificationSettings(for: settings.channelID)
|
|
let localWasNewer = existingSettings.map { $0.updatedAt > settings.updatedAt } ?? false
|
|
dataManager.upsertChannelNotificationSettings(settings)
|
|
|
|
// Push the local version back when it won - the server still
|
|
// has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit("Applied channel notification settings from iCloud: \(settings.channelID)")
|
|
|
|
case RecordType.controlsPreset:
|
|
guard canSyncControlsPresets else { return .success }
|
|
let preset = try recordMapper.toLayoutPreset(from: record)
|
|
|
|
// Only import if device class matches current device
|
|
guard preset.deviceClass == .current else {
|
|
LoggingService.shared.logCloudKit("Skipping controls preset - wrong device class: \(preset.deviceClass)")
|
|
return .success
|
|
}
|
|
|
|
// Use shared layout service if available, fallback to new instance
|
|
let layoutService = playerControlsLayoutService ?? PlayerControlsLayoutService()
|
|
|
|
// importPreset keeps whichever side has the newer updatedAt
|
|
let localPresets = await layoutService.presetsForSync()
|
|
let localPresetWasNewer = localPresets.first(where: { $0.id == preset.id }).map { $0.updatedAt > preset.updatedAt } ?? false
|
|
try await layoutService.importPreset(preset)
|
|
|
|
// Push the local version back when it won - the server still
|
|
// has the older version
|
|
if localPresetWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit("Applied controls preset from iCloud: \(preset.name)")
|
|
|
|
default:
|
|
LoggingService.shared.logCloudKit("Received unknown record type: \(record.recordType)")
|
|
}
|
|
} catch let cloudKitError as CloudKitError {
|
|
// Track unsupported schema version for UI warning
|
|
if case .unsupportedSchemaVersion = cloudKitError {
|
|
hasNewerSchemaRecords = true
|
|
}
|
|
LoggingService.shared.logCloudKitError("Failed to parse \(record.recordType) record: \(cloudKitError.localizedDescription)", error: cloudKitError)
|
|
return .failed
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to apply remote \(record.recordType) record", error: error)
|
|
return .failed
|
|
}
|
|
return .success
|
|
}
|
|
|
|
/// Apply a remote deletion to local SwiftData.
|
|
/// Entities are matched on the full scoped record name, so a deletion in
|
|
/// one source scope cannot remove same-ID entities from other scopes, and
|
|
/// nothing is echoed back to CloudKit.
|
|
private func applyRemoteDeletion(_ recordID: CKRecord.ID, to dataManager: DataManager) async {
|
|
let recordName = recordID.recordName
|
|
|
|
if recordName.hasPrefix("sub-") {
|
|
guard canSyncSubscriptions else { return }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(4)))
|
|
if let subscription = localSubscription(matching: recordName, channelID: channelID, in: dataManager) {
|
|
dataManager.deleteSubscription(subscription)
|
|
dataManager.save()
|
|
let change = SubscriptionChange(addedSubscriptions: [], removedChannelIDs: [channelID])
|
|
NotificationCenter.default.post(
|
|
name: .subscriptionsDidChange,
|
|
object: nil,
|
|
userInfo: [SubscriptionChange.userInfoKey: change]
|
|
)
|
|
LoggingService.shared.logCloudKit("Deleted subscription from iCloud: \(channelID)")
|
|
}
|
|
} else if recordName.hasPrefix("watch-") {
|
|
guard canSyncPlaybackHistory else { return }
|
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(6)))
|
|
if let entry = localWatchEntry(matching: recordName, videoID: videoID, in: dataManager) {
|
|
dataManager.deleteWatchEntry(entry)
|
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
|
LoggingService.shared.logCloudKit("Deleted watch entry from iCloud: \(videoID)")
|
|
}
|
|
} else if recordName.hasPrefix("bookmark-") {
|
|
guard canSyncBookmarks else { return }
|
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(9)))
|
|
if let bookmark = localBookmark(matching: recordName, videoID: videoID, in: dataManager) {
|
|
dataManager.deleteBookmark(bookmark)
|
|
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
|
LoggingService.shared.logCloudKit("Deleted bookmark from iCloud: \(videoID)")
|
|
}
|
|
} else if recordName.hasPrefix("playlist-") {
|
|
guard canSyncPlaylists else { return }
|
|
let playlistIDString = String(recordName.dropFirst(9))
|
|
if let playlistID = UUID(uuidString: playlistIDString),
|
|
let playlist = dataManager.playlist(forID: playlistID) {
|
|
dataManager.deletePlaylist(playlist)
|
|
LoggingService.shared.logCloudKit("Deleted playlist from iCloud: \(playlistID)")
|
|
}
|
|
} else if recordName.hasPrefix("item-") {
|
|
guard canSyncPlaylists else { return }
|
|
let itemIDString = String(recordName.dropFirst(5))
|
|
if let itemID = UUID(uuidString: itemIDString),
|
|
let item = dataManager.playlistItem(forID: itemID) {
|
|
dataManager.deletePlaylistItem(item)
|
|
LoggingService.shared.logCloudKit("Deleted playlist item from iCloud: \(itemID)")
|
|
}
|
|
} else if recordName.hasPrefix("search-") {
|
|
guard canSyncSearchHistory else { return }
|
|
let searchIDString = String(recordName.dropFirst(7))
|
|
if let searchID = UUID(uuidString: searchIDString),
|
|
let searchHistory = dataManager.searchHistoryEntry(forID: searchID) {
|
|
dataManager.deleteSearchQuery(searchHistory)
|
|
LoggingService.shared.logCloudKit("Deleted search history from iCloud: \(searchID)")
|
|
}
|
|
} else if recordName.hasPrefix("recent-channel-") {
|
|
guard canSyncSearchHistory else { return }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(15)))
|
|
if let recentChannel = localRecentChannel(matching: recordName, channelID: channelID, in: dataManager) {
|
|
dataManager.deleteRecentChannelEntry(recentChannel)
|
|
LoggingService.shared.logCloudKit("Deleted recent channel from iCloud: \(channelID)")
|
|
}
|
|
} else if recordName.hasPrefix("recent-playlist-") {
|
|
guard canSyncSearchHistory else { return }
|
|
let playlistID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(16)))
|
|
if let recentPlaylist = localRecentPlaylist(matching: recordName, playlistID: playlistID, in: dataManager) {
|
|
dataManager.deleteRecentPlaylistEntry(recentPlaylist)
|
|
LoggingService.shared.logCloudKit("Deleted recent playlist from iCloud: \(playlistID)")
|
|
}
|
|
} else if recordName.hasPrefix("channel-notif-") {
|
|
guard canSyncSubscriptions else { return }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(14)))
|
|
if let settings = localChannelNotificationSettings(matching: recordName, channelID: channelID, in: dataManager) {
|
|
dataManager.deleteChannelNotificationSettings(settings)
|
|
LoggingService.shared.logCloudKit("Deleted channel notification settings from iCloud: \(channelID)")
|
|
}
|
|
} else if recordName.hasPrefix("controls-") {
|
|
guard canSyncControlsPresets else { return }
|
|
let presetIDString = String(recordName.dropFirst(9)) // Remove "controls-" prefix
|
|
if let presetID = UUID(uuidString: presetIDString) {
|
|
// Use shared layout service if available, fallback to new instance
|
|
let layoutService = playerControlsLayoutService ?? PlayerControlsLayoutService()
|
|
try? await layoutService.removePreset(id: presetID)
|
|
LoggingService.shared.logCloudKit("Deleted controls preset from iCloud: \(presetID)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Status Types
|
|
|
|
/// Upload progress tracking for initial sync
|
|
struct UploadProgress: Equatable {
|
|
var currentCategory: String
|
|
var isComplete: Bool
|
|
var totalOperations: Int
|
|
|
|
var displayText: String {
|
|
if isComplete {
|
|
return "Initial sync complete"
|
|
}
|
|
return "Uploading \(currentCategory)..."
|
|
}
|
|
}
|
|
|
|
/// Computed sync status for UI display
|
|
enum SyncStatus: Equatable {
|
|
case syncing
|
|
case upToDate
|
|
case pending(count: Int)
|
|
case error(Error)
|
|
|
|
static func == (lhs: SyncStatus, rhs: SyncStatus) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.syncing, .syncing), (.upToDate, .upToDate):
|
|
return true
|
|
case (.pending(let lCount), .pending(let rCount)):
|
|
return lCount == rCount
|
|
case (.error, .error):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CloudKit Errors
|
|
|
|
enum CloudKitError: Error, LocalizedError, Sendable {
|
|
case iCloudNotAvailable(status: CKAccountStatus)
|
|
case notAuthenticated
|
|
case syncDisabled
|
|
case quotaExceeded
|
|
case zoneNotFound
|
|
case recordNotFound
|
|
case conflictResolutionFailed
|
|
|
|
// Schema versioning errors
|
|
case missingRequiredField(field: String, recordType: String)
|
|
case typeMismatch(field: String, recordType: String, expected: String)
|
|
case unsupportedSchemaVersion(version: Int64, recordType: String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .iCloudNotAvailable(let status):
|
|
"iCloud is not available (status: \(status))"
|
|
case .notAuthenticated:
|
|
"Not signed into iCloud. Sign in to Settings to enable sync."
|
|
case .syncDisabled:
|
|
"iCloud sync is turned off. Turn it on before resetting iCloud data."
|
|
case .quotaExceeded:
|
|
"iCloud storage quota exceeded. Free up space in iCloud settings."
|
|
case .zoneNotFound:
|
|
"Sync zone not found. Will recreate automatically."
|
|
case .recordNotFound:
|
|
"Record not found in CloudKit"
|
|
case .conflictResolutionFailed:
|
|
"Failed to resolve sync conflict"
|
|
case .missingRequiredField(let field, let recordType):
|
|
"Missing required field '\(field)' in \(recordType) record"
|
|
case .typeMismatch(let field, let recordType, let expected):
|
|
"Type mismatch for field '\(field)' in \(recordType) record (expected \(expected))"
|
|
case .unsupportedSchemaVersion(let version, let recordType):
|
|
"Unsupported schema version \(version) for \(recordType) record. Please update the app."
|
|
}
|
|
}
|
|
}
|