mirror of
https://github.com/yattee/yattee.git
synced 2026-11-14 07:47:16 +00:00
A zone deleted from another device — a reset, an iCloud data purge — arrives as an event on every other device, and the handler answered it by calling back into the sync engine from inside the engine's own callback. CloudKit traps on that, in release, so the app died a second after the push; and because the handler destroyed the persisted sync state before doing the work, the relaunch saw a zone that existed again, no local sign anything was lost, and never noticed the wipe. Both devices then reported being in sync with an empty zone. The handler now only writes a durable marker and mutates engine state: the zone is recreated through pending database changes — which also gets past the zone manager's memo — and the re-upload is registered, never sent. The engine sends on its own schedule. The marker is written before any of it and retired only after the engine serializes the state the recovery registered, so a crash anywhere in the middle is redone on the next launch. Accounts lead the re-seed now instead of trailing nine categories behind it. They carry the logins, the tombstones and the selected context, and going last is how a wipe took them with it. A purge is not a reset: CloudKit requires that data deleted that way is not sent back, so those reasons drop what was queued rather than republish it. Local data stays — it is this app's store, not a cache of the zone. And the reset that promises "your local data has been re-uploaded" now does it.
140 lines
5.1 KiB
Swift
140 lines
5.1 KiB
Swift
//
|
|
// CloudKitZoneRecovery.swift
|
|
// Yattee
|
|
//
|
|
// Deciding — and remembering — what to do when the record zone is deleted out
|
|
// from under this device.
|
|
//
|
|
|
|
import CloudKit
|
|
import Foundation
|
|
|
|
/// What a remote deletion of the app's record zone requires of this device.
|
|
enum ZoneDeletionResponse: String, Sendable {
|
|
/// Recreate the zone and register every local record for upload again.
|
|
///
|
|
/// The zone was deleted by this app — "Reset iCloud Sync Data" on another
|
|
/// device — so local data is still the truth and iCloud is the copy that
|
|
/// went missing.
|
|
case reseed
|
|
|
|
/// Drop what the zone held, and never send it back.
|
|
///
|
|
/// The account owner asked iCloud to forget this app's data (Settings →
|
|
/// iCloud → Manage Storage) or reset their encrypted data. CloudKit is
|
|
/// explicit that data deleted for either reason must not be resent, so
|
|
/// nothing is republished and everything still queued from before the
|
|
/// deletion is dropped. Local data is left alone: it is this app's own
|
|
/// store, not a cache of the zone.
|
|
case discard
|
|
|
|
/// The response CloudKit's deletion reason calls for.
|
|
///
|
|
/// A reason this build does not recognise is answered as a purge:
|
|
/// republishing data the server deliberately dropped is the one mistake
|
|
/// here that cannot be taken back.
|
|
init(reason: CKDatabase.DatabaseChange.Deletion.Reason) {
|
|
switch reason {
|
|
case .deleted:
|
|
self = .reseed
|
|
case .purged, .encryptedDataReset:
|
|
self = .discard
|
|
@unknown default:
|
|
self = .discard
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One category of local data a re-seed publishes.
|
|
enum ReseedCategory: String, CaseIterable, Sendable {
|
|
case accounts
|
|
case subscriptions
|
|
case watchHistory
|
|
case bookmarks
|
|
case playlists
|
|
case searchHistory
|
|
case recentChannels
|
|
case recentPlaylists
|
|
case channelNotificationSettings
|
|
case controlsPresets
|
|
|
|
/// The order a re-seed registers categories in.
|
|
///
|
|
/// Accounts lead. They carry the logins, the deletion tombstones and the
|
|
/// selected context — the records every other category is read through —
|
|
/// and a re-seed can be cut short at any point by the same backgrounding,
|
|
/// sign-out or crash that interrupts any other upload. Going last, as the
|
|
/// initial upload used to, is how a wipe took the accounts with it.
|
|
static let reseedOrder: [ReseedCategory] = [
|
|
.accounts,
|
|
.subscriptions,
|
|
.watchHistory,
|
|
.bookmarks,
|
|
.playlists,
|
|
.searchHistory,
|
|
.recentChannels,
|
|
.recentPlaylists,
|
|
.channelNotificationSettings,
|
|
.controlsPresets
|
|
]
|
|
}
|
|
|
|
/// Remembers that the zone was wiped, until the answer to it is durably queued.
|
|
///
|
|
/// Answering a wipe takes several steps — recreate the zone, re-register every
|
|
/// local record — and the process can die between any two of them. Without a
|
|
/// marker written *before* the first step, a relaunch finds a zone that exists
|
|
/// again and no local sign that anything was lost, and the wipe is invisible
|
|
/// for good.
|
|
///
|
|
/// Durability rests on what the engine's own change token rests on: both are
|
|
/// `UserDefaults` writes, so a crash that preserved the token preserved a
|
|
/// marker written before it too.
|
|
@MainActor
|
|
final class CloudKitZoneRecoveryMarker {
|
|
private let defaults: UserDefaults
|
|
private let key: String
|
|
|
|
/// Whether the work this marker stands for is registered with the engine
|
|
/// and only waiting for the engine to serialize its state.
|
|
private var awaitingStatePersistence = false
|
|
|
|
init(defaults: UserDefaults = .standard, key: String = "cloudKitZoneNeedsReseed") {
|
|
self.defaults = defaults
|
|
self.key = key
|
|
}
|
|
|
|
/// The response a run recorded and may not have finished.
|
|
var pending: ZoneDeletionResponse? {
|
|
defaults.string(forKey: key).flatMap(ZoneDeletionResponse.init(rawValue:))
|
|
}
|
|
|
|
/// Records what has to happen — before any of it does.
|
|
func begin(_ response: ZoneDeletionResponse) {
|
|
defaults.set(response.rawValue, forKey: key)
|
|
awaitingStatePersistence = false
|
|
}
|
|
|
|
/// Says the recovery is fully registered with the engine, so the next
|
|
/// serialization of that state may retire the marker.
|
|
///
|
|
/// Deliberately separate from `begin`: a recovery that could not register
|
|
/// everything — accounts belonging to an identity that has not loaded yet —
|
|
/// keeps its marker and is redone on the next launch.
|
|
func readyToClear() {
|
|
awaitingStatePersistence = true
|
|
}
|
|
|
|
/// Retires the marker once the engine has persisted the state the recovery
|
|
/// registered, and only then.
|
|
///
|
|
/// State updates arrive for every other reason too, and one of those must
|
|
/// not retire a marker whose work has not been redone — that is exactly the
|
|
/// state a relaunch after an interrupted recovery starts in.
|
|
func stateDidPersist() {
|
|
guard awaitingStatePersistence else { return }
|
|
awaitingStatePersistence = false
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
}
|