Files
yattee/Yattee/Views/Settings/InstanceAccountsSection.swift
Arkadiusz Fal 53adfd2d66 Migrate a saved login without asking about it
The pre-revamp release reads its unscoped Keychain item with
kSecAttrSynchronizableAny and no identity in the key, so a credential
retained across an Apple Account change is already served to whoever
holds the device. Asking before reusing it prevents nothing that is not
already happening, and it cost every upgrading user a trip into the
instance editor to find a button they had no reason to look for.

What silent adoption could genuinely add is re-filing that secret as
synchronizable under the identity signed in now, which uploads a login
of unproven provenance into that person's iCloud Keychain and spreads it
to their devices. So the copy is written device-local instead, and the
migration runs where the status is computed. The real owner loses
nothing: the item it was copied from syncs, so each of their devices
finds it and adopts its own copy, and adoption still never deletes it.

Two things keep that closed. Turning iCloud sync on rewrites stored
credentials to follow the setting, which would undo the whole point, so
adopted ones are left out of it. And only the account the instance's
legacy login became may inherit the secret — the old item is keyed by
instance, so a second login the user added themselves would otherwise
match it.
2026-08-31 23:59:36 +02:00

168 lines
6.1 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"))
}
}
// 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 {
// Without this a second login on an instance cannot be reached: the
// legacy picker lists one entry per instance, so it always resolves
// to whichever account was signed in first. The switcher that makes
// this a first-class choice everywhere comes later; until then this
// is what makes a second login usable at all.
if !isCurrentContext(account) {
Button {
appEnvironment?.accountsManager.setContext(.account(account.id))
} label: {
Label(String(localized: "accounts.useThisAccount"), systemImage: "checkmark.circle")
}
}
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
}
// 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) ?? .checking
}
private func statusText(for account: Account) -> String {
switch status(for: account) {
case .checking: String(localized: "accounts.status.checking")
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 isCurrentContext(_ account: Account) -> Bool {
appEnvironment?.accountsManager.currentContext == .account(account.id)
}
private func statusColor(for account: Account) -> Color {
switch status(for: account) {
case .checking: .secondary
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)
}
}
}