mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
An account with no user-set label was named username@host everywhere it appears — the switcher menu, the settings rows, the playlist selector — which overflows a menu row the moment the host has a subdomain. The default name and the switcher's disambiguation suffixes now use the instance's title when one is set, falling back to the host as before.
321 lines
13 KiB
Swift
321 lines
13 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.
|
|
///
|
|
/// Not the same question as `validity(of:)`: a rejected or unreachable
|
|
/// account is still a perfectly good thing to switch *to* — that is where
|
|
/// the user goes to fix it — while a login on a switched-off source is not.
|
|
func isSwitchable(_ context: DataContext) -> Bool {
|
|
switch context {
|
|
case .local:
|
|
return true
|
|
|
|
case let .account(id):
|
|
guard accountsManager.selectableAccounts.contains(where: { $0.id == id }),
|
|
let account = accountsManager.account(id: id) else {
|
|
return false
|
|
}
|
|
return accountsManager.instance(for: account)?.isEnabled ?? false
|
|
}
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
}
|