Files
yattee/Yattee/Services/Credentials/PipedCredentialsManager.swift
2026-09-08 22:23:30 +02:00

264 lines
9.4 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 resyncCredentialsWithCurrentSetting() {
accountCredentials.resyncCredentialsWithCurrentSetting()
}
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)
}
@discardableResult
func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool {
accountCredentials.migrateCredential(forAccountID: accountID, fromScope: sourceScope)
}
@discardableResult
func deleteCredential(forAccountID accountID: UUID, inScope scope: String) -> Bool {
accountCredentials.deleteCredential(forAccountID: accountID, inScope: scope)
}
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)
}
}