mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
An additional login could be created but never selected: the legacy picker lists one entry per instance and always resolved to whichever account signed in first. Account rows gain a "Use This Account" action, and the subscriptions view reloads on the data context rather than on the legacy setting — two logins on the same instance map onto the same legacy value, so switching between them changed nothing on screen while writes went to the other account. Two devices migrating different legacy selections produced contexts with identical epoch timestamps, and each refused the other's: a permanent, silent divergence that both sides considered settled. Equal timestamps are now broken by the selected account's ID, on the incoming side and in the conflict resolver alike — a record that wins the upload must not be refused on arrival. Credential cleanup falls back to both managers whenever the backend cannot be resolved, not only when the account is unknown here: an instance deletion can arrive before the tombstone, and the credential was then left in the Keychain.
97 lines
3.5 KiB
Swift
97 lines
3.5 KiB
Swift
//
|
|
// DataContext.swift
|
|
// Yattee
|
|
//
|
|
// Which data set the Subscriptions, Feed and Playlists views are showing.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// The globally selected source of user data.
|
|
///
|
|
/// Replaces `SubscriptionAccount`, which could only ever describe *subscriptions*
|
|
/// and identified an account by the instance it lived on — a model that cannot
|
|
/// express two logins on the same instance.
|
|
enum DataContext: Codable, Hashable, Sendable {
|
|
/// The device's own data, synced through iCloud.
|
|
case local
|
|
/// A specific `Account.id`.
|
|
case account(UUID)
|
|
|
|
var accountID: UUID? {
|
|
if case let .account(id) = self { return id }
|
|
return nil
|
|
}
|
|
|
|
var isLocal: Bool { self == .local }
|
|
|
|
/// Total order used **only** to break a tie between two contexts chosen at
|
|
/// the same instant.
|
|
///
|
|
/// Migrated contexts are all deliberately stamped at the epoch, so two
|
|
/// devices migrating different legacy selections produce genuinely equal
|
|
/// timestamps. Without a tie-break each would keep its own and consider the
|
|
/// matter settled — a permanent, silent divergence. The value is arbitrary;
|
|
/// what matters is that every device computes the same one.
|
|
var tieBreakKey: String { accountID?.uuidString ?? "" }
|
|
}
|
|
|
|
/// A context together with the moment it was chosen.
|
|
///
|
|
/// The timestamp is app-managed and is the only ordering used when two devices
|
|
/// pick different contexts — CloudKit's own modification dates are never
|
|
/// consulted, because they record when a *record* was written rather than when
|
|
/// the user made a choice.
|
|
struct StoredDataContext: Codable, Hashable, Sendable {
|
|
var context: DataContext
|
|
var changedAt: Date
|
|
|
|
init(context: DataContext, changedAt: Date = Date()) {
|
|
self.context = context
|
|
self.changedAt = changedAt
|
|
}
|
|
|
|
/// A context produced by migrating the legacy `subscriptionAccount` value.
|
|
///
|
|
/// Stamped at the epoch so that any explicit choice the user has made on any
|
|
/// device outranks it regardless of which arrives first. A migration is a
|
|
/// guess at intent; an explicit choice is intent.
|
|
static func migrated(_ context: DataContext) -> StoredDataContext {
|
|
StoredDataContext(context: context, changedAt: Date(timeIntervalSince1970: 0))
|
|
}
|
|
|
|
var isMigrated: Bool { changedAt == Date(timeIntervalSince1970: 0) }
|
|
|
|
/// Whether `other` should replace this selection.
|
|
///
|
|
/// Ordered by the app-managed timestamp, with `DataContext.tieBreakKey`
|
|
/// settling an exact tie so that every device converges on the same answer
|
|
/// rather than each keeping its own.
|
|
func isOutranked(by other: StoredDataContext) -> Bool {
|
|
if other.changedAt != changedAt { return other.changedAt > changedAt }
|
|
return other.context.tieBreakKey > context.tieBreakKey
|
|
}
|
|
}
|
|
|
|
// MARK: - Legacy migration
|
|
|
|
extension DataContext {
|
|
/// Maps the pre-revamp `SubscriptionAccount` onto a context.
|
|
///
|
|
/// The instance UUID becomes the account UUID directly: migrated accounts
|
|
/// deliberately reuse their instance's UUID (see `Account.migrated(from:)`),
|
|
/// so this is an identity mapping rather than a lookup that could fail.
|
|
init(legacy: SubscriptionAccount) {
|
|
switch legacy.type {
|
|
case .local:
|
|
self = .local
|
|
case .invidious, .piped:
|
|
if let instanceID = legacy.instanceID {
|
|
self = .account(instanceID)
|
|
} else {
|
|
self = .local
|
|
}
|
|
}
|
|
}
|
|
}
|