mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
Register pending changes with the engine state instead of keeping app-managed record arrays, and materialize records from local data at send time in nextRecordZoneChangeBatch. This fixes several data-loss paths: - Pending changes now persist inside the engine state serialization, so saves queued during the debounce window (or while offline) survive app termination. Legacy record-name keys are migrated once. - Transient CloudKit errors (zoneBusy, serviceUnavailable, rate limits, network failures, limitExceeded, batchRequestFailed) re-register the change for engine-scheduled retry instead of silently dropping it. - Conflicts are resolved against the exact record that was sent; the resolved record (carrying the server change tag) is cached and takes precedence at the next send, and any newer local edit evicts it. - Queue methods are now synchronous, removing a task-reordering race where a fast delete/re-add could end up deleting the re-added entity. - Records are built fresh at send time, so queued changes no longer upload stale field snapshots, and batch size limits are handled by the framework.
2547 lines
114 KiB
Swift
2547 lines
114 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
|
|
}
|
|
|
|
/// 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
|
|
|
|
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"
|
|
|
|
/// 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?
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
dataManager: DataManager? = nil,
|
|
settingsManager: SettingsManager? = nil,
|
|
instancesManager: InstancesManager? = nil
|
|
) {
|
|
self.dataManager = dataManager
|
|
self.settingsManager = settingsManager
|
|
self.instancesManager = instancesManager
|
|
|
|
// Initialize CloudKit
|
|
self.container = CKContainer(identifier: AppIdentifiers.iCloudContainer)
|
|
self.database = container.privateCloudDatabase
|
|
|
|
// Initialize zone manager
|
|
self.zoneManager = CloudKitZoneManager(database: database)
|
|
|
|
// 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 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()
|
|
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
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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()
|
|
|
|
// 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 {
|
|
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()
|
|
|
|
// 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)
|
|
}
|
|
|
|
/// Manually trigger a full sync.
|
|
func sync() async {
|
|
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 isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
|
|
// Trigger immediate sync (no debounce for initial upload)
|
|
await sync()
|
|
}
|
|
|
|
// 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 isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
|
|
// Trigger immediate sync (no debounce for initial upload)
|
|
await sync()
|
|
}
|
|
|
|
// 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 isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
|
|
// Trigger immediate sync (no debounce for initial upload)
|
|
await sync()
|
|
}
|
|
|
|
// 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 isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
await sync()
|
|
}
|
|
|
|
// 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 isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
await sync()
|
|
}
|
|
|
|
// 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 isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
await sync()
|
|
}
|
|
|
|
// 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 isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
await sync()
|
|
}
|
|
|
|
// 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 isSyncEnabled, let dataManager else {
|
|
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
await sync()
|
|
}
|
|
|
|
// MARK: - ControlsPreset Sync Operations
|
|
|
|
/// Upload all local controls presets to CloudKit (for initial sync when enabling category).
|
|
func uploadAllLocalControlsPresets() async {
|
|
guard canSyncControlsPresets else {
|
|
LoggingService.shared.logCloudKit("Cannot upload controls presets: sync disabled or settings category disabled")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
await sync()
|
|
}
|
|
|
|
/// 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 {
|
|
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.
|
|
func resetSync() async throws {
|
|
// 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()
|
|
|
|
LoggingService.shared.logCloudKit("Sync reset completed")
|
|
}
|
|
|
|
// MARK: - Account Status
|
|
|
|
/// Refreshes the cached iCloud account status
|
|
func refreshAccountStatus() async {
|
|
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
|
|
|
|
/// 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
|
|
)
|
|
|
|
// Upload each category sequentially with progress updates
|
|
await uploadAllLocalSubscriptions()
|
|
await uploadAllLocalWatchHistory()
|
|
await uploadAllLocalBookmarks()
|
|
await uploadAllLocalPlaylists()
|
|
await uploadAllLocalSearchHistory()
|
|
await uploadAllRecentChannels()
|
|
await uploadAllRecentPlaylists()
|
|
await uploadAllLocalChannelNotificationSettings()
|
|
await uploadAllLocalControlsPresets()
|
|
|
|
// 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:
|
|
LoggingService.shared.logCloudKit("Zone not found, recreating...")
|
|
Task {
|
|
try? await zoneManager.createZoneIfNeeded()
|
|
await sync()
|
|
}
|
|
|
|
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) {
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(delay))
|
|
await sync()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CKSyncEngineDelegate
|
|
|
|
extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|
nonisolated func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) 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)
|
|
|
|
default:
|
|
await MainActor.run {
|
|
switch event {
|
|
case .stateUpdate(let update):
|
|
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)
|
|
updatePendingCount()
|
|
|
|
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
|
|
|
|
case .didFetchChanges:
|
|
LoggingService.shared.logCloudKit("CKSyncEngine finished fetching changes")
|
|
isReceivingChanges = false
|
|
|
|
case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .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] {
|
|
return resolved
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// Builds a CKRecord for the given record name from current local data.
|
|
/// The record name prefix identifies the entity type; the mapper re-derives
|
|
/// the scoped record name, which must match the requested one (a mismatch
|
|
/// means the local entity belongs to a different source scope).
|
|
private func materializeRecord(named recordName: String) async -> CKRecord? {
|
|
guard let dataManager else { return nil }
|
|
|
|
func scopedMatch(_ record: CKRecord) -> CKRecord? {
|
|
record.recordID.recordName == recordName ? record : nil
|
|
}
|
|
|
|
if recordName.hasPrefix("sub-") {
|
|
guard canSyncSubscriptions else { return nil }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(4)))
|
|
guard let subscription = dataManager.subscription(for: channelID) else { return nil }
|
|
return scopedMatch(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 = dataManager.watchEntry(for: videoID), shouldSyncWatchEntry(entry) else { return nil }
|
|
return scopedMatch(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 = dataManager.bookmark(for: videoID) else { return nil }
|
|
return scopedMatch(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 = dataManager.recentChannelEntry(forChannelID: channelID) else { return nil }
|
|
return scopedMatch(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 = dataManager.recentPlaylistEntry(forPlaylistID: playlistID) else { return nil }
|
|
return scopedMatch(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 = dataManager.channelNotificationSettings(for: channelID) else { return nil }
|
|
return scopedMatch(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)
|
|
}
|
|
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
|
Task {
|
|
try? await zoneManager.createZoneIfNeeded()
|
|
}
|
|
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)
|
|
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).
|
|
/// Now that the engine resumes from persisted state, a remote deletion of our
|
|
/// zone (reset from another device, iCloud data purge, encrypted data reset)
|
|
/// would leave sync silently dead - recreate the zone and re-seed it from
|
|
/// local data.
|
|
private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) async {
|
|
LoggingService.shared.logCloudKit("Fetched database changes")
|
|
|
|
guard changes.deletions.contains(where: { $0.zoneID.zoneName == RecordType.zoneName }) else {
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit("Zone '\(RecordType.zoneName)' was deleted remotely - recreating and re-uploading local data")
|
|
UserDefaults.standard.removeObject(forKey: syncStateKey)
|
|
|
|
// Cached conflict records hold change tags from the deleted zone
|
|
conflictResolvedRecords.removeAll()
|
|
retryCount.removeAll()
|
|
|
|
do {
|
|
try await zoneManager.createZoneIfNeeded()
|
|
await performInitialUpload()
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to recreate zone after remote deletion", error: error)
|
|
}
|
|
}
|
|
|
|
/// Handle fetched changes from CloudKit.
|
|
private func handleFetchedChanges(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) async {
|
|
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 {
|
|
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 {
|
|
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
|
|
if let existing = dataManager.subscription(for: subscription.channelID) {
|
|
// 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)")
|
|
} 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
|
|
if let existing = dataManager.watchEntry(for: watchEntry.videoID) {
|
|
// 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.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)")
|
|
} 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
|
|
if let existing = dataManager.bookmark(for: bookmark.videoID) {
|
|
// 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)")
|
|
} 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 {
|
|
// 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)")
|
|
}
|
|
} 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) {
|
|
// 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)")
|
|
} 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) {
|
|
// 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)")
|
|
} 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
|
|
if let existing = dataManager.recentChannelEntry(forChannelID: recentChannel.channelID) {
|
|
// 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)")
|
|
} 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
|
|
if let existing = dataManager.recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) {
|
|
// 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)")
|
|
} 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)
|
|
|
|
// Use upsert which handles conflict resolution based on updatedAt
|
|
dataManager.upsertChannelNotificationSettings(settings)
|
|
|
|
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()
|
|
try await layoutService.importPreset(preset)
|
|
|
|
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.
|
|
private func applyRemoteDeletion(_ recordID: CKRecord.ID, to dataManager: DataManager) async {
|
|
let recordName = recordID.recordName
|
|
|
|
// Parse record type from record name and strip scope suffix for bare ID lookup
|
|
if recordName.hasPrefix("sub-") {
|
|
guard canSyncSubscriptions else { return }
|
|
let rest = String(recordName.dropFirst(4))
|
|
let channelID = SyncableRecordType.extractBareID(from: rest)
|
|
dataManager.unsubscribe(from: channelID)
|
|
LoggingService.shared.logCloudKit("Deleted subscription from iCloud: \(channelID)")
|
|
} else if recordName.hasPrefix("watch-") {
|
|
guard canSyncPlaybackHistory else { return }
|
|
let rest = String(recordName.dropFirst(6))
|
|
let videoID = SyncableRecordType.extractBareID(from: rest)
|
|
dataManager.removeFromHistory(videoID: videoID)
|
|
LoggingService.shared.logCloudKit("Deleted watch entry from iCloud: \(videoID)")
|
|
} else if recordName.hasPrefix("bookmark-") {
|
|
guard canSyncBookmarks else { return }
|
|
let rest = String(recordName.dropFirst(9))
|
|
let videoID = SyncableRecordType.extractBareID(from: rest)
|
|
dataManager.removeBookmark(for: videoID)
|
|
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 rest = String(recordName.dropFirst(15))
|
|
let channelID = SyncableRecordType.extractBareID(from: rest)
|
|
if let recentChannel = dataManager.recentChannelEntry(forChannelID: channelID) {
|
|
dataManager.deleteRecentChannel(recentChannel)
|
|
LoggingService.shared.logCloudKit("Deleted recent channel from iCloud: \(channelID)")
|
|
}
|
|
} else if recordName.hasPrefix("recent-playlist-") {
|
|
guard canSyncSearchHistory else { return }
|
|
let rest = String(recordName.dropFirst(16))
|
|
let playlistID = SyncableRecordType.extractBareID(from: rest)
|
|
if let recentPlaylist = dataManager.recentPlaylistEntry(forPlaylistID: playlistID) {
|
|
dataManager.deleteRecentPlaylist(recentPlaylist)
|
|
LoggingService.shared.logCloudKit("Deleted recent playlist from iCloud: \(playlistID)")
|
|
}
|
|
} else if recordName.hasPrefix("channel-notif-") {
|
|
guard canSyncSubscriptions else { return }
|
|
let rest = String(recordName.dropFirst(14))
|
|
let channelID = SyncableRecordType.extractBareID(from: rest)
|
|
dataManager.deleteNotificationSettings(for: channelID)
|
|
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 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 .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."
|
|
}
|
|
}
|
|
}
|