mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
"Yattee (iCloud)" was hardcoded for the local data context even with iCloud sync disabled, when the data is purely on-device. The name now comes from DataContextValidator.localContextName, which checks iCloudSyncEnabled and falls back to "Yattee (This Device)".
348 lines
14 KiB
Swift
348 lines
14 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 the switcher only *lists* a disabled source's
|
|
// contexts — it refuses to switch to them — so 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 .instanceDisabled: return .temporarilyUnavailable(.instanceDisabled)
|
|
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 switcher *lists*, usable or not.
|
|
///
|
|
/// A source the user switched off does not make its logins disappear. The
|
|
/// switcher listing only what can be switched to meant the badge on the
|
|
/// toolbar icon pointed at a menu the account had dropped out of — the
|
|
/// selected account included, so the one control that explains the badge
|
|
/// stopped naming the thing it was badging for. The rows stay, carrying
|
|
/// their status, and `isSwitchable(_:)` decides which of them can be picked.
|
|
///
|
|
/// The selection is the row that may never be missing, so it is appended
|
|
/// even when it is no longer among the selectable accounts — as long as the
|
|
/// account still exists. A context whose account is gone is not listed:
|
|
/// there is nothing to name it with, and correction handles deletions.
|
|
var listedContexts: [DataContext] {
|
|
var contexts: [DataContext] = [.local]
|
|
contexts += accountsManager.selectableAccounts.map { DataContext.account($0.id) }
|
|
|
|
let current = accountsManager.currentContext
|
|
if case let .account(id) = current,
|
|
accountsManager.account(id: id) != nil,
|
|
!contexts.contains(current) {
|
|
contexts.append(current)
|
|
}
|
|
|
|
return 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. A disabled source is the one
|
|
/// exception — nothing may be read from or written to a server the user has
|
|
/// turned off — and it is expressed by refusing the switch, not by hiding
|
|
/// the row.
|
|
var availableContexts: [DataContext] {
|
|
listedContexts.filter { isSwitchable($0) }
|
|
}
|
|
|
|
/// Whether picking this context would land the user somewhere usable.
|
|
///
|
|
/// The line runs between states that heal on their own and states only the
|
|
/// user can end. An unreachable account may be back in a minute — and
|
|
/// while the device itself is offline *every* account is unreachable, so
|
|
/// refusing those would lock the whole switcher — and a context still
|
|
/// resolving is refused nothing it may turn out to deserve. But a rejected
|
|
/// or missing credential and a switched-off source stay that way until the
|
|
/// user acts, in Settings, not here: picking one only lands on the local
|
|
/// fallback with a banner, so the row is listed — status in its title —
|
|
/// and refused.
|
|
func isSwitchable(_ context: DataContext) -> Bool {
|
|
switch context {
|
|
case .local:
|
|
return true
|
|
|
|
case .account(let id):
|
|
guard accountsManager.selectableAccounts.contains(where: { $0.id == id }) else {
|
|
return false
|
|
}
|
|
|
|
switch validity(of: context) {
|
|
case .valid:
|
|
return true
|
|
case .removed:
|
|
return false
|
|
case let .temporarilyUnavailable(reason):
|
|
switch reason {
|
|
case .instanceDisabled, .credentialRejected, .credentialMissing:
|
|
return false
|
|
case .checking, .instancePending, .unreachable:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 localContextName
|
|
|
|
case let .account(id):
|
|
guard let account = accountsManager.account(id: id) else {
|
|
return localContextName
|
|
}
|
|
return displayName(for: account)
|
|
}
|
|
}
|
|
|
|
/// "(iCloud)" only when the data actually syncs; otherwise it lives on
|
|
/// this device alone and the label says so.
|
|
var localContextName: String {
|
|
if appEnvironment?.settingsManager.iCloudSyncEnabled == true {
|
|
return String(localized: "subscriptions.account.local")
|
|
}
|
|
return String(localized: "subscriptions.account.local.device")
|
|
}
|
|
|
|
/// User-set label, else whatever the account falls back to.
|
|
func displayName(for account: Account) -> String {
|
|
if let name = account.displayName, !name.isEmpty { return name }
|
|
return defaultDisplayName(for: account)
|
|
}
|
|
|
|
/// What the account is called with no user-set label: the stored login
|
|
/// identifier, else the source's name, else its host.
|
|
///
|
|
/// After the `@` comes the source's *title* when it has one — the host is
|
|
/// the fallback, not the name: `arek@nerdvpn.de` reads, while
|
|
/// `arek@invidious.nerdvpn.de` overflows every menu row it appears in.
|
|
///
|
|
/// Device-local by nature. 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 — two devices holding the very same account can
|
|
/// legitimately fall back to different names for it.
|
|
func defaultDisplayName(for account: Account) -> String {
|
|
if let username = appEnvironment?
|
|
.accountCredentialsManager(for: account)?
|
|
.username(for: account),
|
|
!username.isEmpty {
|
|
if let label = accountsManager.instance(for: account)?.shortLabel {
|
|
return "\(username)@\(label)"
|
|
}
|
|
return username
|
|
}
|
|
|
|
if let instance = accountsManager.instance(for: account) {
|
|
return instance.shortLabel ?? instance.type.displayName
|
|
}
|
|
|
|
return String(localized: "accounts.status.instanceMissing")
|
|
}
|
|
|
|
/// What a rename field's contents mean once the user is done typing.
|
|
///
|
|
/// `nil` clears the user-set label and hands the account back to
|
|
/// `defaultDisplayName`; anything else is stored as typed.
|
|
///
|
|
/// The question the field cannot answer on its own is what an *untouched*
|
|
/// save means. The field starts prefilled with the label the row shows, and
|
|
/// on an account with no user-set label that label is device-local — the
|
|
/// login identifier only exists where the credential does. Saving it
|
|
/// unedited must not turn one device's fallback into a synced label every
|
|
/// other device is then made to display, so that case clears instead.
|
|
///
|
|
/// The test is editedness, never equality with the default: on a second
|
|
/// device the string that device happens to fall back to is a perfectly
|
|
/// ordinary name to *choose*, and typing it has to store and sync like any
|
|
/// other. An unedited save on an account that already carries a label
|
|
/// re-states that label, which the manager then drops as a no-op — the name
|
|
/// stays, rather than being silently cleared by a tap that changed nothing.
|
|
static func renamedDisplayName(
|
|
_ input: String,
|
|
prefilledWith prefill: String,
|
|
hasUserSetLabel: Bool
|
|
) -> String? {
|
|
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
let wasEdited = trimmed != prefill.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard wasEdited || hasUserSetLabel else { return nil }
|
|
|
|
return trimmed
|
|
}
|
|
}
|