mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Signing in wrote `usesAccountLogin`, which syncs through key-value storage independently of the account records. A device receiving that flag before the account synthesized a migrated account for a login that was never legacy — and the marker arriving later deliberately retracts nothing, so the credential-less duplicate was permanent and uploaded. The flag is pre-revamp state that migration only reads; accounts describe logins now. `needsCredentials` reads the account list for any instance that has one, so the badge it drives is unaffected. A tombstone now dominates contexts as well as accounts: an offline device can select an account another device deleted and publish that choice with a newer timestamp, which would have left every device pointing at an account that is never coming back. Cached private feeds survived an Apple-account switch two ways. A scope this device has seen before resolves while `CredentialIdentityScope` is still being constructed, so the change notification fires before any observer exists — startup now compares the persisted cache owner. And a purge asked for before the disk cache is read was undone by the load, which now carries it. The need to re-bootstrap after dropping records for want of a resolved identity is persisted. The engine may already have moved its token past those records, so a launch that lost the in-memory flag would neither replay nor bootstrap them.
177 lines
6.8 KiB
Swift
177 lines
6.8 KiB
Swift
//
|
|
// AccountSyncStore.swift
|
|
// Yattee
|
|
//
|
|
// Durable, identity-namespaced staging for fetched account-category records.
|
|
//
|
|
|
|
import CloudKit
|
|
import Foundation
|
|
|
|
/// One fetched account-category change, held until it may be applied.
|
|
struct StagedAccountChange: Codable, Sendable {
|
|
enum Kind: String, Codable, Sendable {
|
|
case save
|
|
case delete
|
|
}
|
|
|
|
/// Arrival order. Two changes to the same record collapse to the later one;
|
|
/// changes to *different* records keep their order, which is what lets a
|
|
/// tombstone be applied ahead of an account record it dominates.
|
|
let sequence: Int
|
|
let recordName: String
|
|
let kind: Kind
|
|
|
|
/// Archived `CKRecord`. `nil` for deletions, which carry only a record ID.
|
|
let recordData: Data?
|
|
|
|
func record() -> CKRecord? {
|
|
guard let recordData else { return nil }
|
|
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKRecord.self, from: recordData)
|
|
}
|
|
}
|
|
|
|
/// Storage for everything account sync has to survive a relaunch with.
|
|
///
|
|
/// ### Why staging is persisted at all
|
|
///
|
|
/// `CKSyncEngine` emits several `fetchedRecordZoneChanges` events between
|
|
/// `willFetchChanges` and `didFetchChanges`, and may persist its change token
|
|
/// after any of them. Applying records as they arrive would expose an account
|
|
/// delivered in the first event before the tombstone that deletes it arrives in
|
|
/// the third; holding them only in memory would lose them to a crash while the
|
|
/// engine's token had already moved past them. So each event's records are
|
|
/// written here before the delegate returns, and applied as a set once the zone
|
|
/// fetch has actually succeeded.
|
|
///
|
|
/// Durability rests on the same mechanism the engine's own token does: both are
|
|
/// `UserDefaults` writes, and they are ordered, so any crash that preserved the
|
|
/// token also preserved the staging written before it.
|
|
///
|
|
/// ### Why everything is namespaced
|
|
///
|
|
/// Every value here belongs to one Apple account. Sharing storage across
|
|
/// identities would let a fetch begun under one account be applied under the
|
|
/// next, which is precisely the disclosure that scoping accounts prevents.
|
|
@MainActor
|
|
final class AccountSyncStore {
|
|
private let defaults: UserDefaults
|
|
|
|
init(defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
}
|
|
|
|
private func key(_ name: String, in namespace: String) -> String {
|
|
"accountSync.\(namespace).\(name)"
|
|
}
|
|
|
|
// MARK: - Staging
|
|
|
|
func stagedChanges(in namespace: String) -> [StagedAccountChange] {
|
|
guard let data = defaults.data(forKey: key("staged", in: namespace)),
|
|
let decoded = try? JSONDecoder().decode([StagedAccountChange].self, from: data) else {
|
|
return []
|
|
}
|
|
return decoded.sorted { $0.sequence < $1.sequence }
|
|
}
|
|
|
|
/// Stages fetched changes, replacing any earlier change to the same record.
|
|
///
|
|
/// Records that fail to archive are dropped rather than silently staged as
|
|
/// deletions — losing a record is recoverable by a later fetch or bootstrap,
|
|
/// but a phantom deletion is not.
|
|
func stage(records: [CKRecord], deletions: [CKRecord.ID], in namespace: String) {
|
|
guard !records.isEmpty || !deletions.isEmpty else { return }
|
|
|
|
var byName: [String: StagedAccountChange] = [:]
|
|
for change in stagedChanges(in: namespace) {
|
|
byName[change.recordName] = change
|
|
}
|
|
|
|
var sequence = byName.values.map(\.sequence).max().map { $0 + 1 } ?? 0
|
|
|
|
for record in records {
|
|
guard let data = try? NSKeyedArchiver.archivedData(
|
|
withRootObject: record,
|
|
requiringSecureCoding: true
|
|
) else {
|
|
LoggingService.shared.logCloudKitError(
|
|
"Could not archive account record \(record.recordID.recordName) for staging",
|
|
error: CloudKitError.recordNotFound
|
|
)
|
|
continue
|
|
}
|
|
|
|
byName[record.recordID.recordName] = StagedAccountChange(
|
|
sequence: sequence,
|
|
recordName: record.recordID.recordName,
|
|
kind: .save,
|
|
recordData: data
|
|
)
|
|
sequence += 1
|
|
}
|
|
|
|
for recordID in deletions {
|
|
byName[recordID.recordName] = StagedAccountChange(
|
|
sequence: sequence,
|
|
recordName: recordID.recordName,
|
|
kind: .delete,
|
|
recordData: nil
|
|
)
|
|
sequence += 1
|
|
}
|
|
|
|
let ordered = byName.values.sorted { $0.sequence < $1.sequence }
|
|
guard let data = try? JSONEncoder().encode(ordered) else {
|
|
LoggingService.shared.logCloudKit("Could not persist staged account changes")
|
|
return
|
|
}
|
|
defaults.set(data, forKey: key("staged", in: namespace))
|
|
}
|
|
|
|
func clearStagedChanges(in namespace: String) {
|
|
defaults.removeObject(forKey: key("staged", in: namespace))
|
|
}
|
|
|
|
// MARK: - Schema bootstrap
|
|
|
|
/// Whether this identity has run the one-time full-zone fetch that picks up
|
|
/// account records a previous app version consumed without understanding.
|
|
func isBootstrapCompleted(in namespace: String) -> Bool {
|
|
defaults.integer(forKey: key("schemaVersion", in: namespace)) >= AccountSyncStore.schemaVersion
|
|
}
|
|
|
|
func markBootstrapCompleted(in namespace: String) {
|
|
defaults.set(AccountSyncStore.schemaVersion, forKey: key("schemaVersion", in: namespace))
|
|
}
|
|
|
|
/// Forces the bootstrap to run again for this identity.
|
|
///
|
|
/// Used when account records had to be dropped because the identity they
|
|
/// belonged to could not be established at the time — the engine's token has
|
|
/// moved past them, so only a full-zone fetch can recover them.
|
|
func clearBootstrapCompletion(in namespace: String) {
|
|
defaults.removeObject(forKey: key("schemaVersion", in: namespace))
|
|
}
|
|
|
|
// MARK: - Identity recovery
|
|
|
|
/// Whether account records were dropped because no identity was resolved to
|
|
/// stage them under.
|
|
///
|
|
/// Deliberately not namespaced — the identity they belonged to is the one
|
|
/// thing that could not be established — and persisted, because the engine
|
|
/// may already have moved its change token past those records: a crash
|
|
/// before the identity resolved would otherwise leave them unreachable by
|
|
/// any later fetch.
|
|
var hasPendingIdentityRecovery: Bool {
|
|
get { defaults.bool(forKey: "accountSync.pendingIdentityRecovery") }
|
|
set { defaults.set(newValue, forKey: "accountSync.pendingIdentityRecovery") }
|
|
}
|
|
|
|
/// The account-category schema this build understands. Raising it makes
|
|
/// every identity bootstrap once more, which is how a future release picks
|
|
/// up record types this one ignores.
|
|
static let schemaVersion = 1
|
|
}
|