Files
yattee/Yattee/Core/AppEnvironment.swift
Arkadiusz Fal 05a03723f6 Rework corner layout with portrait-style info panel below the video
The corner layout now always renders the info page as a portrait-style
panel below the video (collapsible description, drag to hide/expand),
while the side column holds tabs for the remaining pages. Key pieces:

- Below-video panel reuses PortraitDetailsPanel (no comments pill,
  square corners, chapters/related stay in the side column tabs)
- Drag down hides the panel with the video recentering in lockstep;
  swipe up on the video reveals it following the finger
- Fullscreen button toggles the panels in corner layout (controls hide
  first so their resize never plays visibly); status bar and home
  indicator hide only while the panels are hidden
- Panel visibility and column width are seeded at init from a warmed
  PlayerControlsLayout cache, so the first frame resolves corner mode
  without flashing the default layout or animating the panels in
- Dismiss pan is blocked inside the below-video panel frame
  (cornerBelowPanelFrame in NavigationCoordinator)
- Resize grabber slimmed to match the panel drag handle look
2026-09-23 07:25:02 +02:00

1457 lines
67 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 {
// 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)
}
}