Files
yattee/Yattee/Views/Settings/InstanceAccountsSection.swift
Arkadiusz Fal 9065234606 Key subscription providers by account rather than backend type
SubscriptionService held one provider per backend and each resolved its
instance from settingsManager.subscriptionAccount, falling back to "the
first enabled Invidious instance" when that was unset. With several logins
possible on one instance that model breaks twice: two accounts would share
a single in-memory subscription cache, and the fallback could send an
account's request to a server it holds no credential for.

Providers are now built per account and cached by account ID, and each
resolves its instance and credential from the account it was built for -
no fallback. The protocol's accountType becomes a DataContext, which is
what actually identifies a provider now.

Views that branched on "is the subscription account local" read the context
instead, and the settings picker writes both stores. Keeping the legacy
setting in step is deliberate for now: several views still read it, and
letting the two diverge would show one account in the UI while serving
another's data. The Phase 3 switcher removes the last readers.

Signing out discards the account's provider so its cached subscriptions
cannot be served after the account is gone.
2026-08-24 23:56:40 +02:00

197 lines
7.0 KiB
Swift

//
// InstanceAccountsSection.swift
// Yattee
//
// The per-instance account list shown in EditSourceView.
//
import SwiftUI
/// Which account a login sheet is for. `account == nil` means "create a new one".
struct AccountLoginTarget: Identifiable {
let id: UUID
let account: Account?
static let newAccount = AccountLoginTarget(id: UUID(), account: nil)
static func existing(_ account: Account) -> AccountLoginTarget {
AccountLoginTarget(id: account.id, account: account)
}
}
/// Lists the accounts signed in on one instance.
///
/// Replaces the single "Log in / Log out" pair: an instance can now hold
/// several logins, each with its own credential and status.
struct InstanceAccountsSection: View {
let instance: Instance
@Binding var loginTarget: AccountLoginTarget?
@Environment(\.appEnvironment) private var appEnvironment
private var accounts: [Account] {
appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? []
}
var body: some View {
Section {
ForEach(accounts) { account in
accountRow(account)
}
Button {
loginTarget = .newAccount
} label: {
Label(
String(localized: accounts.isEmpty ? "login.loginToAccount" : "accounts.addAccount"),
systemImage: "person.badge.plus"
)
}
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
} header: {
Text(String(localized: "login.header.account"))
} footer: {
Text(String(localized: accounts.isEmpty ? "login.footer.loginBenefits" : "login.footer.loggedIn"))
}
adoptionSection
}
// MARK: - Rows
@ViewBuilder
private func accountRow(_ account: Account) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(displayName(for: account))
.lineLimit(1)
Text(statusText(for: account))
.font(.caption)
.foregroundStyle(statusColor(for: account))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contextMenu {
Button {
loginTarget = .existing(account)
} label: {
Label(String(localized: "login.signInAgain"), systemImage: "person.badge.key")
}
Button(role: .destructive) {
signOut(account)
} label: {
Label(String(localized: "login.signOutAndRemove"), systemImage: "rectangle.portrait.and.arrow.forward")
}
}
#if os(tvOS)
// tvOS never syncs app Keychain items, so an account that arrived from
// another device is permanently credential-less here until the user
// signs in on the Apple TV itself. Offer that directly rather than
// leaving a status the user cannot act on.
if status(for: account) == .credentialMissing {
Button {
loginTarget = .existing(account)
} label: {
Label(String(localized: "accounts.signInOnThisDevice"), systemImage: "person.badge.key")
}
.buttonStyle(TVSettingsButtonStyle())
}
#endif
}
/// The one-tap adoption of a login saved by an earlier version.
///
/// Shown only when such an item exists and has not been adopted. It is a
/// deliberate action because a retained Keychain item carries no proof of
/// which Apple Account it belonged to — the app cannot tell an inherited
/// credential from the user's own.
@ViewBuilder
private var adoptionSection: some View {
let adoptable = accounts.filter { account in
appEnvironment?
.accountCredentialsManager(for: account)?
.hasAdoptableLegacyCredential(for: account) == true
}
if !adoptable.isEmpty {
Section {
ForEach(adoptable) { account in
Button {
adoptLegacyCredential(for: account)
} label: {
Label(String(localized: "accounts.useSavedLogin"), systemImage: "key.horizontal")
}
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
} footer: {
Text(String(localized: "accounts.useSavedLogin.footer"))
}
}
}
// MARK: - Presentation
private func displayName(for account: Account) -> String {
appEnvironment?.dataContextValidator.displayName(for: account) ?? instance.displayName
}
private func status(for account: Account) -> AccountStatus {
appEnvironment?.accountsManager.status(for: account.id) ?? .credentialMissing
}
private func statusText(for account: Account) -> String {
switch status(for: account) {
case .signedIn: String(localized: "accounts.status.signedIn")
case .credentialMissing: String(localized: "accounts.status.credentialMissing")
case .credentialRejected: String(localized: "accounts.status.credentialRejected")
case .unreachable: String(localized: "accounts.status.unreachable")
}
}
private func statusColor(for account: Account) -> Color {
switch status(for: account) {
case .signedIn: .secondary
case .credentialMissing: .secondary
case .credentialRejected: .red
case .unreachable: .orange
}
}
// MARK: - Actions
private func signOut(_ account: Account) {
guard let appEnvironment else { return }
// Deleting the account is what authorizes removing the legacy item, and
// only when this device adopted it and it still holds that same secret.
appEnvironment.accountCredentialsManager(for: account)?.deleteCredential(for: account)
appEnvironment.accountCredentialsManager(for: account)?.deleteLegacyCredentialIfOwned(for: account)
appEnvironment.accountsManager.removeAccount(account.id)
// Drop the provider too, or its in-memory subscription cache would keep
// answering for an account that no longer exists.
appEnvironment.subscriptionService.discardProvider(for: account.id)
if appEnvironment.accountsManager.accounts(forInstance: instance.id).isEmpty {
appEnvironment.instancesManager.setUsesAccountLogin(false, for: instance)
}
}
private func adoptLegacyCredential(for account: Account) {
guard let appEnvironment,
let credentials = appEnvironment.accountCredentialsManager(for: account)
else { return }
if credentials.adoptLegacyCredential(for: account) {
appEnvironment.accountsManager.setStatus(.signedIn, for: account.id)
} else {
appEnvironment.toastManager.showError(
String(localized: "accounts.useSavedLogin.failed")
)
}
}
}