mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Reading a server-side playlist was all the app could do: the write half of both APIs was missing, and the only thing that knew which credential to use was the subscription layer. So playlists arrive here as an account concern — hand AccountService an account and it finds the instance, reads the secret, refuses what the backend cannot do, and records what the answer implies about the login. Capabilities decide, never the instance type: a privacy setting sent to a backend that has none is dropped rather than refused, because the playlist is still perfectly creatable there. Every call captures the credential it used and re-reads it before recording an outcome, so a late 401 cannot reject the login the user just fixed, nor a late success clear a rejection it never earned. Removal is where the backends stop being alike. Invidious names the row itself with an indexId that survives refetches, so it removes directly. Piped removes by position, and a position is only true of the fetch that minted it — so the playlist is refetched and the position checked to still hold the same video before anything is deleted. That narrows the window; it cannot close it, and only the refetch afterwards shows what really happened. The cache exists so that refetch cannot be forgotten. Every mutation goes through it and republishes what it touched — on failure as much as on success, because a write that reported failure may still have landed — and nothing is ever edited optimistically. It is in memory alone: the data belongs to a login rather than to this device, and a stored Piped entry id would be aimed at whatever occupies that slot next launch. A per-account generation fences results that come back after the account was cleared, since clearing has never cancelled the request that was filling it. Along the way, clients minted per instance sent no User-Agent at all — instances that whitelist the Yattee prefix answered them with a 401 the shared client never earned. The factory now bakes the app's User-Agent in at construction, where no request can outrun it.
187 lines
7.1 KiB
Swift
187 lines
7.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
|
|
|
|
/// Bumped whenever a credential is replaced, purely to re-read the login
|
|
/// identifier the rows display.
|
|
///
|
|
/// That identifier lives in the Keychain beside the secret, not in any
|
|
/// observable state, so signing in again as somebody else moves nothing
|
|
/// SwiftUI watches — same account, same context, and the status was
|
|
/// already `signedIn`. Without this the row keeps naming the previous
|
|
/// person while the app is already serving the new one's data, which is
|
|
/// exactly backwards: the label is the only thing telling two logins on
|
|
/// one instance apart.
|
|
@State private var credentialRevision = 0
|
|
|
|
private var accounts: [Account] {
|
|
appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? []
|
|
}
|
|
|
|
var body: some View {
|
|
Section {
|
|
ForEach(accounts) { account in
|
|
accountRow(account)
|
|
}
|
|
// Read here on purpose, so the rows genuinely depend on it rather
|
|
// than relying on a state write invalidating a body that never
|
|
// mentions it.
|
|
.id(credentialRevision)
|
|
|
|
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"))
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
|
|
credentialRevision &+= 1
|
|
}
|
|
}
|
|
|
|
// 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 what the account had in memory too, or its subscriptions and
|
|
// playlists would keep answering for an account that no longer exists.
|
|
appEnvironment.discardAccountData(for: account.id)
|
|
|
|
if appEnvironment.accountsManager.accounts(forInstance: instance.id).isEmpty {
|
|
appEnvironment.instancesManager.setUsesAccountLogin(false, for: instance)
|
|
}
|
|
}
|
|
}
|