mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
44
Yattee/Services/Credentials/InstanceCredentialsManager.swift
Normal file
44
Yattee/Services/Credentials/InstanceCredentialsManager.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// InstanceCredentialsManager.swift
|
||||
// Yattee
|
||||
//
|
||||
// Protocol defining the common interface for instance-specific credentials management.
|
||||
// Both InvidiousCredentialsManager and PipedCredentialsManager conform to this protocol.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol for instance-specific credentials management.
|
||||
/// Provides a unified interface for storing and retrieving authentication credentials
|
||||
/// across different instance types (Invidious, Piped, etc.).
|
||||
@MainActor
|
||||
protocol InstanceCredentialsManager: AnyObject, Observable {
|
||||
/// Set of instance IDs that are currently logged in.
|
||||
/// Used for reactive UI updates when login state changes.
|
||||
var loggedInInstanceIDs: Set<UUID> { get }
|
||||
|
||||
/// Stores a credential (SID for Invidious, token for Piped) for an instance.
|
||||
/// - Parameters:
|
||||
/// - credential: The credential value to store
|
||||
/// - instance: The instance to associate the credential with
|
||||
func setCredential(_ credential: String, for instance: Instance)
|
||||
|
||||
/// Retrieves the stored credential for an instance.
|
||||
/// - Parameter instance: The instance to retrieve the credential for
|
||||
/// - Returns: The stored credential, or nil if not logged in
|
||||
func credential(for instance: Instance) -> String?
|
||||
|
||||
/// Deletes the stored credential for an instance (logout).
|
||||
/// - Parameter instance: The instance to log out from
|
||||
func deleteCredential(for instance: Instance)
|
||||
|
||||
/// Checks if an instance has a stored credential.
|
||||
/// - Parameter instance: The instance to check
|
||||
/// - Returns: true if logged in, false otherwise
|
||||
func isLoggedIn(for instance: Instance) -> Bool
|
||||
|
||||
/// Refreshes the login status for an instance from the Keychain.
|
||||
/// Call this when a view appears to ensure UI is in sync with stored state.
|
||||
/// - Parameter instance: The instance to refresh status for
|
||||
func refreshLoginStatus(for instance: Instance)
|
||||
}
|
||||
254
Yattee/Services/Credentials/InvidiousCredentialsManager.swift
Normal file
254
Yattee/Services/Credentials/InvidiousCredentialsManager.swift
Normal file
@@ -0,0 +1,254 @@
|
||||
//
|
||||
// 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 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] = [:]
|
||||
|
||||
/// Reference to settings manager for cleanup on logout and iCloud sync decisions
|
||||
weak var 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() {
|
||||
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: 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: 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: 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: 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)
|
||||
}
|
||||
}
|
||||
180
Yattee/Services/Credentials/PipedCredentialsManager.swift
Normal file
180
Yattee/Services/Credentials/PipedCredentialsManager.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// 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 let keychainServiceName = "com.yattee.piped"
|
||||
|
||||
/// Tracks which instances have stored tokens (for reactive UI updates)
|
||||
private(set) var loggedInInstanceIDs: Set<UUID> = []
|
||||
|
||||
/// Reference to settings manager for iCloud sync decisions
|
||||
weak var 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
|
||||
}
|
||||
|
||||
// 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: 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: 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: 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: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
245
Yattee/Services/Credentials/YatteeServerCredentialsManager.swift
Normal file
245
Yattee/Services/Credentials/YatteeServerCredentialsManager.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
//
|
||||
// YatteeServerCredentialsManager.swift
|
||||
// Yattee
|
||||
//
|
||||
// Manages Yattee Server credentials (username/password) stored securely in the Keychain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Credential structure for Yattee Server basic authentication.
|
||||
struct YatteeServerCredential: Codable {
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
/// Manages Yattee Server credentials stored securely in the Keychain.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class YatteeServerCredentialsManager: InstanceCredentialsManager {
|
||||
private let keychainServiceName = "com.yattee.yatteeserver"
|
||||
|
||||
/// Tracks which instances have stored credentials (for reactive UI updates)
|
||||
private(set) var loggedInInstanceIDs: Set<UUID> = []
|
||||
|
||||
/// Reference to settings manager for iCloud sync decisions
|
||||
weak var 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() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Stores credentials for a Yattee Server instance.
|
||||
/// Credentials sync to iCloud Keychain when iCloud sync is enabled for instances.
|
||||
/// - Parameters:
|
||||
/// - username: The username for basic auth
|
||||
/// - password: The password for basic auth
|
||||
/// - instance: The Yattee Server instance
|
||||
func setCredentials(username: String, password: String, for instance: Instance) {
|
||||
let account = instance.id.uuidString
|
||||
let credential = YatteeServerCredential(username: username, password: password)
|
||||
|
||||
guard let data = try? JSONEncoder().encode(credential) else {
|
||||
LoggingService.shared.error("Failed to encode Yattee Server credentials", 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: 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: 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 credentials for Yattee Server 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 credentials for Yattee Server instance",
|
||||
category: .keychain,
|
||||
details: "instanceID=\(instance.id), status=\(status)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves credentials for a Yattee Server instance.
|
||||
/// Searches both synced and non-synced items.
|
||||
/// - Parameter instance: The Yattee Server instance
|
||||
/// - Returns: The credentials if stored, nil otherwise
|
||||
func credentials(for instance: Instance) -> YatteeServerCredential? {
|
||||
let account = instance.id.uuidString
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: 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 credential = try? JSONDecoder().decode(YatteeServerCredential.self, from: data) else {
|
||||
LoggingService.shared.debug(
|
||||
"No credentials found for Yattee Server instance",
|
||||
category: .keychain,
|
||||
details: "instanceID=\(instance.id), status=\(status)"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
LoggingService.shared.debug(
|
||||
"Retrieved credentials for Yattee Server instance",
|
||||
category: .keychain,
|
||||
details: "instanceID=\(instance.id)"
|
||||
)
|
||||
return credential
|
||||
}
|
||||
|
||||
/// Deletes credentials for an instance.
|
||||
/// Deletes both synced and non-synced items.
|
||||
/// - Parameter instance: The Yattee Server instance
|
||||
func deleteCredentials(for instance: Instance) {
|
||||
let account = instance.id.uuidString
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainServiceName,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
|
||||
if status == errSecSuccess || status == errSecItemNotFound {
|
||||
LoggingService.shared.info(
|
||||
"Deleted credentials for Yattee Server instance",
|
||||
category: .keychain,
|
||||
details: "instanceID=\(instance.id)"
|
||||
)
|
||||
} else {
|
||||
LoggingService.shared.error(
|
||||
"Failed to delete credentials for Yattee Server instance",
|
||||
category: .keychain,
|
||||
details: "instanceID=\(instance.id), status=\(status)"
|
||||
)
|
||||
}
|
||||
|
||||
// Update tracked set for reactive UI
|
||||
loggedInInstanceIDs.remove(instance.id)
|
||||
}
|
||||
|
||||
/// Checks if an instance has stored credentials.
|
||||
/// - Parameter instance: The Yattee Server instance
|
||||
/// - Returns: true if credentials exist, false otherwise
|
||||
func hasCredentials(for instance: Instance) -> Bool {
|
||||
// Check tracked set first for performance
|
||||
if loggedInInstanceIDs.contains(instance.id) {
|
||||
return true
|
||||
}
|
||||
// Fall back to Keychain check
|
||||
let hasCreds = credentials(for: instance) != nil
|
||||
if hasCreds {
|
||||
loggedInInstanceIDs.insert(instance.id)
|
||||
}
|
||||
return hasCreds
|
||||
}
|
||||
|
||||
/// 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 credentials(for: instance) != nil {
|
||||
loggedInInstanceIDs.insert(instance.id)
|
||||
} else {
|
||||
loggedInInstanceIDs.remove(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - InstanceCredentialsManager Protocol
|
||||
|
||||
/// Stores a credential for an instance (protocol conformance).
|
||||
/// The credential is expected to be a JSON-encoded YatteeServerCredential.
|
||||
/// - Parameters:
|
||||
/// - credential: JSON string containing {"username": "...", "password": "..."}
|
||||
/// - instance: The instance to associate the credential with
|
||||
func setCredential(_ credential: String, for instance: Instance) {
|
||||
guard let data = credential.data(using: .utf8),
|
||||
let creds = try? JSONDecoder().decode(YatteeServerCredential.self, from: data) else {
|
||||
LoggingService.shared.error(
|
||||
"Failed to decode credential string for Yattee Server",
|
||||
category: .keychain
|
||||
)
|
||||
return
|
||||
}
|
||||
setCredentials(username: creds.username, password: creds.password, for: instance)
|
||||
}
|
||||
|
||||
/// Retrieves the stored credential for an instance (protocol conformance).
|
||||
/// Returns a JSON-encoded string of the username/password.
|
||||
/// - Parameter instance: The instance to retrieve the credential for
|
||||
/// - Returns: JSON string of the credential, or nil if not logged in
|
||||
func credential(for instance: Instance) -> String? {
|
||||
guard let creds = credentials(for: instance),
|
||||
let data = try? JSONEncoder().encode(creds),
|
||||
let jsonString = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return jsonString
|
||||
}
|
||||
|
||||
/// Deletes the stored credential for an instance (protocol conformance).
|
||||
/// - Parameter instance: The instance to log out from
|
||||
func deleteCredential(for instance: Instance) {
|
||||
deleteCredentials(for: instance)
|
||||
}
|
||||
|
||||
/// Checks if an instance has a stored credential (protocol conformance).
|
||||
/// - Parameter instance: The instance to check
|
||||
/// - Returns: true if logged in, false otherwise
|
||||
func isLoggedIn(for instance: Instance) -> Bool {
|
||||
hasCredentials(for: instance)
|
||||
}
|
||||
|
||||
// MARK: - Basic Auth Header Generation
|
||||
|
||||
/// Generates the HTTP Basic Auth header value for an instance.
|
||||
/// - Parameter instance: The Yattee Server instance
|
||||
/// - Returns: The Authorization header value (e.g., "Basic dXNlcjpwYXNz") or nil if no credentials
|
||||
func basicAuthHeader(for instance: Instance) -> String? {
|
||||
guard let creds = credentials(for: instance) else { return nil }
|
||||
let credentials = "\(creds.username):\(creds.password)"
|
||||
guard let data = credentials.data(using: .utf8) else { return nil }
|
||||
return "Basic \(data.base64EncodedString())"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user