mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
On tvOS SwiftData keeps the store in the app group's Library/Caches, which the system may purge. The CKSyncEngine tokens live in UserDefaults and survive, so the next launch opened an empty store and fetched only records changed since the old tokens; subscriptions and bookmarks that had not changed since never came back. DataManager now records whether the store file existed when the container opened, and the sync engine clears its persisted state once per launch when it did not, so the first sync after a loss is a full fetch. Also log the store path and a persistent-store failure that previously fell back to in-memory storage silently.
1458 lines
68 KiB
Swift
1458 lines
68 KiB
Swift
//
|
|
// AppEnvironment.swift
|
|
// Yattee
|
|
//
|
|
// Dependency injection container for the application.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// Main dependency injection container that holds all app services.
|
|
/// Passed through the SwiftUI environment to provide dependencies to views.
|
|
@MainActor
|
|
@Observable
|
|
final class AppEnvironment {
|
|
// MARK: - Services
|
|
|
|
let settingsManager: SettingsManager
|
|
let instancesManager: InstancesManager
|
|
let contentService: ContentService
|
|
let instanceDetector: InstanceDetector
|
|
let dataManager: DataManager
|
|
let subscriptionService: SubscriptionService
|
|
let navigationCoordinator: NavigationCoordinator
|
|
let downloadManager: DownloadManager
|
|
let downloadSettings: DownloadSettings
|
|
let playerService: PlayerService
|
|
let queueManager: QueueManager
|
|
let cloudKitSync: CloudKitSyncEngine
|
|
let deArrowBrandingProvider: DeArrowBrandingProvider
|
|
let notificationManager: NotificationManager
|
|
let backgroundRefreshManager: BackgroundRefreshManager
|
|
let mediaSourcesManager: MediaSourcesManager
|
|
let webDAVClient: WebDAVClient
|
|
let webDAVClientFactory: WebDAVClientFactory
|
|
let smbClient: SMBClient
|
|
let localFileClient: LocalFileClient
|
|
let urlSessionFactory: URLSessionFactory
|
|
let httpClientFactory: HTTPClientFactory
|
|
let localNetworkService: LocalNetworkService
|
|
let remoteControlCoordinator: RemoteControlCoordinator
|
|
let networkShareDiscoveryService: NetworkShareDiscoveryService
|
|
let connectivityMonitor: ConnectivityMonitor
|
|
let httpClient: HTTPClient
|
|
let toastManager: ToastManager
|
|
let handoffManager: HandoffManager
|
|
let invidiousCredentialsManager: InvidiousCredentialsManager
|
|
let pipedCredentialsManager: PipedCredentialsManager
|
|
let basicAuthCredentialsManager: BasicAuthCredentialsManager
|
|
/// Shared by both account credential managers so a single CloudKit lookup
|
|
/// resolves the scope for all of them.
|
|
let credentialIdentityScope: CredentialIdentityScope
|
|
let accountsManager: AccountsManager
|
|
let accountSyncCoordinator: AccountSyncCoordinator
|
|
/// Hands accounts added while signed out to whichever identity signs in.
|
|
let localNamespaceImporter: LocalNamespaceImporter
|
|
let homeInstanceCache: HomeInstanceCache
|
|
let invidiousAPI: InvidiousAPI
|
|
let pipedAPI: PipedAPI
|
|
let credentialProbeService: CredentialProbeService
|
|
let accountService: AccountService
|
|
let remotePlaylistsCache: RemotePlaylistsCache
|
|
let dataContextValidator: DataContextValidator
|
|
let watchHistorySync: WatchHistorySyncService
|
|
let playerControlsLayoutService: PlayerControlsLayoutService
|
|
let legacyMigrationService: LegacyDataMigrationService
|
|
let sourcesSettings: SourcesSettings
|
|
|
|
/// Center-section settings of the active player controls preset, cached for
|
|
/// synchronous access. Menu bar commands read seek durations from here since
|
|
/// they cannot await the layout service actor.
|
|
private(set) var activeControlsCenterSettings: CenterSectionSettings = .default
|
|
|
|
@ObservationIgnored private var controlsSettingsObservers: [NSObjectProtocol] = []
|
|
@ObservationIgnored private var contextCacheObservers: [NSObjectProtocol] = []
|
|
|
|
/// The context the account-scoped caches currently hold data for.
|
|
@ObservationIgnored private var lastCachedContext: DataContext = .local
|
|
|
|
/// The selection behind `lastCachedContext`, kept to tell a move of the
|
|
/// selection apart from a status flap under an unchanged one.
|
|
@ObservationIgnored private var lastSelectedContext: DataContext = .local
|
|
|
|
/// A digest of each account's stored credential, so a replacement arriving
|
|
/// over iCloud Keychain can be told from the same secret being re-read.
|
|
///
|
|
/// Persisted, because the replacement can arrive while the app is not
|
|
/// running: the Apple account and the context are unchanged on the next
|
|
/// launch, so nothing else would notice that the cached feed belongs to
|
|
/// whoever was signed in before.
|
|
@ObservationIgnored private var credentialFingerprints: [UUID: String] = [:]
|
|
|
|
private var credentialFingerprintsKey: String {
|
|
"accountCredentialFingerprints.\(credentialIdentityScope.current ?? "unresolved")"
|
|
}
|
|
|
|
private func loadCredentialFingerprints() {
|
|
credentialFingerprints = (UserDefaults.standard
|
|
.dictionary(forKey: credentialFingerprintsKey) as? [String: String] ?? [:])
|
|
.reduce(into: [:]) { result, entry in
|
|
if let id = UUID(uuidString: entry.key) { result[id] = entry.value }
|
|
}
|
|
}
|
|
|
|
/// Drops the fingerprints recorded while the identity was unresolved.
|
|
///
|
|
/// Nothing legitimate is written under that key — `refreshAccountStatuses`
|
|
/// refuses to read the Keychain until the scope resolves — so anything left
|
|
/// there is a digest of a credential read under a scope the app could not
|
|
/// vouch for, and comparing a real credential against it would report a
|
|
/// replacement that never happened.
|
|
private func purgeUnresolvedCredentialFingerprints() {
|
|
guard credentialIdentityScope.isResolved else { return }
|
|
UserDefaults.standard.removeObject(forKey: "accountCredentialFingerprints.unresolved")
|
|
}
|
|
|
|
private func saveCredentialFingerprints() {
|
|
let stored = credentialFingerprints.reduce(into: [String: String]()) { result, entry in
|
|
result[entry.key.uuidString] = entry.value
|
|
}
|
|
UserDefaults.standard.set(stored, forKey: credentialFingerprintsKey)
|
|
}
|
|
|
|
/// Re-reads the Keychain for a while after an account turns up without one.
|
|
@ObservationIgnored private var credentialArrivalTask: Task<Void, Never>?
|
|
|
|
// MARK: - Shared Instance
|
|
|
|
/// The single, process-wide app environment.
|
|
///
|
|
/// SwiftUI may evaluate a `@State` property's default-value autoclosure more
|
|
/// than once (it keeps only the first result but still runs the side effects
|
|
/// of the discarded instances). Constructing `AppEnvironment` more than once
|
|
/// would create multiple `DownloadManager`s — and therefore multiple
|
|
/// background `URLSession`s registered under the same identifier — causing
|
|
/// download-completion delegate callbacks to be delivered to an instance
|
|
/// whose `activeDownloads` is empty (the finished file is then dropped).
|
|
/// Referencing this `static let` from the App's `@State` guarantees exactly
|
|
/// one instance for the lifetime of the process.
|
|
static let shared = AppEnvironment()
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
httpClient: HTTPClient? = nil,
|
|
settingsManager: SettingsManager? = nil,
|
|
instancesManager: InstancesManager? = nil,
|
|
dataManager: DataManager? = nil,
|
|
navigationCoordinator: NavigationCoordinator? = nil,
|
|
downloadManager: DownloadManager? = nil
|
|
) {
|
|
let client = httpClient ?? HTTPClient()
|
|
self.httpClient = client
|
|
|
|
let settings = settingsManager ?? SettingsManager()
|
|
self.settingsManager = settings
|
|
|
|
// Configure HTTP client with custom User-Agent
|
|
Task {
|
|
await client.setUserAgent(settings.customUserAgent)
|
|
await client.setRandomizeUserAgentPerRequest(settings.randomizeUserAgentPerRequest)
|
|
}
|
|
|
|
// Every per-instance client is minted by a factory rather than shared,
|
|
// and until this is recorded those clients send no User-Agent at all —
|
|
// which instances that whitelist the `Yattee/<version>` prefix answer
|
|
// with a 401 the shared client never earns. Set before anything that
|
|
// builds a factory, `ContentService` included.
|
|
UserAgentConfiguration.shared.update(
|
|
userAgent: settings.customUserAgent,
|
|
randomizePerRequest: settings.randomizeUserAgentPerRequest
|
|
)
|
|
|
|
let instances = instancesManager ?? InstancesManager(settingsManager: settings)
|
|
instances.setSettingsManager(settings)
|
|
self.instancesManager = instances
|
|
|
|
// Initialize Basic Auth Credentials Manager early (needed for ContentService)
|
|
let basicAuthCreds = BasicAuthCredentialsManager()
|
|
basicAuthCreds.settingsManager = settings
|
|
self.basicAuthCredentialsManager = basicAuthCreds
|
|
|
|
let contentSvc = ContentService(httpClient: client, basicAuthCredentialsManager: basicAuthCreds)
|
|
self.contentService = contentSvc
|
|
self.instanceDetector = InstanceDetector(httpClient: client)
|
|
self.navigationCoordinator = navigationCoordinator ?? NavigationCoordinator()
|
|
self.downloadManager = downloadManager ?? DownloadManager()
|
|
self.downloadSettings = DownloadSettings()
|
|
|
|
// Initialize DataManager, falling back to in-memory for failures
|
|
let dm: DataManager
|
|
if let manager = dataManager {
|
|
dm = manager
|
|
} else {
|
|
do {
|
|
dm = try DataManager(iCloudSyncEnabled: settings.iCloudSyncEnabled)
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Persistent store unavailable, falling back to in-memory storage", error: error)
|
|
// Fall back to in-memory storage if persistent storage fails
|
|
dm = try! DataManager(inMemory: true)
|
|
}
|
|
}
|
|
self.dataManager = dm
|
|
|
|
// Resolve which Apple account owns this device's credentials before the
|
|
// managers that key items by it are built. Cached scopes restore
|
|
// synchronously here; a first-ever resolution needs the network and is
|
|
// kicked off below.
|
|
let identityScope = CredentialIdentityScope()
|
|
self.credentialIdentityScope = identityScope
|
|
|
|
// Initialize Invidious Credentials Manager (needed for SubscriptionService)
|
|
let invidiousCreds = InvidiousCredentialsManager(identityScope: identityScope)
|
|
invidiousCreds.settingsManager = settings
|
|
self.invidiousCredentialsManager = invidiousCreds
|
|
|
|
// Initialize Piped Credentials Manager
|
|
let pipedCreds = PipedCredentialsManager(identityScope: identityScope)
|
|
pipedCreds.settingsManager = settings
|
|
self.pipedCredentialsManager = pipedCreds
|
|
|
|
// Accounts share the credential identity namespace: the account list and
|
|
// the credentials it points at must always describe the same Apple account
|
|
let accounts = AccountsManager(identityScope: identityScope, instancesManager: instances)
|
|
self.accountsManager = accounts
|
|
|
|
// Accounts created while signed out live in the `local` namespace, and
|
|
// nothing reads that namespace once an identity resolves. This is what
|
|
// moves them — records, credentials and the chosen context — into the
|
|
// Apple account that signs in.
|
|
self.localNamespaceImporter = LocalNamespaceImporter(
|
|
accountsManager: accounts,
|
|
identityScope: identityScope,
|
|
credentialStores: { [weak accounts, weak invidiousCreds, weak pipedCreds] account in
|
|
// Same resolution as `accountCredentialsManager(for:)`, except
|
|
// that an instance which has not synced yet is not an answer:
|
|
// the scoped key is the account ID either way, so both stores
|
|
// are asked and at most one of them holds anything.
|
|
var stores: [any AccountCredentialsManager] = []
|
|
|
|
guard let type = accounts?.instance(for: account)?.type else {
|
|
if let invidiousCreds { stores.append(invidiousCreds) }
|
|
if let pipedCreds { stores.append(pipedCreds) }
|
|
return stores
|
|
}
|
|
|
|
switch type {
|
|
case .invidious: if let invidiousCreds { stores.append(invidiousCreds) }
|
|
case .piped: if let pipedCreds { stores.append(pipedCreds) }
|
|
// The slot a future account-keyed store lands in, exactly as in
|
|
// `accountCredentialsManager(for:)`.
|
|
default: break
|
|
}
|
|
return stores
|
|
}
|
|
)
|
|
|
|
// Initialize Invidious API (used by SubscriptionService and SubscriptionFeedCache)
|
|
let invidiousAPI = InvidiousAPI(httpClient: client)
|
|
self.invidiousAPI = invidiousAPI
|
|
|
|
// Initialize Piped API (used by SubscriptionService and SubscriptionFeedCache)
|
|
let pipedAPI = PipedAPI(httpClient: client)
|
|
self.pipedAPI = pipedAPI
|
|
|
|
// A 401 or 403 from an ordinary request is never allowed to reject a
|
|
// login on its own — it is re-asked here against an endpoint that
|
|
// exists to be authenticated, and only that answer marks the account.
|
|
let probeService = CredentialProbeService(
|
|
accountsManager: accounts,
|
|
credentialReader: { [weak accounts, weak invidiousCreds, weak pipedCreds] account in
|
|
guard let type = accounts?.instance(for: account)?.type else { return nil }
|
|
switch type {
|
|
case .invidious: return invidiousCreds?.credential(for: account)
|
|
case .piped: return pipedCreds?.credential(for: account)
|
|
default: return nil
|
|
}
|
|
},
|
|
transport: { instance, credential in
|
|
do {
|
|
switch instance.type {
|
|
case .invidious:
|
|
// GET /api/v1/auth/subscriptions — the cheapest call
|
|
// that does nothing except require a session.
|
|
_ = try await invidiousAPI.subscriptions(instance: instance, sid: credential)
|
|
case .piped:
|
|
_ = try await pipedAPI.subscriptions(instance: instance, authToken: credential)
|
|
default:
|
|
return .indeterminate
|
|
}
|
|
return .authenticated
|
|
} catch {
|
|
return CredentialProbeService.classify(error)
|
|
}
|
|
}
|
|
)
|
|
self.credentialProbeService = probeService
|
|
|
|
// Weak: the manager is owned for the life of the app and the probe
|
|
// service holds it back, so a strong capture here would be a cycle.
|
|
accounts.onSuspectedCredentialRejection = { [weak probeService] account in
|
|
probeService?.probeSuspectedRejection(for: account)
|
|
}
|
|
|
|
// Server-side playlists, addressed by account. The same credential
|
|
// lookup the probe service uses — one account, one secret, whichever
|
|
// backend it belongs to — so neither can read a login the other cannot.
|
|
let accountSvc = AccountService(
|
|
accountsManager: accounts,
|
|
credentialReader: { [weak accounts, weak invidiousCreds, weak pipedCreds] account in
|
|
guard let type = accounts?.instance(for: account)?.type else { return nil }
|
|
switch type {
|
|
case .invidious: return invidiousCreds?.credential(for: account)
|
|
case .piped: return pipedCreds?.credential(for: account)
|
|
default: return nil
|
|
}
|
|
},
|
|
transport: contentSvc,
|
|
historyTransport: contentSvc
|
|
)
|
|
self.accountService = accountSvc
|
|
self.remotePlaylistsCache = RemotePlaylistsCache(accountService: accountSvc)
|
|
|
|
// Initialize SubscriptionService with all required dependencies
|
|
self.subscriptionService = SubscriptionService(
|
|
dataManager: dm,
|
|
providerFactory: DefaultSubscriptionProviderFactory(
|
|
invidiousAPI: invidiousAPI,
|
|
pipedAPI: pipedAPI,
|
|
invidiousCredentialsManager: invidiousCreds,
|
|
pipedCredentialsManager: pipedCreds,
|
|
settingsManager: settings
|
|
)
|
|
)
|
|
subscriptionService.setAccountsManager(accounts)
|
|
|
|
// Initialize CloudKit Sync Engine
|
|
let cloudKit = CloudKitSyncEngine(
|
|
dataManager: dm,
|
|
settingsManager: settings,
|
|
instancesManager: instances
|
|
)
|
|
self.cloudKitSync = cloudKit
|
|
dm.cloudKitSync = cloudKit
|
|
|
|
// Accounts sync as CloudKit records rather than through KVS: they need
|
|
// per-record conflict resolution and durable deletions, neither of
|
|
// which a single overwritten key-value document can provide.
|
|
let accountSync = AccountSyncCoordinator(
|
|
accountsManager: accounts,
|
|
identityScope: identityScope,
|
|
isCategoryEnabled: { [weak cloudKit] in cloudKit?.canSyncAccounts ?? false }
|
|
)
|
|
self.accountSyncCoordinator = accountSync
|
|
cloudKit.accountSync = accountSync
|
|
|
|
// Initialize DeArrow with low-priority networking
|
|
let lowPrioritySession = URLSessionFactory.shared.lowPrioritySession()
|
|
let deArrowHTTPClient = HTTPClient(session: lowPrioritySession)
|
|
let deArrowAPI = DeArrowAPI(httpClient: deArrowHTTPClient, urlSession: lowPrioritySession)
|
|
let deArrowProvider = DeArrowBrandingProvider(api: deArrowAPI)
|
|
deArrowProvider.setSettingsManager(settings)
|
|
self.deArrowBrandingProvider = deArrowProvider
|
|
|
|
// Initialize PlayerService
|
|
let downloads = self.downloadManager
|
|
let player = PlayerService(
|
|
httpClient: client,
|
|
contentService: contentSvc,
|
|
dataManager: dm
|
|
)
|
|
player.setInstancesManager(instances)
|
|
player.setSettingsManager(settings)
|
|
player.setDownloadManager(downloads)
|
|
player.setNavigationCoordinator(self.navigationCoordinator)
|
|
player.setDeArrowBrandingProvider(deArrowProvider)
|
|
self.playerService = player
|
|
|
|
// Initialize QueueManager
|
|
let queue = QueueManager(contentService: contentSvc)
|
|
queue.setPlayerState(player.state)
|
|
queue.setPlayerService(player)
|
|
queue.setSettingsManager(settings)
|
|
queue.setInstancesManager(instances)
|
|
queue.setDownloadManager(downloads)
|
|
player.setQueueManager(queue)
|
|
self.queueManager = queue
|
|
|
|
// Initialize Notification & Background Refresh managers
|
|
let notifManager = NotificationManager()
|
|
#if !os(tvOS)
|
|
notifManager.registerNotificationCategories()
|
|
#endif
|
|
self.notificationManager = notifManager
|
|
|
|
let bgRefreshManager = BackgroundRefreshManager(notificationManager: notifManager)
|
|
self.backgroundRefreshManager = bgRefreshManager
|
|
|
|
// Initialize URL Session and Client Factories
|
|
let sessionFactory = URLSessionFactory.shared
|
|
self.urlSessionFactory = sessionFactory
|
|
self.httpClientFactory = HTTPClientFactory(sessionFactory: sessionFactory)
|
|
self.webDAVClientFactory = WebDAVClientFactory(sessionFactory: sessionFactory)
|
|
|
|
// Initialize Media Sources components
|
|
let mediaSources = MediaSourcesManager(settingsManager: settings)
|
|
self.mediaSourcesManager = mediaSources
|
|
mediaSources.setDataManager(dm)
|
|
|
|
// Initialize media clients
|
|
self.webDAVClient = WebDAVClient()
|
|
self.smbClient = SMBClient()
|
|
self.localFileClient = LocalFileClient()
|
|
|
|
// Wire up media services to player
|
|
player.setMediaSourcesManager(mediaSources)
|
|
self.navigationCoordinator.setMediaSourcesManager(mediaSources)
|
|
player.setSMBClient(self.smbClient)
|
|
player.setWebDAVClient(self.webDAVClient)
|
|
player.setLocalFileClient(self.localFileClient)
|
|
|
|
// Wire up SMB client to check if SMB playback is active
|
|
// This prevents crashes from concurrent libsmbclient usage
|
|
let smbClientRef = self.smbClient
|
|
Task { [weak player] in
|
|
await smbClientRef.setPlaybackActiveCallback { [weak player] in
|
|
player?.state.isSMBPlaybackActive ?? false
|
|
}
|
|
}
|
|
|
|
// Initialize Remote Control components
|
|
let networkService = LocalNetworkService()
|
|
self.localNetworkService = networkService
|
|
self.networkShareDiscoveryService = NetworkShareDiscoveryService()
|
|
let remoteControl = RemoteControlCoordinator(networkService: networkService)
|
|
remoteControl.setPlayerService(player)
|
|
remoteControl.setContentService(contentSvc)
|
|
remoteControl.setInstancesManager(instances)
|
|
remoteControl.setNavigationCoordinator(self.navigationCoordinator)
|
|
remoteControl.setMediaSourcesManager(mediaSources)
|
|
remoteControl.setSettingsManager(settings)
|
|
self.remoteControlCoordinator = remoteControl
|
|
|
|
// Restore remote control enabled state (after all services are set up)
|
|
remoteControl.restoreEnabledState()
|
|
|
|
// Initialize Connectivity Monitor for network-aware quality selection
|
|
let connectivity = ConnectivityMonitor()
|
|
self.connectivityMonitor = connectivity
|
|
player.setConnectivityMonitor(connectivity)
|
|
|
|
// Initialize Toast Manager
|
|
let toast = ToastManager()
|
|
self.toastManager = toast
|
|
toast.setNavigationCoordinator(self.navigationCoordinator)
|
|
remoteControl.setToastManager(toast)
|
|
self.downloadManager.setToastManager(toast)
|
|
self.downloadManager.setDownloadSettings(self.downloadSettings)
|
|
|
|
// Initialize Handoff Manager
|
|
let handoff = HandoffManager()
|
|
handoff.setPlayerState(player.state)
|
|
handoff.setSettingsManager(settings)
|
|
self.handoffManager = handoff
|
|
self.navigationCoordinator.setHandoffManager(handoff)
|
|
player.setHandoffManager(handoff)
|
|
|
|
// Initialize Home Instance Cache
|
|
self.homeInstanceCache = .shared
|
|
|
|
// Validates the account-based data context. Corrections are restricted
|
|
// to deletions: a selection that merely cannot be verified yet is kept
|
|
self.dataContextValidator = DataContextValidator(
|
|
accountsManager: accounts,
|
|
instancesManager: instances,
|
|
toastManager: toast
|
|
)
|
|
|
|
// Mirrors what is played here into the active account's server-side
|
|
// history. Reads the *effective* context, so it has to be built after
|
|
// the validator that decides it.
|
|
let watchHistory = WatchHistorySyncService(
|
|
accountsManager: accounts,
|
|
accountService: accountSvc,
|
|
contextValidator: self.dataContextValidator
|
|
)
|
|
self.watchHistorySync = watchHistory
|
|
player.setWatchHistorySyncService(watchHistory)
|
|
|
|
// Initialize Player Controls Layout Service
|
|
let layoutService = PlayerControlsLayoutService()
|
|
self.playerControlsLayoutService = layoutService
|
|
|
|
// Initialize Legacy Migration Service
|
|
self.legacyMigrationService = LegacyDataMigrationService(
|
|
instancesManager: instances,
|
|
basicAuthCredentialsManager: basicAuthCreds,
|
|
invidiousCredentialsManager: invidiousCreds,
|
|
pipedCredentialsManager: pipedCreds,
|
|
invidiousAPI: invidiousAPI,
|
|
pipedAPI: pipedAPI
|
|
)
|
|
|
|
// Initialize Sources Settings
|
|
self.sourcesSettings = SourcesSettings()
|
|
|
|
// Wire up CloudKit sync to player controls layout service (bidirectional)
|
|
cloudKit.playerControlsLayoutService = layoutService
|
|
Task {
|
|
await layoutService.setCloudKitSync(cloudKit)
|
|
}
|
|
|
|
// Wire up player controls layout service to player service (for preset-based settings)
|
|
player.setPlayerControlsLayoutService(layoutService)
|
|
|
|
// Cache active preset's center settings and keep them in sync
|
|
Task { await self.refreshActiveControlsSettings() }
|
|
for name: Notification.Name in [.playerControlsActivePresetDidChange, .playerControlsPresetsDidChange] {
|
|
controlsSettingsObservers.append(
|
|
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
Task { @MainActor in
|
|
await self?.refreshActiveControlsSettings()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// Set up circular dependencies after all properties are initialized
|
|
bgRefreshManager.setAppEnvironment(self)
|
|
|
|
// Backfill credential flags for instances created before the flags
|
|
// existed, so existing setups publish them to iCloud without a re-login
|
|
backfillCredentialFlags()
|
|
|
|
dataContextValidator.setAppEnvironment(self)
|
|
subscriptionService.setContextValidator(dataContextValidator)
|
|
|
|
// The context can now change without anyone touching this device — a
|
|
// choice made on another one, or an account deleted there. The feed
|
|
// cache is global rather than account-keyed, so it would otherwise keep
|
|
// serving the previous account's videos.
|
|
//
|
|
// A deletion that arrives from another device takes the credential with
|
|
// it. Only a deletion does this — an account merely missing from a
|
|
// fetch keeps everything, because a fetch is not an enumeration.
|
|
// A context repair reads statuses and commits — there is no second
|
|
// chance at it once the selection no longer names the deleted account —
|
|
// and the deletion that triggers one can arrive at any moment: a silent
|
|
// push handled in the background, long after anything last recomputed
|
|
// them. Foreground activation sequences the refresh ahead of the repair
|
|
// for exactly this reason; wiring it into the repair itself closes every
|
|
// other entry point too, instead of each fetch trigger having to
|
|
// remember the order.
|
|
accounts.refreshStatusesForRepair = { [weak self] in
|
|
self?.refreshAccountStatuses()
|
|
}
|
|
|
|
// Every activation of an identity namespace sweeps the signed-out one.
|
|
// A hook rather than another observer of the scope change: the sweep
|
|
// must run after the manager has reloaded, and observer registration
|
|
// order is not something to depend on for that. Statuses are recomputed
|
|
// only when something moved — a credential that just arrived under this
|
|
// identity is what decides whether its account reads as signed in.
|
|
accounts.onNamespaceActivated = { [weak self] in
|
|
guard let self, self.localNamespaceImporter.sweepIfNeeded() else { return }
|
|
self.refreshAccountStatuses()
|
|
}
|
|
|
|
accountSync.onAccountDeleted = { [weak self] accountID, account in
|
|
guard let self else { return }
|
|
|
|
// The backend is unknown whenever the account itself never arrived
|
|
// here, and equally when its instance is already gone — an instance
|
|
// deletion can reach this device before the tombstone does. The
|
|
// scoped key is the account ID either way, so both managers are
|
|
// asked to drop it; at most one of them holds anything. The
|
|
// unscoped legacy item is left alone in that case: deleting it
|
|
// needs the instance, and its ownership check is bound to one.
|
|
// The provider holds this account's subscriptions in memory and may
|
|
// have a fetch in flight; without dropping it, the deleted
|
|
// account's channels can still arrive and be shown. Its playlists
|
|
// are held the same way, and go the same way.
|
|
self.discardAccountData(for: accountID)
|
|
|
|
guard let account, let credentials = self.accountCredentialsManager(for: account) else {
|
|
self.invidiousCredentialsManager.deleteCredential(forAccountID: accountID)
|
|
self.pipedCredentialsManager.deleteCredential(forAccountID: accountID)
|
|
|
|
// The unscoped legacy item needs the instance to be identified,
|
|
// but not to be *checked*: the adoption marker is specific to
|
|
// service, account, scope and the exact secret, so asking both
|
|
// managers retires it if either owns it and does nothing
|
|
// otherwise. Worth asking — this account was explicitly deleted,
|
|
// and the instance may simply have gone first.
|
|
if let account {
|
|
self.invidiousCredentialsManager.deleteLegacyCredentialIfOwned(for: account)
|
|
self.pipedCredentialsManager.deleteLegacyCredentialIfOwned(for: account)
|
|
}
|
|
return
|
|
}
|
|
|
|
credentials.deleteCredential(for: account)
|
|
credentials.deleteLegacyCredentialIfOwned(for: account)
|
|
}
|
|
|
|
// Statuses are cleared whenever the identity namespace reloads, so they
|
|
// have to be recomputed once the new scope's Keychain is readable —
|
|
// otherwise a synced account shows "sign in required" until relaunch.
|
|
NotificationCenter.default.addObserver(
|
|
forName: .credentialIdentityScopeDidChange,
|
|
object: identityScope,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
// Deferred rather than run inline: AccountsManager observes the same
|
|
// notification to reload its namespace and clear statuses, and
|
|
// relying on observer registration order to land after it would be
|
|
// silently fragile.
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
// Fingerprints are recorded per identity, like everything else
|
|
// keyed to one.
|
|
self.loadCredentialFingerprints()
|
|
self.purgeUnresolvedCredentialFingerprints()
|
|
self.refreshAccountStatuses()
|
|
|
|
// Compared against the recorded owner rather than cleared
|
|
// outright: a scope that merely dropped to unresolved and came
|
|
// back is the same Apple account, and with iCloud Drive off
|
|
// that happens on every activation.
|
|
self.discardCachesFromAnotherIdentityIfNeeded()
|
|
self.refreshCachesForEffectiveContext()
|
|
}
|
|
}
|
|
|
|
loadCredentialFingerprints()
|
|
purgeUnresolvedCredentialFingerprints()
|
|
discardCachesFromAnotherIdentityIfNeeded(atLaunch: true)
|
|
|
|
// Turn any pre-revamp single-instance logins into accounts. Repeatable:
|
|
// it runs again whenever instances arrive from iCloud.
|
|
accounts.reconcileLegacyAccounts()
|
|
accounts.migrateLegacyContextIfNeeded()
|
|
|
|
// A launch that restores its identity scope from cache resolves inside
|
|
// the initializer above, so no scope change will ever be announced for
|
|
// it and `onNamespaceActivated` never fires. Invoked once here so that
|
|
// path sweeps too; the sweep is idempotent, so an identity that does
|
|
// arrive later simply finds nothing left.
|
|
localNamespaceImporter.sweepIfNeeded()
|
|
|
|
refreshAccountStatuses()
|
|
|
|
// Seeded once the statuses it depends on are known, so launching does
|
|
// not look like a change and throw away caches that are still right.
|
|
lastCachedContext = dataContextValidator.effectiveContext
|
|
lastSelectedContext = accountsManager.currentContext
|
|
|
|
// Accounts and instances arriving are triggers too, not redundant ones:
|
|
// a context naming an account that has not synced yet — or one whose
|
|
// instance has not — reads as unavailable, and these arrivals are what
|
|
// move the effective context back onto it. They are also what make a
|
|
// synced account's credential findable, so statuses are recomputed with
|
|
// them: an account that appears mid-session would otherwise sit at
|
|
// "sign in required" until the app was relaunched.
|
|
// A status change is deliberately not one of the refresh triggers: the
|
|
// refresher reads the Keychain, which cannot know what a server said,
|
|
// so reacting to a status by recomputing it would erase every verdict
|
|
// the API layer records. It still re-examines the caches, because a
|
|
// credential arriving or being rejected moves the effective context.
|
|
for name: Notification.Name in [
|
|
.dataContextDidChange, .accountsDidChange, .instancesDidChange,
|
|
.accountStatusesDidChange, .connectivityDidReturn
|
|
] {
|
|
contextCacheObservers.append(
|
|
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
guard let self else { return }
|
|
if name != .dataContextDidChange, name != .accountStatusesDidChange {
|
|
self.refreshAccountStatuses()
|
|
}
|
|
self.refreshCachesForEffectiveContext()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// Resolve the credential identity scope if this Apple account has never
|
|
// been seen on this device. Until it lands, account credentials report
|
|
// "can't tell yet" rather than "signed out".
|
|
Task {
|
|
let scopeBefore = identityScope.current
|
|
await identityScope.resolve()
|
|
|
|
// Accounts are stored per identity, so a scope that only just
|
|
// resolved (or moved) means the in-memory list is the wrong
|
|
// namespace's.
|
|
if identityScope.current != scopeBefore {
|
|
accounts.reloadForCurrentIdentity()
|
|
}
|
|
self.refreshAccountStatuses()
|
|
}
|
|
|
|
// Log device capabilities on startup for debugging
|
|
HardwareCapabilities.shared.logCapabilities()
|
|
|
|
// Clean up any leftover subtitle temp files from previous sessions
|
|
cleanupAllTempSubtitles()
|
|
|
|
// Run orphan diagnostics on startup to help debug storage issues
|
|
#if !os(tvOS)
|
|
Task {
|
|
self.downloadManager.logOrphanDiagnostics()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Cleans up all temporary subtitle files from previous sessions.
|
|
/// Call this on app launch to ensure temp directory doesn't accumulate old files.
|
|
private func cleanupAllTempSubtitles() {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("yattee-subtitles", isDirectory: true)
|
|
|
|
do {
|
|
if FileManager.default.fileExists(atPath: tempDir.path) {
|
|
try FileManager.default.removeItem(at: tempDir)
|
|
LoggingService.shared.debug("Cleaned up all temp subtitle files on launch", category: .general)
|
|
}
|
|
} catch {
|
|
// Log but don't fail - this is just cleanup
|
|
LoggingService.shared.debug(
|
|
"Failed to clean up temp subtitles on launch: \(error.localizedDescription)",
|
|
category: .general
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Configuration
|
|
|
|
/// Refreshes the cached center-section settings from the active player controls preset.
|
|
func refreshActiveControlsSettings() async {
|
|
let layout = await playerControlsLayoutService.activeLayout()
|
|
activeControlsCenterSettings = layout.centerSettings
|
|
// Warm the layout caches so the expanded player resolves the correct
|
|
// layout (corner mode, panel settings) on its very first frame.
|
|
PlayerControlsLayout.cached = layout
|
|
GlobalLayoutSettings.cached = layout.globalSettings
|
|
MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings
|
|
PanelSettings.cached = layout.effectivePanelSettings
|
|
}
|
|
|
|
/// Updates the HTTP client's User-Agent configuration from current settings.
|
|
/// Call this after changing User-Agent related settings.
|
|
func updateUserAgent() {
|
|
let userAgent = settingsManager.customUserAgent
|
|
let randomizePerRequest = settingsManager.randomizeUserAgentPerRequest
|
|
// Clients built from here on; the ones already handed out are updated
|
|
// through their own setters, and only the shared one is long-lived.
|
|
UserAgentConfiguration.shared.update(userAgent: userAgent, randomizePerRequest: randomizePerRequest)
|
|
Task {
|
|
await httpClient.setUserAgent(userAgent)
|
|
await httpClient.setRandomizeUserAgentPerRequest(randomizePerRequest)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sharing
|
|
|
|
/// URL for sharing a video, honoring the privacy-frontend sharing setting.
|
|
///
|
|
/// When enabled, YouTube videos are shared as links to the active
|
|
/// instance's web watch page. Videos the active instance doesn't serve
|
|
/// (PeerTube, extracted sources) and instances without a web frontend
|
|
/// keep the video's original share URL.
|
|
func shareURL(for video: Video) -> URL {
|
|
guard settingsManager.privacyFrontendSharingEnabled,
|
|
case .global(let provider) = video.id.source,
|
|
provider == ContentSource.youtubeProvider,
|
|
let base = instancesManager.activeInstance?.watchPageBaseURL,
|
|
var components = URLComponents(url: base, resolvingAgainstBaseURL: false)
|
|
else {
|
|
return video.shareURL
|
|
}
|
|
components.path += "/watch"
|
|
components.queryItems = [URLQueryItem(name: "v", value: video.id.videoID)]
|
|
return components.url ?? video.shareURL
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
/// Ensures the notification infrastructure is enabled (system permission + master toggle + background refresh).
|
|
/// Call this before enabling per-channel notifications.
|
|
/// - Returns: `true` if notifications are fully enabled, `false` if the user denied permission.
|
|
func ensureNotificationsEnabled() async -> Bool {
|
|
if settingsManager.backgroundNotificationsEnabled {
|
|
return true
|
|
}
|
|
|
|
let granted = await notificationManager.requestAuthorization()
|
|
if granted {
|
|
settingsManager.backgroundNotificationsEnabled = true
|
|
backgroundRefreshManager.handleNotificationsEnabledChanged(true)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// MARK: - Accounts
|
|
|
|
/// Switches the data context, from wherever in the app the user asked.
|
|
///
|
|
/// The one entry point, so every switcher behaves identically: the cache
|
|
/// the feed is served from is global rather than account-keyed, and a
|
|
/// switch that skipped this would leave the previous account's videos on
|
|
/// screen under the new account's name.
|
|
func switchDataContext(to context: DataContext) {
|
|
guard context != accountsManager.currentContext else { return }
|
|
|
|
accountsManager.setContext(context)
|
|
SubscriptionFeedCache.shared.handleAccountChange()
|
|
|
|
LoggingService.shared.info(
|
|
"Data context switched by the user",
|
|
category: .general,
|
|
details: "context=\(context)"
|
|
)
|
|
}
|
|
|
|
/// Drops the account-scoped caches when the context whose data they hold
|
|
/// has moved.
|
|
///
|
|
/// Covers the moves nobody on this device made — a selection synced from
|
|
/// another device, an account deleted there, a credential arriving or being
|
|
/// rejected. The switcher calls `switchDataContext(to:)` instead, which
|
|
/// clears the feed itself rather than waiting for a notification to make
|
|
/// the round trip.
|
|
private func refreshCachesForEffectiveContext() {
|
|
// Cached data is invalidated on the *effective* context — the one whose
|
|
// data is actually on screen. Not on the legacy value, because two
|
|
// logins on the same instance collapse onto the same one; and not on
|
|
// the stored context, because that does not move when a credential
|
|
// arrives or disappears and the app falls back to local and back again.
|
|
// Never while the identity is unresolved. With iCloud Drive off the
|
|
// scope drops to unresolved on every activation and comes back
|
|
// unchanged; the context reads as local in between, and treating that
|
|
// as a move would throw the caches away several times a day. "Cannot
|
|
// tell yet" is not grounds for anything destructive.
|
|
let effective = dataContextValidator.effectiveContext
|
|
guard credentialIdentityScope.isResolved else { return }
|
|
|
|
let selected = accountsManager.currentContext
|
|
if effective != lastCachedContext {
|
|
lastCachedContext = effective
|
|
if selected == lastSelectedContext {
|
|
// The selection is where it was; only the *reads* moved — a
|
|
// status flap, an unreachable fallback, a credential arriving.
|
|
// The data on screen still belongs to the same choice of
|
|
// account, so it is fenced rather than thrown away: an outage
|
|
// that flips the status every minute must not leave the user
|
|
// staring at an empty feed they had cached seconds ago.
|
|
SubscriptionFeedCache.shared.handleEffectiveContextChange()
|
|
HomeInstanceCache.shared.clearAllFeeds()
|
|
remotePlaylistsCache.clearAll()
|
|
} else {
|
|
clearAccountScopedCaches()
|
|
}
|
|
}
|
|
lastSelectedContext = selected
|
|
}
|
|
|
|
/// Discards everything cached under an account's previous credential.
|
|
///
|
|
/// Signing in again keeps the account ID and the selected context, so
|
|
/// nothing about the *context* changes — but the login behind it may be a
|
|
/// different person entirely, and their subscriptions and feed would
|
|
/// otherwise stay on screen.
|
|
func accountCredentialDidChange(for account: Account) {
|
|
invalidateCaches(for: account)
|
|
refreshAccountStatuses()
|
|
}
|
|
|
|
/// Drops everything cached under an account's previous credential.
|
|
private func invalidateCaches(for account: Account) {
|
|
discardAccountData(for: account.id)
|
|
HomeInstanceCache.shared.clear(instanceID: account.instanceID, contentType: .feed)
|
|
|
|
// Reached by both ways a credential is replaced — the user signing in
|
|
// again here, and a replacement arriving over iCloud Keychain that the
|
|
// fingerprint digest spots — so the probe service's per-account dedup
|
|
// is retired here rather than at either call site. A verdict earned by
|
|
// the old secret must never delay asking about the new one.
|
|
credentialProbeService.credentialDidChange(for: account.id)
|
|
|
|
if accountsManager.currentContext == .account(account.id) {
|
|
SubscriptionFeedCache.shared.handleAccountChange()
|
|
}
|
|
|
|
// Views hold their own copies of this account's data, and signing in
|
|
// again as somebody else changes neither the account ID, the context,
|
|
// nor usually the status — so nothing they observe would move. Told
|
|
// explicitly instead.
|
|
NotificationCenter.default.post(name: .accountCredentialDidChange, object: nil)
|
|
}
|
|
|
|
/// Drops the in-memory data an account left behind, and disowns whatever is
|
|
/// still being fetched for it.
|
|
///
|
|
/// One call for every way an account stops being usable — signed out,
|
|
/// deleted here, deleted on another device, or its instance removed — so a
|
|
/// new place that has to be cleared is added once rather than four times.
|
|
func discardAccountData(for accountID: UUID) {
|
|
subscriptionService.discardProvider(for: accountID)
|
|
remotePlaylistsCache.clear(accountID: accountID)
|
|
watchHistorySync.cancelPending(for: accountID)
|
|
}
|
|
|
|
/// Drops everything cached for whichever account was being shown.
|
|
///
|
|
/// Neither cache is keyed by account: the subscriptions feed is a single
|
|
/// global list, and home feeds are keyed by instance alone. Both would
|
|
/// otherwise keep serving one login's private videos to another.
|
|
private func clearAccountScopedCaches() {
|
|
SubscriptionFeedCache.shared.handleAccountChange()
|
|
HomeInstanceCache.shared.clearAllFeeds()
|
|
// Keyed by account, so nothing here is shared between two logins — but
|
|
// an Apple account switch replaces the whole account list, and results
|
|
// still in flight for the previous one have to be disowned.
|
|
remotePlaylistsCache.clearAll()
|
|
}
|
|
|
|
private static let cacheOwnerKey = "accountScopedCacheOwner"
|
|
|
|
/// Discards cached private data left behind by a different Apple account.
|
|
///
|
|
/// The identity-change notification cannot cover this: a scope this device
|
|
/// has seen before resolves synchronously while `CredentialIdentityScope` is
|
|
/// being constructed, which is before any observer here exists. So an Apple
|
|
/// account switched while the app was closed would otherwise reach the new
|
|
/// user with the previous one's feeds still on disk.
|
|
/// - Parameter atLaunch: whether this is the startup check. It decides what
|
|
/// an *unresolved* identity means. At launch it means the cached data's
|
|
/// owner cannot be confirmed at all — which is exactly the state a switch
|
|
/// to an unseen Apple account while offline produces — so the safe answer
|
|
/// is to discard. Mid-session it is a transient: with iCloud Drive off the
|
|
/// scope drops to unresolved and re-resolves on every activation, and
|
|
/// clearing on that would throw the caches away several times a day.
|
|
private func discardCachesFromAnotherIdentityIfNeeded(atLaunch: Bool = false) {
|
|
let defaults = UserDefaults.standard
|
|
|
|
guard let owner = credentialIdentityScope.current else {
|
|
// No recorded owner is not the same as "nothing to protect": the
|
|
// first launch after upgrading finds caches written before anyone
|
|
// recorded whose they are, which is exactly the case that must not
|
|
// be adopted.
|
|
guard atLaunch else { return }
|
|
|
|
clearAccountScopedCaches()
|
|
defaults.removeObject(forKey: Self.cacheOwnerKey)
|
|
LoggingService.shared.info(
|
|
"Cannot confirm who the cached account data belongs to — discarded it",
|
|
category: .general
|
|
)
|
|
return
|
|
}
|
|
|
|
let previous = defaults.string(forKey: Self.cacheOwnerKey)
|
|
defaults.set(owner, forKey: Self.cacheOwnerKey)
|
|
|
|
guard previous != owner else { return }
|
|
|
|
// A missing owner is not the same as "ours". The first launch after
|
|
// upgrading finds caches written before anyone recorded who they belong
|
|
// to, and the Apple account may have changed since. Adopting them would
|
|
// be a guess with someone's private feed as the stake.
|
|
clearAccountScopedCaches()
|
|
LoggingService.shared.info(
|
|
previous == nil
|
|
? "Cached account data predates knowing who it belongs to — discarded it"
|
|
: "Apple account changed — discarded the previous account's cached data",
|
|
category: .general
|
|
)
|
|
}
|
|
|
|
/// Deletes an instance together with the accounts that live on it.
|
|
///
|
|
/// The cascade has to happen here, while the instance is still resolvable:
|
|
/// once it is gone, an account's credential manager cannot be determined
|
|
/// from its type, and the credential would be stranded with no way to reach
|
|
/// it. Only an explicit deletion cascades — an instance disappearing from
|
|
/// iCloud leaves its accounts orphaned rather than deleted, because absence
|
|
/// is not a deletion.
|
|
func removeInstance(_ instance: Instance) {
|
|
// Recorded first: the accounts on this instance may not have arrived
|
|
// yet, and one turning up afterwards has to be deleted rather than
|
|
// left as an orphan.
|
|
accountsManager.recordInstanceDeletion(instance.id)
|
|
|
|
for account in accountsManager.accounts(forInstance: instance.id) {
|
|
// Resolved while the instance is still registered — it is removed
|
|
// below — and the account's own deletion recorded before its secret
|
|
// is touched, so a crash in between cannot leave a signed-out
|
|
// account behind with no deletion owed to anybody.
|
|
let credentials = accountCredentialsManager(for: account)
|
|
accountsManager.removeAccount(account.id)
|
|
credentials?.deleteCredential(for: account)
|
|
credentials?.deleteLegacyCredentialIfOwned(for: account)
|
|
discardAccountData(for: account.id)
|
|
}
|
|
|
|
instancesManager.remove(instance)
|
|
}
|
|
|
|
/// Signs an account out and removes it, with everything keyed to it.
|
|
///
|
|
/// The one cascade behind every by-hand sign-out, so a place that has to be
|
|
/// cleared is added here rather than in each screen that lists accounts.
|
|
func signOutAndRemoveAccount(_ account: Account) {
|
|
let instance = accountsManager.instance(for: account)
|
|
// Resolved before the account is removed, though nothing about the
|
|
// lookup needs it to still be there: the store is chosen by the
|
|
// instance's type, and the instance is not going anywhere here.
|
|
let credentials = accountCredentialsManager(for: account)
|
|
|
|
// The tombstone and its outbox entry go first. A crash after the
|
|
// credential was deleted but before the deletion was recorded would
|
|
// otherwise leave the account standing on the next launch — signed out,
|
|
// and with nothing owed to the devices that still have it.
|
|
accountsManager.removeAccount(account.id)
|
|
|
|
// Deleting the account is what authorizes removing the legacy item, and
|
|
// only when this device adopted it and it still holds that same secret.
|
|
credentials?.deleteCredential(for: account)
|
|
credentials?.deleteLegacyCredentialIfOwned(for: account)
|
|
// Drop what the account had in memory too, or its subscriptions and
|
|
// playlists would keep answering for an account that no longer exists.
|
|
discardAccountData(for: account.id)
|
|
|
|
// Lowering the pre-revamp flag once nothing is left signed in on the
|
|
// instance. Migration-only state (see `Instance.usesAccountLogin`):
|
|
// left raised, it is what `needsCredentials(for:)` would keep reporting
|
|
// a missing credential from.
|
|
if let instance, accountsManager.accounts(forInstance: instance.id).isEmpty {
|
|
instancesManager.setUsesAccountLogin(false, for: instance)
|
|
}
|
|
}
|
|
|
|
/// Removes an account whose instance is gone.
|
|
///
|
|
/// Separate from `signOutAndRemoveAccount(_:)` because none of the lookups
|
|
/// that cascade depends on can resolve without an instance.
|
|
func removeOrphanedAccount(_ accountID: UUID) {
|
|
AccountRemoval.removeOrphan(
|
|
accountID,
|
|
stores: [invidiousCredentialsManager, pipedCredentialsManager],
|
|
accountsManager: accountsManager,
|
|
discardData: { discardAccountData(for: $0) }
|
|
)
|
|
}
|
|
|
|
/// The account-keyed credentials manager for an account's instance.
|
|
/// `nil` when the instance is unknown or its type has no account concept.
|
|
func accountCredentialsManager(for account: Account) -> (any AccountCredentialsManager)? {
|
|
guard let instance = accountsManager.instance(for: account) else { return nil }
|
|
switch instance.type {
|
|
case .invidious: return invidiousCredentialsManager
|
|
case .piped: return pipedCredentialsManager
|
|
// The slot a future account-keyed store lands in. What a backend's
|
|
// accounts can do is `InstanceType.accountCapabilities`; which store
|
|
// holds their secrets is this switch, and the two move together.
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
/// The account whose credential should be used when talking to an instance.
|
|
///
|
|
/// Prefers the account the user has selected as their context; otherwise any
|
|
/// signed-in account on that instance. Call sites that think in instances
|
|
/// rather than accounts — imports, feed fetches, browse — go through here
|
|
/// instead of the pre-revamp instance-keyed Keychain lookup, which a new
|
|
/// login no longer writes.
|
|
func account(forInstance instance: Instance) -> Account? {
|
|
// The selected account is preferred only when it can actually answer.
|
|
// Returning a credential-less one would report the whole instance as
|
|
// signed out while a sibling login sits right there, usable.
|
|
if case let .account(id) = accountsManager.currentContext,
|
|
let account = accountsManager.account(id: id),
|
|
account.instanceID == instance.id,
|
|
accountCredentialsManager(for: account)?.isLoggedIn(for: account) == true {
|
|
return account
|
|
}
|
|
|
|
return accountsManager.accounts(forInstance: instance.id).first {
|
|
accountCredentialsManager(for: $0)?.isLoggedIn(for: $0) == true
|
|
}
|
|
}
|
|
|
|
/// Which feed implementation a context needs.
|
|
///
|
|
/// `.local` covers both the local data set and any account whose instance
|
|
/// or type cannot supply a feed — those fall back rather than failing.
|
|
enum FeedBackend { case local, invidious, piped }
|
|
|
|
func feedBackend(for context: DataContext) -> FeedBackend {
|
|
guard case let .account(id) = context,
|
|
let account = accountsManager.account(id: id),
|
|
let instance = accountsManager.instance(for: account)
|
|
else { return .local }
|
|
|
|
switch instance.type {
|
|
case .invidious: return .invidious
|
|
case .piped: return .piped
|
|
default: return .local
|
|
}
|
|
}
|
|
|
|
/// The selected context's account, when it lives on an instance of the
|
|
/// given type. Used by paths that still need to know which backend they are
|
|
/// talking to before they can build a request.
|
|
func account(forSelectedContext type: InstanceType) -> Account? {
|
|
guard case let .account(id) = accountsManager.currentContext,
|
|
let account = accountsManager.account(id: id),
|
|
accountsManager.instance(for: account)?.type == type
|
|
else { return nil }
|
|
return account
|
|
}
|
|
|
|
/// The stored credential for an instance, resolved through its accounts.
|
|
func credential(forInstance instance: Instance) -> String? {
|
|
guard let account = account(forInstance: instance) else { return nil }
|
|
return accountCredentialsManager(for: account)?.credential(for: account)
|
|
}
|
|
|
|
/// Whether any account on this instance is signed in.
|
|
func isSignedIn(to instance: Instance) -> Bool {
|
|
credential(forInstance: instance) != nil
|
|
}
|
|
|
|
/// Recomputes each account's status from the Keychain.
|
|
///
|
|
/// Only ever reports `credentialMissing` versus `signedIn` — a rejected
|
|
/// credential is something only the server can tell us, so that state is set
|
|
/// by the API layer when a request actually fails.
|
|
/// - Parameter preservingUnreachable: leaves an account the server failed to
|
|
/// answer for alone. Only the credential-arrival poll passes this: it runs
|
|
/// every 15 seconds on behalf of a *different* account, and lifting the
|
|
/// outage fallback as a side effect of that would retry a down server four
|
|
/// times a minute instead of once, for as long as anything was waiting.
|
|
/// Everything else keeps the default, so a real signal — connectivity
|
|
/// returning, an account list changing — still lifts it at once.
|
|
func refreshAccountStatuses(preservingUnreachable: Bool = false) {
|
|
var didChangeFingerprints = false
|
|
defer { if didChangeFingerprints { saveCredentialFingerprints() } }
|
|
|
|
for account in accountsManager.accounts {
|
|
guard let credentials = accountCredentialsManager(for: account) else { continue }
|
|
|
|
// An unresolved identity scope means "cannot tell yet", not
|
|
// "signed out" — leave any known status alone rather than
|
|
// downgrading a working account to sign-in-required.
|
|
guard credentials.isScopeResolved else { continue }
|
|
|
|
credentials.refreshLoginStatus(for: account)
|
|
|
|
// An account migrated from a pre-revamp login has its credential
|
|
// under the old unscoped key. Move it across before concluding
|
|
// anything: the upgrade is not something to ask the user about —
|
|
// the previous release already served that item to whoever held the
|
|
// device, and the copy made here stays on it.
|
|
if !credentials.isLoggedIn(for: account),
|
|
credentials.hasAdoptableLegacyCredential(for: account),
|
|
credentials.adoptLegacyCredential(for: account) {
|
|
credentials.refreshLoginStatus(for: account)
|
|
}
|
|
|
|
guard credentials.isLoggedIn(for: account) else {
|
|
accountsManager.setStatus(.credentialMissing, for: account.id)
|
|
continue
|
|
}
|
|
|
|
// A credential the server rejected is still a credential: the
|
|
// Keychain cannot tell that it stopped working, so this must not
|
|
// report "signed in" over it. What it *can* tell is whether the
|
|
// credential is still the same one — signing in again on another
|
|
// device replaces it under the same key — and a rejection only
|
|
// applies to the secret that earned it.
|
|
//
|
|
// `unreachable` is normally not preserved here. It is transient, and
|
|
// it stops reads from reaching the server at all, so keeping it
|
|
// would strand the account: nothing would call out to discover that
|
|
// the server came back. The credential-arrival poll is the one
|
|
// caller that has to leave it standing — see the parameter.
|
|
// The secret itself is watched, not just the account: iCloud
|
|
// Keychain can replace it under the same key when the user signs in
|
|
// again elsewhere. The account ID, the context and the status all
|
|
// stay put, so nothing else would notice that the data on screen
|
|
// now belongs to a different backend user.
|
|
let fingerprint = credentials.credential(for: account)
|
|
.map { CloudKitIdentity.shortDigest(of: Data($0.utf8)) }
|
|
let previousFingerprint = credentialFingerprints[account.id]
|
|
let didReplaceCredential = previousFingerprint != nil && previousFingerprint != fingerprint
|
|
|
|
// Written only when it actually moved: the arrival poll comes
|
|
// through here every 15 seconds, and a secret that has not changed
|
|
// should not cost a defaults write each time.
|
|
if previousFingerprint != fingerprint {
|
|
credentialFingerprints[account.id] = fingerprint
|
|
didChangeFingerprints = true
|
|
}
|
|
|
|
if didReplaceCredential {
|
|
LoggingService.shared.info(
|
|
"The stored credential was replaced — discarding what it had cached",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id)"
|
|
)
|
|
invalidateCaches(for: account)
|
|
}
|
|
|
|
// A rejection belongs to the credential that earned it. A
|
|
// replacement has not been rejected by anyone.
|
|
let status = accountsManager.status(for: account.id)
|
|
if Self.statusOutlivesKeychainRefresh(
|
|
status,
|
|
credentialWasReplaced: didReplaceCredential,
|
|
preservingUnreachable: preservingUnreachable
|
|
) {
|
|
continue
|
|
}
|
|
|
|
if status == .credentialRejected {
|
|
LoggingService.shared.info(
|
|
"The rejected login was replaced — letting the account try again",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id)"
|
|
)
|
|
}
|
|
|
|
accountsManager.setStatus(.signedIn, for: account.id)
|
|
}
|
|
|
|
waitForArrivingCredentialsIfNeeded()
|
|
}
|
|
|
|
/// Whether a status the Keychain has nothing to say about should stand.
|
|
///
|
|
/// Both verdicts this preserves came from the server, and neither can be
|
|
/// re-derived from a stored secret — but a secret that was *replaced* voids
|
|
/// both of them, because whatever the server said, it said it about the
|
|
/// credential that is no longer there.
|
|
static func statusOutlivesKeychainRefresh(
|
|
_ status: AccountStatus,
|
|
credentialWasReplaced: Bool,
|
|
preservingUnreachable: Bool
|
|
) -> Bool {
|
|
guard !credentialWasReplaced else { return false }
|
|
|
|
switch status {
|
|
case .credentialRejected:
|
|
return true
|
|
case .unreachable:
|
|
return preservingUnreachable
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Re-reads the Keychain for a couple of minutes while any account is
|
|
/// waiting on one.
|
|
///
|
|
/// iCloud Keychain delivers on its own schedule and announces nothing when
|
|
/// it does, so an account that syncs ahead of its credential — the ordinary
|
|
/// case on a new device — would sit at "sign in required", with reads
|
|
/// falling back to local, until something unrelated happened to recompute
|
|
/// statuses.
|
|
///
|
|
/// A rejected account is waiting on the very same delivery: the user fixes
|
|
/// the login on one device, and this one goes on holding a secret the
|
|
/// server has already refused until the replacement lands. Nobody sitting
|
|
/// in front of that screen can hurry it along, and without this the account
|
|
/// stays red until something unrelated recomputes — which is how a
|
|
/// second device was measured still saying "sign in again" 41 minutes after
|
|
/// the login had been fixed.
|
|
///
|
|
/// Only the Keychain is re-read; the server is asked nothing. A secret that
|
|
/// has not changed therefore costs one local lookup a tick and no request.
|
|
/// Stops as soon as nothing is waiting, and gives up rather than polling
|
|
/// forever: on tvOS, where app Keychain items never sync at all, the wait
|
|
/// is permanent by design.
|
|
private func waitForArrivingCredentialsIfNeeded() {
|
|
guard isWaitingForCredential else {
|
|
credentialArrivalTask?.cancel()
|
|
credentialArrivalTask = nil
|
|
return
|
|
}
|
|
|
|
guard credentialArrivalTask == nil else { return }
|
|
|
|
credentialArrivalTask = Task { [weak self] in
|
|
for _ in 0 ..< 8 {
|
|
try? await Task.sleep(for: .seconds(15))
|
|
guard !Task.isCancelled, let self, self.isWaitingForCredential else { break }
|
|
|
|
// The whole verdict rather than a shortened version of it: a
|
|
// rejection lifts only when the stored secret is a different
|
|
// one, and that comparison lives in there. Another account's
|
|
// outage fallback is left where it is: this tick is not news
|
|
// about that server, and cutting its minute of backoff down to
|
|
// fifteen seconds is not something waiting on a credential
|
|
// should do.
|
|
self.refreshAccountStatuses(preservingUnreachable: true)
|
|
}
|
|
|
|
// A cancelled poll has already been succeeded — clearing the
|
|
// reference now would orphan whatever replaced it, leaving a
|
|
// poller nothing can stop.
|
|
guard !Task.isCancelled else { return }
|
|
self?.credentialArrivalTask = nil
|
|
}
|
|
}
|
|
|
|
/// Whether any account is holding out for a credential iCloud Keychain has
|
|
/// yet to deliver — either none at all, or one to replace what the server
|
|
/// refused.
|
|
private var isWaitingForCredential: Bool {
|
|
accountsManager.accounts.contains {
|
|
let status = accountsManager.status(for: $0.id)
|
|
return status == .credentialMissing || status == .credentialRejected
|
|
}
|
|
}
|
|
|
|
// MARK: - Credentials Management
|
|
|
|
/// Returns the appropriate credentials manager for an instance type.
|
|
/// - Parameter instance: The instance to get a credentials manager for
|
|
/// - Returns: The credentials manager, or nil if the instance type doesn't support authentication
|
|
func credentialsManager(for instance: Instance) -> (any InstanceCredentialsManager)? {
|
|
switch instance.type {
|
|
case .invidious:
|
|
return invidiousCredentialsManager
|
|
case .piped:
|
|
return pipedCredentialsManager
|
|
case .yatteeServer:
|
|
return basicAuthCredentialsManager
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Backfills `usesBasicAuth`/`usesAccountLogin` on instances that were created
|
|
/// before these flags existed, based on credentials currently in the Keychain.
|
|
/// Only ever sets the flags — a missing Keychain entry (e.g. right after a
|
|
/// reinstall) must not clear a previously recorded flag.
|
|
///
|
|
/// The `usesAccountLogin` half is one-way migration machinery: an upgrading
|
|
/// install whose flag was lost (it syncs through iCloud key-value storage)
|
|
/// but whose instance-keyed credential survived would otherwise never have
|
|
/// its legacy account synthesized by
|
|
/// `AccountsManager.reconcileLegacyAccounts()`. Fresh sign-ins never set
|
|
/// the flag.
|
|
private func backfillCredentialFlags() {
|
|
for instance in instancesManager.instances {
|
|
if !instance.usesBasicAuth,
|
|
instance.supportsHTTPBasicAuthProxy,
|
|
basicAuthCredentialsManager.hasCredentials(for: instance) {
|
|
instancesManager.setUsesBasicAuth(true, for: instance)
|
|
}
|
|
|
|
if !instance.usesAccountLogin,
|
|
instance.supportsAuthentication,
|
|
let manager = credentialsManager(for: instance),
|
|
manager.isLoggedIn(for: instance) {
|
|
instancesManager.setUsesAccountLogin(true, for: instance)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether an instance is known to require credentials that are missing from
|
|
/// the Keychain (e.g. after reinstalling the app and importing sources from iCloud).
|
|
/// - Yattee Server always requires basic auth; other types require it when
|
|
/// `usesBasicAuth` was recorded.
|
|
/// - Invidious/Piped account logins are checked when `usesAccountLogin` was recorded.
|
|
func needsCredentials(for instance: Instance) -> Bool {
|
|
if instance.type == .yatteeServer || instance.usesBasicAuth,
|
|
!basicAuthCredentialsManager.hasCredentials(for: instance) {
|
|
return true
|
|
}
|
|
|
|
// Accounts describe the logins on an instance once there are any, so the
|
|
// pre-revamp flag is consulted only for instances that have none —
|
|
// fresh logins deliberately no longer set it.
|
|
let accounts = accountsManager.accounts(forInstance: instance.id)
|
|
if !accounts.isEmpty {
|
|
// An unresolved identity scope means "cannot tell yet". Reporting a
|
|
// missing credential on it would badge every account on every
|
|
// launch until the scope lands.
|
|
guard credentialIdentityScope.isResolved else { return false }
|
|
|
|
return !accounts.contains { account in
|
|
accountCredentialsManager(for: account)?.isLoggedIn(for: account) ?? false
|
|
}
|
|
}
|
|
|
|
if instance.usesAccountLogin,
|
|
let manager = credentialsManager(for: instance),
|
|
!manager.isLoggedIn(for: instance) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// MARK: - Preview/Testing Support
|
|
|
|
@MainActor
|
|
static var preview: AppEnvironment {
|
|
let dataManager = try? DataManager.preview()
|
|
return AppEnvironment(dataManager: dataManager)
|
|
}
|
|
}
|
|
|
|
// MARK: - Environment Key
|
|
|
|
private struct AppEnvironmentKey: EnvironmentKey {
|
|
static let defaultValue: AppEnvironment? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var appEnvironment: AppEnvironment? {
|
|
get { self[AppEnvironmentKey.self] }
|
|
set { self[AppEnvironmentKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func appEnvironment(_ environment: AppEnvironment) -> some View {
|
|
self.environment(\.appEnvironment, environment)
|
|
}
|
|
}
|
|
|
|
// MARK: - Video Queue Context Environment
|
|
|
|
private struct VideoQueueContextKey: EnvironmentKey {
|
|
static let defaultValue: VideoQueueContext? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var videoQueueContext: VideoQueueContext? {
|
|
get { self[VideoQueueContextKey.self] }
|
|
set { self[VideoQueueContextKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func videoQueueContext(_ context: VideoQueueContext?) -> some View {
|
|
self.environment(\.videoQueueContext, context)
|
|
}
|
|
}
|