mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
CloudKit record names carry a source scope suffix so the same channel or video ID can exist under different sources, but the apply path matched local entities by bare ID: incoming records could merge into an entity from a different scope, and a remote deletion of one scope deleted every local entity with that ID and cascaded scoped deletions for all other scopes back to CloudKit. - Match local entities on the full mapper-derived record name when merging incoming records, materializing records for upload, and applying remote deletions. - Remote deletions now remove only the matching entity and no longer queue CloudKit deletions (no echo, no cross-scope cascade). - Insert dedupe for CloudKit-applied records is scope-aware, so a same-ID record from a different source inserts instead of being silently dropped. Remotely added bookmarks also update the fast-lookup cache immediately. - When conflict resolution during a fetch keeps newer local data, the merge result is queued for upload; previously the server (and other devices) kept the stale version unless the entity was edited again. Strict timestamp comparisons prevent re-upload ping-pong between devices.
2682 lines
122 KiB
Swift
2682 lines
122 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
|
|
}
|
|
|
|
// MARK: - Scope-Aware Local Lookups
|
|
|
|
/// Bare-ID lookups can return an entity from a different source scope when
|
|
/// IDs collide across sources; these helpers match on the mapper-derived
|
|
/// record name so the right entity is merged, materialized, or deleted.
|
|
private func localSubscription(matching recordName: String, channelID: String, in dataManager: DataManager) -> Subscription? {
|
|
dataManager.subscriptions(forChannelID: channelID).first {
|
|
recordMapper.toCKRecord(subscription: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localWatchEntry(matching recordName: String, videoID: String, in dataManager: DataManager) -> WatchEntry? {
|
|
dataManager.watchEntries(forVideoID: videoID).first {
|
|
recordMapper.toCKRecord(watchEntry: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localBookmark(matching recordName: String, videoID: String, in dataManager: DataManager) -> Bookmark? {
|
|
dataManager.bookmarks(forVideoID: videoID).first {
|
|
recordMapper.toCKRecord(bookmark: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localRecentChannel(matching recordName: String, channelID: String, in dataManager: DataManager) -> RecentChannel? {
|
|
dataManager.recentChannelEntries(forChannelID: channelID).first {
|
|
recordMapper.toCKRecord(recentChannel: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localRecentPlaylist(matching recordName: String, playlistID: String, in dataManager: DataManager) -> RecentPlaylist? {
|
|
dataManager.recentPlaylistEntries(forPlaylistID: playlistID).first {
|
|
recordMapper.toCKRecord(recentPlaylist: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
private func localChannelNotificationSettings(matching recordName: String, channelID: String, in dataManager: DataManager) -> ChannelNotificationSettings? {
|
|
dataManager.allChannelNotificationSettings(forChannelID: channelID).first {
|
|
recordMapper.toCKRecord(channelNotificationSettings: $0).recordID.recordName == recordName
|
|
}
|
|
}
|
|
|
|
/// Builds a CKRecord for the given record name from current local data.
|
|
/// The record name prefix identifies the entity type; the local entity is
|
|
/// matched on the full scoped record name (a mismatch means it belongs to
|
|
/// a different source scope).
|
|
private func materializeRecord(named recordName: String) async -> CKRecord? {
|
|
guard let dataManager else { return nil }
|
|
|
|
if recordName.hasPrefix("sub-") {
|
|
guard canSyncSubscriptions else { return nil }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(4)))
|
|
guard let subscription = localSubscription(matching: recordName, channelID: channelID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(subscription: subscription)
|
|
}
|
|
if recordName.hasPrefix("watch-") {
|
|
guard canSyncPlaybackHistory else { return nil }
|
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(6)))
|
|
guard let entry = localWatchEntry(matching: recordName, videoID: videoID, in: dataManager),
|
|
shouldSyncWatchEntry(entry) else { return nil }
|
|
return recordMapper.toCKRecord(watchEntry: entry)
|
|
}
|
|
if recordName.hasPrefix("bookmark-") {
|
|
guard canSyncBookmarks else { return nil }
|
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(9)))
|
|
guard let bookmark = localBookmark(matching: recordName, videoID: videoID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(bookmark: bookmark)
|
|
}
|
|
if recordName.hasPrefix("recent-channel-") {
|
|
guard canSyncSearchHistory else { return nil }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(15)))
|
|
guard let recentChannel = localRecentChannel(matching: recordName, channelID: channelID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(recentChannel: recentChannel)
|
|
}
|
|
if recordName.hasPrefix("recent-playlist-") {
|
|
guard canSyncSearchHistory else { return nil }
|
|
let playlistID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(16)))
|
|
guard let recentPlaylist = localRecentPlaylist(matching: recordName, playlistID: playlistID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(recentPlaylist: recentPlaylist)
|
|
}
|
|
if recordName.hasPrefix("channel-notif-") {
|
|
guard canSyncSubscriptions else { return nil }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(14)))
|
|
guard let settings = localChannelNotificationSettings(matching: recordName, channelID: channelID, in: dataManager) else { return nil }
|
|
return recordMapper.toCKRecord(channelNotificationSettings: settings)
|
|
}
|
|
if recordName.hasPrefix("playlist-") {
|
|
guard canSyncPlaylists,
|
|
let id = UUID(uuidString: String(recordName.dropFirst(9))),
|
|
let playlist = dataManager.playlist(forID: id),
|
|
!playlist.isPlaceholder else { return nil }
|
|
return recordMapper.toCKRecord(playlist: playlist)
|
|
}
|
|
if recordName.hasPrefix("item-") {
|
|
guard canSyncPlaylists,
|
|
let id = UUID(uuidString: String(recordName.dropFirst(5))),
|
|
let item = dataManager.playlistItem(forID: id) else { return nil }
|
|
return recordMapper.toCKRecord(playlistItem: item)
|
|
}
|
|
if recordName.hasPrefix("search-") {
|
|
guard canSyncSearchHistory,
|
|
let id = UUID(uuidString: String(recordName.dropFirst(7))),
|
|
let entry = dataManager.searchHistoryEntry(forID: id) else { return nil }
|
|
return recordMapper.toCKRecord(searchHistory: entry)
|
|
}
|
|
if recordName.hasPrefix("controls-") {
|
|
guard canSyncControlsPresets,
|
|
let id = UUID(uuidString: String(recordName.dropFirst(9))) else { return nil }
|
|
let layoutService = playerControlsLayoutService ?? PlayerControlsLayoutService()
|
|
let presets = await layoutService.presetsForSync()
|
|
guard let preset = presets.first(where: { $0.id == id }) else { return nil }
|
|
return try? recordMapper.toCKRecord(preset: preset)
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit("Cannot materialize record with unknown prefix: \(recordName)")
|
|
return nil
|
|
}
|
|
|
|
/// Handle the result of a sent batch: clean up bookkeeping for successes
|
|
/// and decide per-record how to handle failures.
|
|
private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) async {
|
|
LoggingService.shared.logCloudKit("Sent \(changes.savedRecords.count) saves, \(changes.deletedRecordIDs.count) deletions (\(changes.failedRecordSaves.count) failed saves, \(changes.failedRecordDeletes.count) failed deletes)")
|
|
|
|
for record in changes.savedRecords {
|
|
let recordName = record.recordID.recordName
|
|
conflictResolvedRecords.removeValue(forKey: recordName)
|
|
retryCount.removeValue(forKey: recordName)
|
|
}
|
|
|
|
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 (same source scope only)
|
|
if let existing = localSubscription(matching: record.recordID.recordName, channelID: subscription.channelID, in: dataManager) {
|
|
let localWasNewer = existing.lastUpdatedAt > subscription.lastUpdatedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(subscription: existing)
|
|
let resolved = await conflictResolver.resolveSubscriptionConflict(local: localRecord, server: record)
|
|
let resolvedSubscription = try recordMapper.toSubscription(from: resolved)
|
|
|
|
// Update existing subscription with resolved data
|
|
existing.name = resolvedSubscription.name
|
|
existing.channelDescription = resolvedSubscription.channelDescription
|
|
existing.subscriberCount = resolvedSubscription.subscriberCount
|
|
existing.avatarURLString = resolvedSubscription.avatarURLString
|
|
existing.bannerURLString = resolvedSubscription.bannerURLString
|
|
existing.isVerified = resolvedSubscription.isVerified
|
|
existing.lastUpdatedAt = resolvedSubscription.lastUpdatedAt
|
|
existing.providerName = resolvedSubscription.providerName
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates after conflict resolution
|
|
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged subscription from iCloud (conflict resolved): \(subscription.channelID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New subscription from iCloud
|
|
dataManager.insertSubscription(subscription)
|
|
|
|
// Post notification to update UI
|
|
let change = SubscriptionChange(addedSubscriptions: [subscription], removedChannelIDs: [])
|
|
NotificationCenter.default.post(
|
|
name: .subscriptionsDidChange,
|
|
object: nil,
|
|
userInfo: [SubscriptionChange.userInfoKey: change]
|
|
)
|
|
|
|
LoggingService.shared.logCloudKit("Added subscription from iCloud: \(subscription.channelID)")
|
|
}
|
|
|
|
case RecordType.watchEntry:
|
|
guard canSyncPlaybackHistory else { return .success }
|
|
let watchEntry = try recordMapper.toWatchEntry(from: record)
|
|
|
|
guard shouldSyncWatchEntry(watchEntry) else {
|
|
LoggingService.shared.logCloudKit("Ignoring incoming local-folder watch entry: \(watchEntry.videoID)")
|
|
return .success
|
|
}
|
|
|
|
// Check if exists locally (same source scope only)
|
|
if let existing = localWatchEntry(matching: record.recordID.recordName, videoID: watchEntry.videoID, in: dataManager) {
|
|
let localWasNewer = existing.updatedAt > watchEntry.updatedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(watchEntry: existing)
|
|
let resolved = await conflictResolver.resolveWatchEntryConflict(local: localRecord, server: record)
|
|
let resolvedEntry = try recordMapper.toWatchEntry(from: resolved)
|
|
|
|
// Update existing watch entry with resolved data
|
|
existing.watchedSeconds = resolvedEntry.watchedSeconds
|
|
existing.isFinished = resolvedEntry.isFinished
|
|
existing.finishedAt = resolvedEntry.finishedAt
|
|
existing.updatedAt = resolvedEntry.updatedAt
|
|
|
|
// Update metadata if newer
|
|
existing.title = resolvedEntry.title
|
|
existing.authorName = resolvedEntry.authorName
|
|
existing.authorID = resolvedEntry.authorID
|
|
existing.duration = resolvedEntry.duration
|
|
existing.thumbnailURLString = resolvedEntry.thumbnailURLString
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates after conflict resolution
|
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged watch entry from iCloud (conflict resolved): \(watchEntry.videoID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New watch entry from iCloud
|
|
dataManager.insertWatchEntry(watchEntry)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added watch entry from iCloud: \(watchEntry.videoID)")
|
|
}
|
|
|
|
case RecordType.bookmark:
|
|
guard canSyncBookmarks else { return .success }
|
|
let bookmark = try recordMapper.toBookmark(from: record)
|
|
|
|
// Check if exists locally (same source scope only)
|
|
if let existing = localBookmark(matching: record.recordID.recordName, videoID: bookmark.videoID, in: dataManager) {
|
|
let localWasNewer = existing.createdAt > bookmark.createdAt
|
|
|| (existing.noteModifiedAt ?? .distantPast) > (bookmark.noteModifiedAt ?? .distantPast)
|
|
|| (existing.tagsModifiedAt ?? .distantPast) > (bookmark.tagsModifiedAt ?? .distantPast)
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(bookmark: existing)
|
|
let resolved = await conflictResolver.resolveBookmarkConflict(local: localRecord, server: record)
|
|
let resolvedBookmark = try recordMapper.toBookmark(from: resolved)
|
|
|
|
// Update existing bookmark with resolved data
|
|
existing.title = resolvedBookmark.title
|
|
existing.authorName = resolvedBookmark.authorName
|
|
existing.authorID = resolvedBookmark.authorID
|
|
existing.duration = resolvedBookmark.duration
|
|
existing.thumbnailURLString = resolvedBookmark.thumbnailURLString
|
|
existing.isLive = resolvedBookmark.isLive
|
|
existing.viewCount = resolvedBookmark.viewCount
|
|
existing.publishedAt = resolvedBookmark.publishedAt
|
|
existing.publishedText = resolvedBookmark.publishedText
|
|
existing.note = resolvedBookmark.note
|
|
existing.noteModifiedAt = resolvedBookmark.noteModifiedAt
|
|
existing.tags = resolvedBookmark.tags
|
|
existing.tagsModifiedAt = resolvedBookmark.tagsModifiedAt
|
|
existing.sortOrder = resolvedBookmark.sortOrder
|
|
existing.createdAt = resolvedBookmark.createdAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates after conflict resolution
|
|
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged bookmark from iCloud (conflict resolved): \(bookmark.videoID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New bookmark from iCloud
|
|
dataManager.insertBookmark(bookmark)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added bookmark from iCloud: \(bookmark.videoID)")
|
|
}
|
|
|
|
case RecordType.localPlaylist:
|
|
guard canSyncPlaylists else { return .success }
|
|
let playlist = try recordMapper.toLocalPlaylist(from: record)
|
|
|
|
// Check if exists locally
|
|
if let existing = dataManager.playlist(forID: playlist.id) {
|
|
// Check if this is a placeholder being upgraded to real playlist
|
|
let wasPlaceholder = existing.isPlaceholder
|
|
|
|
if wasPlaceholder {
|
|
// Upgrade placeholder to real playlist - use incoming data directly
|
|
existing.title = playlist.title
|
|
existing.playlistDescription = playlist.playlistDescription
|
|
existing.createdAt = playlist.createdAt
|
|
existing.updatedAt = playlist.updatedAt
|
|
existing.isPlaceholder = false
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Upgraded placeholder to real playlist: \(playlist.title)")
|
|
} else {
|
|
let localWasNewer = existing.updatedAt > playlist.updatedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(playlist: existing)
|
|
let resolved = await conflictResolver.resolveLocalPlaylistConflict(local: localRecord, server: record)
|
|
let resolvedPlaylist = try recordMapper.toLocalPlaylist(from: resolved)
|
|
|
|
// Update existing playlist with resolved data
|
|
existing.title = resolvedPlaylist.title
|
|
existing.playlistDescription = resolvedPlaylist.playlistDescription
|
|
existing.updatedAt = resolvedPlaylist.updatedAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged playlist from iCloud (conflict resolved): \(playlist.title)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
}
|
|
} else {
|
|
// New playlist from iCloud
|
|
dataManager.insertPlaylist(playlist)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added playlist from iCloud: \(playlist.title)")
|
|
}
|
|
|
|
case RecordType.localPlaylistItem:
|
|
guard canSyncPlaylists else { return .success }
|
|
let (item, playlistID) = try recordMapper.toLocalPlaylistItem(from: record)
|
|
|
|
// Check if exists locally
|
|
if let existing = dataManager.playlistItem(forID: item.id) {
|
|
let localWasNewer = existing.addedAt > item.addedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(playlistItem: existing)
|
|
let resolved = await conflictResolver.resolveLocalPlaylistItemConflict(local: localRecord, server: record)
|
|
let (resolvedItem, _) = try recordMapper.toLocalPlaylistItem(from: resolved)
|
|
|
|
// Update existing item with resolved data
|
|
existing.title = resolvedItem.title
|
|
existing.authorName = resolvedItem.authorName
|
|
existing.authorID = resolvedItem.authorID
|
|
existing.duration = resolvedItem.duration
|
|
existing.thumbnailURLString = resolvedItem.thumbnailURLString
|
|
existing.isLive = resolvedItem.isLive
|
|
existing.sortOrder = resolvedItem.sortOrder
|
|
|
|
dataManager.save()
|
|
|
|
LoggingService.shared.logCloudKit("Merged playlist item from iCloud (conflict resolved): \(item.videoID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New item from iCloud - need to find parent playlist
|
|
if let playlistID = playlistID,
|
|
let parentPlaylist = dataManager.playlist(forID: playlistID) {
|
|
item.playlist = parentPlaylist
|
|
dataManager.insertPlaylistItem(item)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added playlist item from iCloud: \(item.videoID)")
|
|
} else if let playlistID = playlistID {
|
|
// Parent playlist not found yet - defer this item
|
|
LoggingService.shared.logCloudKit("Deferring playlist item (parent not yet available): \(item.id)")
|
|
return .deferred(playlistID: playlistID, itemID: item.id)
|
|
} else {
|
|
// No playlist ID - can't process this item
|
|
LoggingService.shared.logCloudKitError("Playlist item has no playlistID", error: NSError(domain: "CloudKitSyncEngine", code: -1, userInfo: [NSLocalizedDescriptionKey: "Playlist item missing playlistID"]))
|
|
return .failed
|
|
}
|
|
}
|
|
|
|
case RecordType.searchHistory:
|
|
guard canSyncSearchHistory else { return .success }
|
|
let searchHistory = try recordMapper.toSearchHistory(from: record)
|
|
|
|
// Check if exists locally
|
|
if let existing = dataManager.searchHistoryEntry(forID: searchHistory.id) {
|
|
let localWasNewer = existing.searchedAt > searchHistory.searchedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(searchHistory: existing)
|
|
let resolved = await conflictResolver.resolveSearchHistoryConflict(local: localRecord, server: record)
|
|
let resolvedHistory = try recordMapper.toSearchHistory(from: resolved)
|
|
|
|
// Update existing search history with resolved data
|
|
existing.query = resolvedHistory.query
|
|
existing.searchedAt = resolvedHistory.searchedAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged search history from iCloud (conflict resolved): \(searchHistory.query)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New search history from iCloud
|
|
dataManager.insertSearchHistory(searchHistory)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added search history from iCloud: \(searchHistory.query)")
|
|
}
|
|
|
|
case RecordType.recentChannel:
|
|
guard canSyncSearchHistory else { return .success }
|
|
let recentChannel = try recordMapper.toRecentChannel(from: record)
|
|
|
|
// Check if exists locally (same source scope only)
|
|
if let existing = localRecentChannel(matching: record.recordID.recordName, channelID: recentChannel.channelID, in: dataManager) {
|
|
let localWasNewer = existing.visitedAt > recentChannel.visitedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(recentChannel: existing)
|
|
let resolved = await conflictResolver.resolveRecentChannelConflict(local: localRecord, server: record)
|
|
let resolvedChannel = try recordMapper.toRecentChannel(from: resolved)
|
|
|
|
// Update existing recent channel with resolved data
|
|
existing.name = resolvedChannel.name
|
|
existing.thumbnailURLString = resolvedChannel.thumbnailURLString
|
|
existing.sourceRawValue = resolvedChannel.sourceRawValue
|
|
existing.instanceURLString = resolvedChannel.instanceURLString
|
|
existing.subscriberCount = resolvedChannel.subscriberCount
|
|
existing.isVerified = resolvedChannel.isVerified
|
|
existing.visitedAt = resolvedChannel.visitedAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged recent channel from iCloud (conflict resolved): \(recentChannel.channelID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New recent channel from iCloud
|
|
dataManager.insertRecentChannel(recentChannel)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added recent channel from iCloud: \(recentChannel.channelID)")
|
|
}
|
|
|
|
case RecordType.recentPlaylist:
|
|
guard canSyncSearchHistory else { return .success }
|
|
let recentPlaylist = try recordMapper.toRecentPlaylist(from: record)
|
|
|
|
// Check if exists locally (same source scope only)
|
|
if let existing = localRecentPlaylist(matching: record.recordID.recordName, playlistID: recentPlaylist.playlistID, in: dataManager) {
|
|
let localWasNewer = existing.visitedAt > recentPlaylist.visitedAt
|
|
|
|
// Conflict - resolve it
|
|
let localRecord = recordMapper.toCKRecord(recentPlaylist: existing)
|
|
let resolved = await conflictResolver.resolveRecentPlaylistConflict(local: localRecord, server: record)
|
|
let resolvedPlaylist = try recordMapper.toRecentPlaylist(from: resolved)
|
|
|
|
// Update existing recent playlist with resolved data
|
|
existing.title = resolvedPlaylist.title
|
|
existing.authorName = resolvedPlaylist.authorName
|
|
existing.thumbnailURLString = resolvedPlaylist.thumbnailURLString
|
|
existing.videoCount = resolvedPlaylist.videoCount
|
|
existing.sourceRawValue = resolvedPlaylist.sourceRawValue
|
|
existing.instanceURLString = resolvedPlaylist.instanceURLString
|
|
existing.visitedAt = resolvedPlaylist.visitedAt
|
|
|
|
dataManager.save()
|
|
|
|
// Post notification for UI updates
|
|
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Merged recent playlist from iCloud (conflict resolved): \(recentPlaylist.playlistID)")
|
|
|
|
// Push the merge result back when local data won - the
|
|
// server still has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
} else {
|
|
// New recent playlist from iCloud
|
|
dataManager.insertRecentPlaylist(recentPlaylist)
|
|
|
|
// Post notification to update UI
|
|
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
|
|
|
LoggingService.shared.logCloudKit("Added recent playlist from iCloud: \(recentPlaylist.playlistID)")
|
|
}
|
|
|
|
case RecordType.channelNotificationSettings:
|
|
guard canSyncSubscriptions else { return .success }
|
|
let settings = try recordMapper.toChannelNotificationSettings(from: record)
|
|
|
|
// Upsert keeps whichever side has the newer updatedAt
|
|
let existingSettings = dataManager.channelNotificationSettings(for: settings.channelID)
|
|
let localWasNewer = existingSettings.map { $0.updatedAt > settings.updatedAt } ?? false
|
|
dataManager.upsertChannelNotificationSettings(settings)
|
|
|
|
// Push the local version back when it won - the server still
|
|
// has the older version
|
|
if localWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit("Applied channel notification settings from iCloud: \(settings.channelID)")
|
|
|
|
case RecordType.controlsPreset:
|
|
guard canSyncControlsPresets else { return .success }
|
|
let preset = try recordMapper.toLayoutPreset(from: record)
|
|
|
|
// Only import if device class matches current device
|
|
guard preset.deviceClass == .current else {
|
|
LoggingService.shared.logCloudKit("Skipping controls preset - wrong device class: \(preset.deviceClass)")
|
|
return .success
|
|
}
|
|
|
|
// Use shared layout service if available, fallback to new instance
|
|
let layoutService = playerControlsLayoutService ?? PlayerControlsLayoutService()
|
|
|
|
// importPreset keeps whichever side has the newer updatedAt
|
|
let localPresets = await layoutService.presetsForSync()
|
|
let localPresetWasNewer = localPresets.first(where: { $0.id == preset.id }).map { $0.updatedAt > preset.updatedAt } ?? false
|
|
try await layoutService.importPreset(preset)
|
|
|
|
// Push the local version back when it won - the server still
|
|
// has the older version
|
|
if localPresetWasNewer {
|
|
addPendingChanges([.saveRecord(record.recordID)])
|
|
}
|
|
|
|
LoggingService.shared.logCloudKit("Applied controls preset from iCloud: \(preset.name)")
|
|
|
|
default:
|
|
LoggingService.shared.logCloudKit("Received unknown record type: \(record.recordType)")
|
|
}
|
|
} catch let cloudKitError as CloudKitError {
|
|
// Track unsupported schema version for UI warning
|
|
if case .unsupportedSchemaVersion = cloudKitError {
|
|
hasNewerSchemaRecords = true
|
|
}
|
|
LoggingService.shared.logCloudKitError("Failed to parse \(record.recordType) record: \(cloudKitError.localizedDescription)", error: cloudKitError)
|
|
return .failed
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to apply remote \(record.recordType) record", error: error)
|
|
return .failed
|
|
}
|
|
return .success
|
|
}
|
|
|
|
/// Apply a remote deletion to local SwiftData.
|
|
/// Entities are matched on the full scoped record name, so a deletion in
|
|
/// one source scope cannot remove same-ID entities from other scopes, and
|
|
/// nothing is echoed back to CloudKit.
|
|
private func applyRemoteDeletion(_ recordID: CKRecord.ID, to dataManager: DataManager) async {
|
|
let recordName = recordID.recordName
|
|
|
|
if recordName.hasPrefix("sub-") {
|
|
guard canSyncSubscriptions else { return }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(4)))
|
|
if let subscription = localSubscription(matching: recordName, channelID: channelID, in: dataManager) {
|
|
dataManager.deleteSubscription(subscription)
|
|
dataManager.save()
|
|
let change = SubscriptionChange(addedSubscriptions: [], removedChannelIDs: [channelID])
|
|
NotificationCenter.default.post(
|
|
name: .subscriptionsDidChange,
|
|
object: nil,
|
|
userInfo: [SubscriptionChange.userInfoKey: change]
|
|
)
|
|
LoggingService.shared.logCloudKit("Deleted subscription from iCloud: \(channelID)")
|
|
}
|
|
} else if recordName.hasPrefix("watch-") {
|
|
guard canSyncPlaybackHistory else { return }
|
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(6)))
|
|
if let entry = localWatchEntry(matching: recordName, videoID: videoID, in: dataManager) {
|
|
dataManager.deleteWatchEntry(entry)
|
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
|
LoggingService.shared.logCloudKit("Deleted watch entry from iCloud: \(videoID)")
|
|
}
|
|
} else if recordName.hasPrefix("bookmark-") {
|
|
guard canSyncBookmarks else { return }
|
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(9)))
|
|
if let bookmark = localBookmark(matching: recordName, videoID: videoID, in: dataManager) {
|
|
dataManager.deleteBookmark(bookmark)
|
|
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
|
LoggingService.shared.logCloudKit("Deleted bookmark from iCloud: \(videoID)")
|
|
}
|
|
} else if recordName.hasPrefix("playlist-") {
|
|
guard canSyncPlaylists else { return }
|
|
let playlistIDString = String(recordName.dropFirst(9))
|
|
if let playlistID = UUID(uuidString: playlistIDString),
|
|
let playlist = dataManager.playlist(forID: playlistID) {
|
|
dataManager.deletePlaylist(playlist)
|
|
LoggingService.shared.logCloudKit("Deleted playlist from iCloud: \(playlistID)")
|
|
}
|
|
} else if recordName.hasPrefix("item-") {
|
|
guard canSyncPlaylists else { return }
|
|
let itemIDString = String(recordName.dropFirst(5))
|
|
if let itemID = UUID(uuidString: itemIDString),
|
|
let item = dataManager.playlistItem(forID: itemID) {
|
|
dataManager.deletePlaylistItem(item)
|
|
LoggingService.shared.logCloudKit("Deleted playlist item from iCloud: \(itemID)")
|
|
}
|
|
} else if recordName.hasPrefix("search-") {
|
|
guard canSyncSearchHistory else { return }
|
|
let searchIDString = String(recordName.dropFirst(7))
|
|
if let searchID = UUID(uuidString: searchIDString),
|
|
let searchHistory = dataManager.searchHistoryEntry(forID: searchID) {
|
|
dataManager.deleteSearchQuery(searchHistory)
|
|
LoggingService.shared.logCloudKit("Deleted search history from iCloud: \(searchID)")
|
|
}
|
|
} else if recordName.hasPrefix("recent-channel-") {
|
|
guard canSyncSearchHistory else { return }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(15)))
|
|
if let recentChannel = localRecentChannel(matching: recordName, channelID: channelID, in: dataManager) {
|
|
dataManager.deleteRecentChannelEntry(recentChannel)
|
|
LoggingService.shared.logCloudKit("Deleted recent channel from iCloud: \(channelID)")
|
|
}
|
|
} else if recordName.hasPrefix("recent-playlist-") {
|
|
guard canSyncSearchHistory else { return }
|
|
let playlistID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(16)))
|
|
if let recentPlaylist = localRecentPlaylist(matching: recordName, playlistID: playlistID, in: dataManager) {
|
|
dataManager.deleteRecentPlaylistEntry(recentPlaylist)
|
|
LoggingService.shared.logCloudKit("Deleted recent playlist from iCloud: \(playlistID)")
|
|
}
|
|
} else if recordName.hasPrefix("channel-notif-") {
|
|
guard canSyncSubscriptions else { return }
|
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(14)))
|
|
if let settings = localChannelNotificationSettings(matching: recordName, channelID: channelID, in: dataManager) {
|
|
dataManager.deleteChannelNotificationSettings(settings)
|
|
LoggingService.shared.logCloudKit("Deleted channel notification settings from iCloud: \(channelID)")
|
|
}
|
|
} else if recordName.hasPrefix("controls-") {
|
|
guard canSyncControlsPresets else { return }
|
|
let presetIDString = String(recordName.dropFirst(9)) // Remove "controls-" prefix
|
|
if let presetID = UUID(uuidString: presetIDString) {
|
|
// Use shared layout service if available, fallback to new instance
|
|
let layoutService = playerControlsLayoutService ?? PlayerControlsLayoutService()
|
|
try? await layoutService.removePreset(id: presetID)
|
|
LoggingService.shared.logCloudKit("Deleted controls preset from iCloud: \(presetID)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Status Types
|
|
|
|
/// Upload progress tracking for initial sync
|
|
struct UploadProgress: Equatable {
|
|
var currentCategory: String
|
|
var isComplete: Bool
|
|
var totalOperations: Int
|
|
|
|
var displayText: String {
|
|
if isComplete {
|
|
return "Initial sync complete"
|
|
}
|
|
return "Uploading \(currentCategory)..."
|
|
}
|
|
}
|
|
|
|
/// Computed sync status for UI display
|
|
enum SyncStatus: Equatable {
|
|
case syncing
|
|
case upToDate
|
|
case pending(count: Int)
|
|
case error(Error)
|
|
|
|
static func == (lhs: SyncStatus, rhs: SyncStatus) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.syncing, .syncing), (.upToDate, .upToDate):
|
|
return true
|
|
case (.pending(let lCount), .pending(let rCount)):
|
|
return lCount == rCount
|
|
case (.error, .error):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CloudKit Errors
|
|
|
|
enum CloudKitError: Error, LocalizedError, Sendable {
|
|
case iCloudNotAvailable(status: CKAccountStatus)
|
|
case notAuthenticated
|
|
case 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."
|
|
}
|
|
}
|
|
}
|