mirror of
https://github.com/yattee/yattee.git
synced 2026-10-26 14:42:53 +00:00
Yattee 2 could hold at most one login per instance: a Keychain item keyed by the instance UUID and a Bool on the instance. This adds the account layer the rest of the revamp builds on. DataContext (.local or .account) replaces SubscriptionAccount as the thing that selects a data set. The legacy value maps onto it as an identity mapping, because migrated accounts deliberately reuse their instance UUID. Contexts carry an app-managed changedAt, and a migrated one is stamped at the epoch so any explicit choice on any device outranks it whatever order they arrive in. AccountCapabilities makes what a backend supports a single switch in InstanceType.accountCapabilities, verified against each API surface - Invidious has history and playlist privacy, Piped has neither. Call sites ask for capabilities instead of switching on instance type, which is the seam the planned per-user Yattee Server accounts land on. AccountsManager owns accounts and the selected context, stored per identity scope so a switch of Apple Account cannot show or upload the previous one's list. Legacy single-logins become accounts through a repeatable reconciliation rather than a one-shot at launch, because instances arrive over iCloud whenever they arrive; a permanent marker set is what makes re-running safe, since usesAccountLogin stays true on the instance forever and would otherwise recreate a deliberately deleted account. A dirty-flag outbox records what still needs uploading so an offline change survives a crash. The legacy subscriptionAccount selection is migrated one way, reading local defaults first and then the raw KVS value - the previous release published that key to iCloud but never read it back, so on a fresh install it exists only there. DataContextValidator replaces SubscriptionAccountValidator, which corrected any selection it could not immediately verify. That was safe when a context was just "an instance I am logged into", but with credentials arriving asynchronously it would discard the user's choice during a slow Keychain sync. Validity is now tri-state: anything that might still resolve keeps the selection and falls back to local for reads only, with server mutations disabled rather than silently retargeted at local data; only an actual deletion corrects the stored context. EditSourceView's single log in/out pair becomes a list of accounts with per-row status, sign-in-again and sign-out-and-remove, plus the explicit "use saved login from this device" action - the one-tap adoption that replaced automatic adoption after the Keychain provenance measurement. On tvOS a credential-less account offers a local sign-in instead, since Apple never syncs app Keychain items there and the state will not resolve on its own. InstanceLoginView now returns the identifier alongside the credential so the caller can file it in the Keychain beside the secret, keeping login identifiers out of synced metadata.
194 lines
6.8 KiB
Swift
194 lines
6.8 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)
|
|
|
|
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")
|
|
)
|
|
}
|
|
}
|
|
}
|