mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
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.
321 lines
11 KiB
Swift
321 lines
11 KiB
Swift
//
|
|
// InvidiousCredentialsManager.swift
|
|
// Yattee
|
|
//
|
|
// Manages Invidious session credentials stored securely in the Keychain.
|
|
//
|
|
|
|
import Foundation
|
|
import Security
|
|
|
|
/// Manages Invidious session IDs (SID) stored securely in the Keychain.
|
|
@MainActor
|
|
@Observable
|
|
final class InvidiousCredentialsManager: InstanceCredentialsManager {
|
|
private static let keychainServiceName = "com.yattee.invidious"
|
|
private let thumbnailCacheKey = "com.yattee.invidious.channelThumbnails"
|
|
|
|
/// Tracks which instances have stored sessions (for reactive UI updates)
|
|
private(set) var loggedInInstanceIDs: Set<UUID> = []
|
|
|
|
/// In-memory cache for channel thumbnails (channelID -> URL string)
|
|
private var thumbnailCache: [String: String] = [:]
|
|
|
|
/// 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 cleanup on logout and iCloud sync decisions
|
|
weak var settingsManager: SettingsManager? {
|
|
didSet { accountCredentials.settingsManager = settingsManager }
|
|
}
|
|
|
|
/// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for instances).
|
|
private var shouldSyncToiCloud: Bool {
|
|
settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncInstances == true
|
|
}
|
|
|
|
init(identityScope: CredentialIdentityScope? = nil) {
|
|
accountCredentials = AccountKeychainCredentials(
|
|
service: Self.keychainServiceName,
|
|
credentialNoun: "SID",
|
|
identityScope: identityScope ?? CredentialIdentityScope()
|
|
)
|
|
loadThumbnailCache()
|
|
}
|
|
|
|
// MARK: - Thumbnail Cache
|
|
|
|
/// Gets cached thumbnail URL for a channel.
|
|
func thumbnailURL(forChannelID channelID: String) -> URL? {
|
|
guard let urlString = thumbnailCache[channelID] else { return nil }
|
|
return URL(string: urlString)
|
|
}
|
|
|
|
/// Caches a thumbnail URL for a channel.
|
|
func setThumbnailURL(_ url: URL, forChannelID channelID: String) {
|
|
thumbnailCache[channelID] = url.absoluteString
|
|
saveThumbnailCache()
|
|
}
|
|
|
|
/// Caches multiple thumbnail URLs at once.
|
|
func setThumbnailURLs(_ thumbnails: [String: URL]) {
|
|
for (channelID, url) in thumbnails {
|
|
thumbnailCache[channelID] = url.absoluteString
|
|
}
|
|
saveThumbnailCache()
|
|
}
|
|
|
|
/// Returns channel IDs that are not in the cache.
|
|
func uncachedChannelIDs(from channelIDs: [String]) -> [String] {
|
|
channelIDs.filter { thumbnailCache[$0] == nil }
|
|
}
|
|
|
|
/// Clears the thumbnail cache.
|
|
func clearThumbnailCache() {
|
|
thumbnailCache.removeAll()
|
|
UserDefaults.standard.removeObject(forKey: thumbnailCacheKey)
|
|
}
|
|
|
|
private func loadThumbnailCache() {
|
|
if let data = UserDefaults.standard.data(forKey: thumbnailCacheKey),
|
|
let cache = try? JSONDecoder().decode([String: String].self, from: data) {
|
|
thumbnailCache = cache
|
|
}
|
|
}
|
|
|
|
private func saveThumbnailCache() {
|
|
if let data = try? JSONEncoder().encode(thumbnailCache) {
|
|
UserDefaults.standard.set(data, forKey: thumbnailCacheKey)
|
|
}
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Stores the SID (session ID) for an Invidious instance.
|
|
/// SID syncs to iCloud Keychain when iCloud sync is enabled for instances.
|
|
/// - Parameters:
|
|
/// - sid: The session ID cookie value
|
|
/// - instance: The Invidious instance
|
|
func setSID(_ sid: String, for instance: Instance) {
|
|
let account = instance.id.uuidString
|
|
guard let data = sid.data(using: .utf8) else {
|
|
LoggingService.shared.error("Failed to encode SID 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 SID for Invidious 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 SID for Invidious instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id), status=\(status)"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Retrieves the SID for an Invidious instance.
|
|
/// Searches both synced and non-synced items.
|
|
/// - Parameter instance: The Invidious instance
|
|
/// - Returns: The session ID if stored, nil otherwise
|
|
func sid(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 sid = String(data: data, encoding: .utf8) else {
|
|
LoggingService.shared.debug(
|
|
"No SID found for Invidious instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id), status=\(status)"
|
|
)
|
|
return nil
|
|
}
|
|
|
|
LoggingService.shared.debug(
|
|
"Retrieved SID for Invidious instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id)"
|
|
)
|
|
return sid
|
|
}
|
|
|
|
/// Deletes the SID for an instance (logout).
|
|
/// Deletes both synced and non-synced items.
|
|
/// - Parameter instance: The Invidious instance
|
|
func deleteSID(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 SID for Invidious instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id)"
|
|
)
|
|
} else {
|
|
LoggingService.shared.error(
|
|
"Failed to delete SID for Invidious instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id), status=\(status)"
|
|
)
|
|
}
|
|
|
|
// Update tracked set for reactive UI
|
|
loggedInInstanceIDs.remove(instance.id)
|
|
|
|
// Clear Feed cache (but keep Feed items in Home settings - toggle will be disabled)
|
|
HomeInstanceCache.shared.clear(instanceID: instance.id, contentType: .feed)
|
|
}
|
|
|
|
/// Checks if an instance has a stored session.
|
|
/// - Parameter instance: The Invidious 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 hasSession = sid(for: instance) != nil
|
|
if hasSession {
|
|
loggedInInstanceIDs.insert(instance.id)
|
|
}
|
|
return hasSession
|
|
}
|
|
|
|
/// 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 sid(for: instance) != nil {
|
|
loggedInInstanceIDs.insert(instance.id)
|
|
} else {
|
|
loggedInInstanceIDs.remove(instance.id)
|
|
}
|
|
}
|
|
|
|
// MARK: - InstanceCredentialsManager Protocol
|
|
|
|
/// Stores a credential (SID) for an Invidious instance.
|
|
/// Protocol conformance - delegates to setSID.
|
|
func setCredential(_ credential: String, for instance: Instance) {
|
|
setSID(credential, for: instance)
|
|
}
|
|
|
|
/// Retrieves the stored credential (SID) for an Invidious instance.
|
|
/// Protocol conformance - delegates to sid(for:).
|
|
func credential(for instance: Instance) -> String? {
|
|
sid(for: instance)
|
|
}
|
|
|
|
/// Deletes the stored credential (SID) for an Invidious instance.
|
|
/// Protocol conformance - delegates to deleteSID.
|
|
func deleteCredential(for instance: Instance) {
|
|
deleteSID(for: instance)
|
|
}
|
|
}
|
|
|
|
// MARK: - AccountCredentialsManager
|
|
|
|
extension InvidiousCredentialsManager: 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)
|
|
}
|
|
}
|