mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
`correctIfContextRemoved` read a locally missing account as a deleted one and reset the context with `setContext(.local)` — a new timestamp and a pending upload. Now that foreground activation calls it, it ran before the fetch that delivers the account record: a perfectly good remote choice was reset and then published over everybody else's. It now asks `AccountsManager` for a repair keyed on the deletion tombstone, which is the same repair a deletion performs — a signed-in login on the same server when there is one, the replaced choice's timestamp, nothing uploaded. The toast names where the selection actually landed. Adopting a synced context that named a deleted account repaired it but kept whatever upload the previous choice still owed, so the sync engine published the automatic fallback under that choice's timestamp — exactly what not stamping it "now" exists to prevent. The adoption clears the flag, and only on adoption: a local choice that outranks the repair keeps its own upload. The channel button's subscription lookup is fenced to the channel and the round it was asked in, so an answer that lands after the user has walked on — or after the account behind it changed — no longer writes itself into the button on screen. Context and credential changes ask again. A destination toggle is compared against the effective context rather than the raw selection: the menu offers Local precisely when the selected account cannot be written to, and that toggle has to be the one the button then shows. The destination rows forget their checkmarks when the logins change. The answers describe credentials that have been replaced, and the tvOS picker deliberately stays open across exactly that. The subscriptions settings screen counts and exports what it is actually reading from. The fetch behind both already followed the effective context while the branch and the footer followed the selection, so an unavailable account meant local data described as the server's. The legacy `subscriptionAccount` migration refused to adopt a context whose account had not synced yet and was never retried, leaving a fresh install on local until the next launch. A synced account arriving now retries it; the migration's own guards keep it a one-way move that never overrides a choice made in the meantime. The remaining negative-able Piped counts — likes, channel subscribers and videos, playlist video counts — go through `PipedSentinel.count` like the views and upload dates already did.
232 lines
9.1 KiB
Swift
232 lines
9.1 KiB
Swift
//
|
|
// DataContextValidator.swift
|
|
// Yattee
|
|
//
|
|
// Decides whether the selected data context can be used right now.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Why the selected context cannot be used at the moment.
|
|
enum DataContextUnavailability: Hashable, Sendable {
|
|
/// Nothing is known yet: the Apple account the credentials are keyed by is
|
|
/// still being established, so the Keychain cannot be read under the right
|
|
/// scope. Never render this as "sign in required".
|
|
case checking
|
|
/// The account exists but its instance hasn't arrived from iCloud yet.
|
|
case instancePending
|
|
/// The account's instance has been switched off. The selection is kept —
|
|
/// the user can turn the instance back on — but nothing may be served from
|
|
/// or written to a server they have disabled.
|
|
case instanceDisabled
|
|
/// No credential on this device yet. On tvOS this never resolves on its own
|
|
/// — app Keychain items are not synced there — so the UI offers a local
|
|
/// sign-in rather than implying a wait.
|
|
case credentialMissing
|
|
/// The server rejected the credential; the user must sign in again.
|
|
case credentialRejected
|
|
/// The server could not be reached.
|
|
case unreachable
|
|
}
|
|
|
|
/// The three states a context can be in.
|
|
///
|
|
/// The distinction that matters is between *unknown* and *gone*. Anything that
|
|
/// might still resolve — a slow iCloud Keychain, an instance that hasn't synced
|
|
/// — is `temporarilyUnavailable` and leaves the user's choice untouched. Only
|
|
/// a deletion the app can point at turns into `removed`.
|
|
enum DataContextValidity: Hashable, Sendable {
|
|
case valid
|
|
case temporarilyUnavailable(DataContextUnavailability)
|
|
case removed
|
|
|
|
var isValid: Bool { self == .valid }
|
|
}
|
|
|
|
/// Validates the selected `DataContext` and supplies the list of switchable
|
|
/// contexts.
|
|
///
|
|
/// Replaces `SubscriptionAccountValidator`, which auto-corrected on any
|
|
/// selection it could not immediately verify. That was safe when a context
|
|
/// could only be "an instance I am logged into", but with credentials arriving
|
|
/// asynchronously it would silently discard the user's choice during a slow
|
|
/// Keychain sync — so correction is now restricted to deletions.
|
|
@MainActor
|
|
@Observable
|
|
final class DataContextValidator {
|
|
private let accountsManager: AccountsManager
|
|
private let instancesManager: InstancesManager
|
|
private let toastManager: ToastManager
|
|
private weak var appEnvironment: AppEnvironment?
|
|
|
|
init(
|
|
accountsManager: AccountsManager,
|
|
instancesManager: InstancesManager,
|
|
toastManager: ToastManager
|
|
) {
|
|
self.accountsManager = accountsManager
|
|
self.instancesManager = instancesManager
|
|
self.toastManager = toastManager
|
|
}
|
|
|
|
func setAppEnvironment(_ environment: AppEnvironment) {
|
|
appEnvironment = environment
|
|
}
|
|
|
|
// MARK: - Validity
|
|
|
|
var validity: DataContextValidity {
|
|
validity(of: accountsManager.currentContext)
|
|
}
|
|
|
|
func validity(of context: DataContext) -> DataContextValidity {
|
|
switch context {
|
|
case .local:
|
|
// Local data is always present. The feed additionally needs a
|
|
// Yattee Server, but that is a feed-level concern, not a reason to
|
|
// reject the context.
|
|
return .valid
|
|
|
|
case let .account(id):
|
|
guard let account = accountsManager.account(id: id) else {
|
|
// Absence is not proof. An account can be missing because it
|
|
// hasn't synced yet; only an actual deletion removes it, and
|
|
// that path corrects the context directly.
|
|
return .temporarilyUnavailable(.instancePending)
|
|
}
|
|
|
|
guard let instance = accountsManager.instance(for: account) else {
|
|
return .temporarilyUnavailable(.instancePending)
|
|
}
|
|
|
|
// The account's own status says nothing about the instance being
|
|
// switched off, and `availableContexts` already hides disabled
|
|
// instances — a context that stayed valid here would keep sending
|
|
// requests to a server the user has turned off.
|
|
guard instance.isEnabled else {
|
|
return .temporarilyUnavailable(.instanceDisabled)
|
|
}
|
|
|
|
switch accountsManager.status(for: id) {
|
|
case .signedIn: return .valid
|
|
case .checking: return .temporarilyUnavailable(.checking)
|
|
case .credentialMissing: return .temporarilyUnavailable(.credentialMissing)
|
|
case .credentialRejected: return .temporarilyUnavailable(.credentialRejected)
|
|
case .unreachable: return .temporarilyUnavailable(.unreachable)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The context reads should actually use.
|
|
///
|
|
/// Falls back to `.local` while the selected context is unusable, but the
|
|
/// fallback is **never persisted** — the moment the credential or instance
|
|
/// arrives, the user's choice takes effect again without them having to
|
|
/// reselect it.
|
|
var effectiveContext: DataContext {
|
|
validity.isValid ? accountsManager.currentContext : .local
|
|
}
|
|
|
|
/// Whether writes may be sent to the current context's server.
|
|
///
|
|
/// Deliberately not the same as having a read fallback: a subscribe or a
|
|
/// playlist edit must fail visibly rather than being redirected into local
|
|
/// data the user did not choose.
|
|
var allowsServerMutations: Bool {
|
|
validity.isValid
|
|
}
|
|
|
|
/// Set while the selected context is unusable, for the banner.
|
|
var unavailability: DataContextUnavailability? {
|
|
if case let .temporarilyUnavailable(reason) = validity { return reason }
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Available contexts
|
|
|
|
/// Every context the user can switch to.
|
|
///
|
|
/// Includes accounts that are currently signed out: hiding them would make
|
|
/// a temporarily unreachable account look deleted, and the switcher is
|
|
/// where the user goes to fix exactly that.
|
|
var availableContexts: [DataContext] {
|
|
let accountContexts = accountsManager.selectableAccounts
|
|
.filter { accountsManager.instance(for: $0)?.isEnabled ?? false }
|
|
.map { DataContext.account($0.id) }
|
|
|
|
return [.local] + accountContexts
|
|
}
|
|
|
|
// MARK: - Correction
|
|
|
|
/// Corrects the context only when the selected account is provably gone.
|
|
///
|
|
/// Called after deletions and on foreground activation. A timeout, a failed
|
|
/// fetch or a record that simply has not arrived must never move anything —
|
|
/// those are all "unknown", and treating them as removal is how a user
|
|
/// loses their selection during a slow sync. Only the deletion tombstone
|
|
/// counts, and the repair it triggers is never published.
|
|
func correctIfContextRemoved() {
|
|
// An unresolved identity has not loaded anybody's accounts yet, so
|
|
// neither the selection nor the tombstones read here are the ones this
|
|
// device will end up with.
|
|
guard accountsManager.isIdentityResolved else { return }
|
|
|
|
// The manager owns the decision: it holds the tombstones, and it
|
|
// repairs the same way an account deleted right now would be — onto a
|
|
// signed-in login on the same server when there is one, keeping the
|
|
// replaced choice's timestamp and publishing nothing.
|
|
guard accountsManager.correctContextIfAccountDeleted() else { return }
|
|
|
|
toastManager.showInfo(
|
|
String(localized: "subscriptions.accountSwitched.title"),
|
|
subtitle: displayName(for: accountsManager.currentContext)
|
|
)
|
|
|
|
LoggingService.shared.info(
|
|
"Selected account was deleted — data context repaired",
|
|
category: .general
|
|
)
|
|
}
|
|
|
|
// MARK: - Display
|
|
|
|
func displayName(for context: DataContext) -> String {
|
|
switch context {
|
|
case .local:
|
|
return String(localized: "subscriptions.account.local")
|
|
|
|
case let .account(id):
|
|
guard let account = accountsManager.account(id: id) else {
|
|
return String(localized: "subscriptions.account.local")
|
|
}
|
|
return displayName(for: account)
|
|
}
|
|
}
|
|
|
|
/// User-set label, else the stored login identifier, else the host.
|
|
///
|
|
/// The username lives in the Keychain beside the credential rather than in
|
|
/// synced metadata, so it is only available on a device that is signed in.
|
|
func displayName(for account: Account) -> String {
|
|
if let name = account.displayName, !name.isEmpty { return name }
|
|
|
|
if let username = appEnvironment?
|
|
.accountCredentialsManager(for: account)?
|
|
.username(for: account),
|
|
!username.isEmpty {
|
|
if let host = accountsManager.instance(for: account)?.url.host {
|
|
return "\(username)@\(host)"
|
|
}
|
|
return username
|
|
}
|
|
|
|
if let instance = accountsManager.instance(for: account) {
|
|
if let name = instance.name, !name.isEmpty { return name }
|
|
return instance.url.host ?? instance.type.displayName
|
|
}
|
|
|
|
return String(localized: "accounts.status.instanceMissing")
|
|
}
|
|
}
|