Files
yattee/Yattee/Services/Credentials/PipedCredentialsManager.swift
Arkadiusz Fal 7c966b18a9 Keep deleted credentials and cached feeds from outliving their account
A deletion can reach a device before the account it deletes does — the
tombstone and the account are separate records, and iCloud Keychain runs
on its own timeline — and the credential cleanup was skipped whenever
the account was unknown here. The scoped key needs only the account ID,
so both credential managers are now asked to drop it; the unscoped
legacy item still needs the instance and is left alone.

Cached data is invalidated on the data context rather than on the legacy
selection it maps to. Two logins on the same instance collapse onto the
same legacy value, so switching between them cleared nothing and the
subscriptions feed kept showing the previous account's videos. Home
feeds are keyed by instance alone and had the same hole across accounts
and across Apple accounts; they are dropped with it.

The third source-deletion path now cascades to accounts like the other
two, and the Invidious private-playlist lookup resolves its session
through the account — a login created after the revamp writes only an
account-scoped credential, so the instance-keyed lookup missed it and
fell through to the public endpoint.
2026-08-25 23:59:17 +02:00

250 lines
8.9 KiB
Swift

//
// PipedCredentialsManager.swift
// Yattee
//
// Manages Piped authentication tokens stored securely in the Keychain.
//
import Foundation
import Security
/// Manages Piped auth tokens stored securely in the Keychain.
@MainActor
@Observable
final class PipedCredentialsManager: InstanceCredentialsManager {
private static let keychainServiceName = "com.yattee.piped"
/// Tracks which instances have stored tokens (for reactive UI updates)
private(set) var loggedInInstanceIDs: Set<UUID> = []
/// Account-keyed, identity-scoped storage — the multi-login path.
///
/// The instance-keyed members below stay for call sites that still think in
/// instances; they read and write the unscoped legacy item unchanged, so an
/// upgrade never silently moves a credential.
let accountCredentials: AccountKeychainCredentials
/// Reference to settings manager for iCloud sync decisions
weak var settingsManager: SettingsManager? {
didSet { accountCredentials.settingsManager = settingsManager }
}
init(identityScope: CredentialIdentityScope? = nil) {
accountCredentials = AccountKeychainCredentials(
service: Self.keychainServiceName,
credentialNoun: "token",
identityScope: identityScope ?? CredentialIdentityScope()
)
}
/// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for instances).
private var shouldSyncToiCloud: Bool {
settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncInstances == true
}
// MARK: - InstanceCredentialsManager Protocol
/// Stores the auth token for a Piped instance.
/// Token syncs to iCloud Keychain when iCloud sync is enabled for instances.
/// - Parameters:
/// - credential: The auth token from login
/// - instance: The Piped instance
func setCredential(_ credential: String, for instance: Instance) {
let account = instance.id.uuidString
guard let data = credential.data(using: .utf8) else {
LoggingService.shared.error("Failed to encode token data", category: .keychain)
return
}
let syncEnabled = shouldSyncToiCloud
// First, delete any existing item (both synced and non-synced) to avoid duplicates
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
// Create new item with current sync preference
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: syncEnabled,
kSecValueData as String: data
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecSuccess {
LoggingService.shared.info(
"Stored token for Piped instance",
category: .keychain,
details: "instanceID=\(instance.id), iCloudSync=\(syncEnabled)"
)
// Update tracked set for reactive UI
loggedInInstanceIDs.insert(instance.id)
} else {
LoggingService.shared.error(
"Failed to store token for Piped instance",
category: .keychain,
details: "instanceID=\(instance.id), status=\(status)"
)
}
}
/// Retrieves the auth token for a Piped instance.
/// Searches both synced and non-synced items.
/// - Parameter instance: The Piped instance
/// - Returns: The auth token if stored, nil otherwise
func credential(for instance: Instance) -> String? {
let account = instance.id.uuidString
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8) else {
LoggingService.shared.debug(
"No token found for Piped instance",
category: .keychain,
details: "instanceID=\(instance.id), status=\(status)"
)
return nil
}
LoggingService.shared.debug(
"Retrieved token for Piped instance",
category: .keychain,
details: "instanceID=\(instance.id)"
)
return token
}
/// Deletes the auth token for an instance (logout).
/// Deletes both synced and non-synced items.
/// - Parameter instance: The Piped instance
func deleteCredential(for instance: Instance) {
let account = instance.id.uuidString
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
LoggingService.shared.info(
"Deleted token for Piped instance",
category: .keychain,
details: "instanceID=\(instance.id)"
)
} else {
LoggingService.shared.error(
"Failed to delete token for Piped instance",
category: .keychain,
details: "instanceID=\(instance.id), status=\(status)"
)
}
// Update tracked set for reactive UI
loggedInInstanceIDs.remove(instance.id)
// Clear Feed cache on logout
HomeInstanceCache.shared.clear(instanceID: instance.id, contentType: .feed)
}
/// Checks if an instance has a stored token.
/// - Parameter instance: The Piped instance
/// - Returns: true if logged in, false otherwise
func isLoggedIn(for instance: Instance) -> Bool {
// Check tracked set first for performance
if loggedInInstanceIDs.contains(instance.id) {
return true
}
// Fall back to Keychain check
let hasToken = credential(for: instance) != nil
if hasToken {
loggedInInstanceIDs.insert(instance.id)
}
return hasToken
}
/// Refreshes the logged-in status for an instance.
/// Call this when the view appears to ensure UI is in sync.
func refreshLoginStatus(for instance: Instance) {
if credential(for: instance) != nil {
loggedInInstanceIDs.insert(instance.id)
} else {
loggedInInstanceIDs.remove(instance.id)
}
}
}
// MARK: - AccountCredentialsManager
extension PipedCredentialsManager: AccountCredentialsManager {
var loggedInAccountIDs: Set<UUID> { accountCredentials.loggedInAccountIDs }
var isScopeResolved: Bool { accountCredentials.isScopeResolved }
func setCredential(_ credential: String, for account: Account) {
accountCredentials.setCredential(credential, for: account)
}
func credential(for account: Account) -> String? {
accountCredentials.credential(for: account)
}
func deleteCredential(for account: Account) {
accountCredentials.deleteCredential(for: account)
}
func deleteCredential(forAccountID accountID: UUID) {
accountCredentials.deleteCredential(forAccountID: accountID)
}
func setUsername(_ username: String, for account: Account) {
accountCredentials.setUsername(username, for: account)
}
func username(for account: Account) -> String? {
accountCredentials.username(for: account)
}
func isLoggedIn(for account: Account) -> Bool {
accountCredentials.isLoggedIn(for: account)
}
func refreshLoginStatus(for account: Account) {
accountCredentials.refreshLoginStatus(for: account)
}
func hasAdoptableLegacyCredential(for account: Account) -> Bool {
accountCredentials.hasAdoptableLegacyCredential(for: account)
}
@discardableResult
func adoptLegacyCredential(for account: Account) -> Bool {
accountCredentials.adoptLegacyCredential(for: account)
}
func deleteLegacyCredentialIfOwned(for account: Account) {
accountCredentials.deleteLegacyCredentialIfOwned(for: account)
}
}