mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Delete declarations that Periphery reports unused on every platform that compiles them (iOS, macOS, tvOS), including nine views used only by their own previews. Platform-gated code still used on another platform, device- only MPV render paths, migration models, CloudKit record names, App Intents metadata and unused parameters are kept.
378 lines
14 KiB
Swift
378 lines
14 KiB
Swift
//
|
|
// InstancesManager.swift
|
|
// Yattee
|
|
//
|
|
// Manages configured backend instances with iCloud sync.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Status of an instance's connectivity and authentication.
|
|
enum InstanceStatus: Equatable {
|
|
/// Instance is online and working.
|
|
case online
|
|
/// Instance requires authentication but credentials are not provided.
|
|
case authRequired
|
|
/// Instance authentication failed (wrong credentials).
|
|
case authFailed
|
|
}
|
|
|
|
/// Manages the list of configured backend instances with iCloud sync.
|
|
@MainActor
|
|
@Observable
|
|
final class InstancesManager {
|
|
// MARK: - Storage
|
|
|
|
private let localDefaults: UserDefaults
|
|
private let ubiquitousStore = NSUbiquitousKeyValueStore.default
|
|
private let instancesKey = "configuredInstances"
|
|
private let activeInstanceKey = "activeInstanceID"
|
|
|
|
// MARK: - Dependencies
|
|
|
|
private weak var settingsManager: SettingsManager?
|
|
|
|
// MARK: - State
|
|
|
|
private(set) var instances: [Instance] = []
|
|
private(set) var activeInstanceID: UUID?
|
|
|
|
/// Current status of each instance, keyed by instance ID.
|
|
private(set) var instanceStatuses: [UUID: InstanceStatus] = [:]
|
|
|
|
// MARK: - Initialization
|
|
|
|
/// - Parameter defaults: where the configured instances are read and
|
|
/// written. The app's own defaults, except under test: a manager built on
|
|
/// the shared ones edits the list the app on that device is using, which
|
|
/// is how test fixtures ended up in it.
|
|
init(settingsManager: SettingsManager? = nil, defaults: UserDefaults = .standard) {
|
|
self.settingsManager = settingsManager
|
|
self.localDefaults = defaults
|
|
|
|
loadInstances()
|
|
loadActiveInstance()
|
|
|
|
// Listen for external changes from iCloud
|
|
NotificationCenter.default.addObserver(
|
|
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
|
object: ubiquitousStore,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.handleiCloudChange()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sets the settings manager reference for checking iCloud sync status.
|
|
func setSettingsManager(_ manager: SettingsManager) {
|
|
self.settingsManager = manager
|
|
}
|
|
|
|
/// Whether iCloud sync is currently enabled.
|
|
private var iCloudSyncEnabled: Bool {
|
|
settingsManager?.iCloudSyncEnabled ?? false
|
|
}
|
|
|
|
/// Whether instance sync is enabled (requires both master toggle and category toggle).
|
|
private var instanceSyncEnabled: Bool {
|
|
iCloudSyncEnabled && (settingsManager?.syncInstances ?? true)
|
|
}
|
|
|
|
/// Handles external iCloud changes by replacing local data with iCloud data.
|
|
private func handleiCloudChange() {
|
|
// Only process iCloud changes if instance sync is enabled
|
|
guard instanceSyncEnabled else { return }
|
|
|
|
guard let iCloudData = ubiquitousStore.data(forKey: instancesKey),
|
|
let iCloudInstances = try? JSONDecoder().decode([Instance].self, from: iCloudData) else {
|
|
return
|
|
}
|
|
|
|
// Replace local instances with iCloud data
|
|
instances = iCloudInstances
|
|
// Save to local defaults for offline access
|
|
localDefaults.set(iCloudData, forKey: instancesKey)
|
|
|
|
// Update sync time
|
|
settingsManager?.updateLastSyncTime()
|
|
|
|
// Instances arriving from iCloud are as much a change as a local edit.
|
|
// Without this, a legacy authenticated instance delivered here never
|
|
// reaches account migration — the account, and the adoption of its
|
|
// saved login, would happen only after an unrelated edit or a relaunch.
|
|
NotificationCenter.default.post(name: .instancesDidChange, object: nil)
|
|
}
|
|
|
|
/// Syncs local data to iCloud (called when enabling iCloud sync).
|
|
/// Only syncs if instance sync is enabled.
|
|
func syncToiCloud() {
|
|
guard instanceSyncEnabled else { return }
|
|
|
|
guard let data = try? JSONEncoder().encode(instances) else { return }
|
|
ubiquitousStore.set(data, forKey: instancesKey)
|
|
ubiquitousStore.synchronize()
|
|
settingsManager?.updateLastSyncTime()
|
|
}
|
|
|
|
/// Replaces local data with iCloud data (called when enabling iCloud sync).
|
|
/// Only replaces if instance sync is enabled.
|
|
func replaceWithiCloudData() {
|
|
guard instanceSyncEnabled else { return }
|
|
|
|
ubiquitousStore.synchronize()
|
|
|
|
guard let iCloudData = ubiquitousStore.data(forKey: instancesKey),
|
|
let iCloudInstances = try? JSONDecoder().decode([Instance].self, from: iCloudData) else {
|
|
// No iCloud data exists, sync local data to iCloud
|
|
syncToiCloud()
|
|
return
|
|
}
|
|
|
|
// Replace local with iCloud data
|
|
instances = iCloudInstances
|
|
localDefaults.set(iCloudData, forKey: instancesKey)
|
|
settingsManager?.updateLastSyncTime()
|
|
|
|
NotificationCenter.default.post(name: .instancesDidChange, object: nil)
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
func add(_ instance: Instance) {
|
|
instances.append(instance)
|
|
saveInstances()
|
|
}
|
|
|
|
func remove(_ instance: Instance) {
|
|
instances.removeAll { $0.id == instance.id }
|
|
|
|
// Clear active instance if it was the removed one
|
|
if activeInstanceID == instance.id {
|
|
activeInstanceID = nil
|
|
localDefaults.removeObject(forKey: activeInstanceKey)
|
|
}
|
|
|
|
saveInstances()
|
|
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
|
|
}
|
|
|
|
func update(_ instance: Instance) {
|
|
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
|
|
instances[index] = instance
|
|
saveInstances()
|
|
// Editing a source can change the proxy answer (URL change, toggle
|
|
// flip). Drop the cached auto-detect verdict so the next playback
|
|
// re-probes.
|
|
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
|
|
}
|
|
}
|
|
|
|
/// Toggles the enabled state of an instance.
|
|
func toggleEnabled(_ instance: Instance) {
|
|
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
|
|
var updated = instances[index]
|
|
updated.isEnabled.toggle()
|
|
instances[index] = updated
|
|
saveInstances()
|
|
}
|
|
}
|
|
|
|
/// Records whether an instance sits behind HTTP Basic Auth.
|
|
/// Persisted (and synced to iCloud) so missing Keychain credentials can be
|
|
/// detected after a reinstall.
|
|
func setUsesBasicAuth(_ value: Bool, for instance: Instance) {
|
|
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
|
|
instances[index].usesBasicAuth != value else { return }
|
|
instances[index].usesBasicAuth = value
|
|
saveInstances()
|
|
}
|
|
|
|
/// Records that a pre-revamp single login existed on an instance.
|
|
///
|
|
/// Migration-only: every caller is migration machinery (a Yattee 1 import,
|
|
/// the credential backfill, or the last account on the instance going
|
|
/// away). Signing in deliberately never calls this — accounts describe
|
|
/// logins now (see `AccountSignInHandler`, and `Instance.usesAccountLogin`
|
|
/// for who writes it and why).
|
|
func setUsesAccountLogin(_ value: Bool, for instance: Instance) {
|
|
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
|
|
instances[index].usesAccountLogin != value else { return }
|
|
instances[index].usesAccountLogin = value
|
|
saveInstances()
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var enabledInstances: [Instance] {
|
|
instances.filter(\.isEnabled)
|
|
}
|
|
|
|
var peertubeInstances: [Instance] {
|
|
instances.filter(\.isPeerTubeInstance)
|
|
}
|
|
|
|
var yatteeServerInstances: [Instance] {
|
|
instances.filter(\.isYatteeServerInstance)
|
|
}
|
|
|
|
var hasYouTubeInstances: Bool {
|
|
instances.contains { $0.isYouTubeInstance }
|
|
}
|
|
|
|
var hasPeerTubeInstances: Bool {
|
|
instances.contains { $0.isPeerTubeInstance }
|
|
}
|
|
|
|
var hasYatteeServerInstances: Bool {
|
|
instances.contains { $0.isYatteeServerInstance }
|
|
}
|
|
|
|
/// Instances whose backend has accounts at all, asked of the capability
|
|
/// table rather than a type list.
|
|
var accountCapableInstances: [Instance] {
|
|
instances.filter { $0.type.accountCapabilities.supportsAccounts }
|
|
}
|
|
|
|
var hasAccountCapableInstances: Bool {
|
|
instances.contains { $0.type.accountCapabilities.supportsAccounts }
|
|
}
|
|
|
|
var enabledYatteeServerInstances: [Instance] {
|
|
yatteeServerInstances.filter(\.isEnabled)
|
|
}
|
|
|
|
/// Selects an enabled instance appropriate for the given video's content source.
|
|
/// - For PeerTube videos: prefers the exact instance, falls back to any PeerTube instance
|
|
/// - For YouTube/extracted content: uses YouTube-capable instance (Invidious, Piped, Yattee Server)
|
|
func instance(for video: Video) -> Instance? {
|
|
instance(for: video.id.source)
|
|
}
|
|
|
|
/// Selects the instance to load a remote playlist from: Invidious first for
|
|
/// YouTube (Invidious playlists are Invidious-specific), the playlist's own
|
|
/// server for federated content, Yattee Server for extracted content.
|
|
func playlistInstance(for source: ContentSource) -> Instance? {
|
|
switch source {
|
|
case .global:
|
|
// For global content (YouTube), prefer Invidious instances over Yattee Server
|
|
// since Invidious playlists (IVPL*) are Invidious-specific
|
|
if let invidiousInstance = enabledInstances.first(where: { $0.type == .invidious }) {
|
|
return invidiousInstance
|
|
}
|
|
// Fall back to any YouTube-compatible instance
|
|
return enabledInstances.first(where: { $0.isYouTubeInstance })
|
|
case .federated(let provider, let instanceURL):
|
|
// For federated content, find matching instance by URL
|
|
if let existingInstance = instances.first(where: { $0.url == instanceURL }) {
|
|
return existingInstance
|
|
}
|
|
// If no configured instance matches, create a temporary one for PeerTube
|
|
if provider == ContentSource.peertubeProvider {
|
|
return Instance(type: .peertube, url: instanceURL)
|
|
}
|
|
return nil
|
|
case .extracted:
|
|
// For extracted content, use Yattee Server
|
|
return yatteeServerInstances.first
|
|
}
|
|
}
|
|
|
|
/// Selects an enabled instance appropriate for the given content source.
|
|
/// - For PeerTube content: prefers the exact instance, falls back to any PeerTube instance
|
|
/// - For extracted content: requires Yattee Server (only backend with yt-dlp)
|
|
/// - For YouTube content: uses YouTube-capable instance (Invidious, Piped, Yattee Server)
|
|
func instance(for contentSource: ContentSource) -> Instance? {
|
|
switch contentSource {
|
|
case .federated(let provider, let instanceURL) where provider == ContentSource.peertubeProvider:
|
|
// PeerTube content - prefer the exact instance, fall back to any PeerTube instance
|
|
return enabledInstances.first { $0.url.host == instanceURL.host }
|
|
?? enabledInstances.first(where: \.isPeerTubeInstance)
|
|
case .extracted:
|
|
// Extracted content requires Yattee Server (yt-dlp)
|
|
return yatteeServerInstances.first
|
|
case .global, .federated:
|
|
// YouTube content - prefer Yattee Server (the active one if it is one),
|
|
// then the active instance, then any other YouTube-capable instance
|
|
let active = activeInstance
|
|
if let active, active.isYatteeServerInstance {
|
|
return active
|
|
}
|
|
if let yatteeServer = enabledYatteeServerInstances.first {
|
|
return yatteeServer
|
|
}
|
|
if let active, active.isYouTubeInstance {
|
|
return active
|
|
}
|
|
return enabledInstances.first(where: \.isYouTubeInstance)
|
|
}
|
|
}
|
|
|
|
/// Disables all Yattee Server instances except the specified one.
|
|
func disableOtherYatteeServerInstances(except instanceID: UUID) {
|
|
for instance in enabledYatteeServerInstances where instance.id != instanceID {
|
|
var updated = instance
|
|
updated.isEnabled = false
|
|
update(updated)
|
|
}
|
|
}
|
|
|
|
// MARK: - Instance Status
|
|
|
|
/// Returns the current status of an instance.
|
|
func status(for instance: Instance) -> InstanceStatus {
|
|
instanceStatuses[instance.id] ?? .online
|
|
}
|
|
|
|
/// The currently active instance for browsing content.
|
|
/// Falls back to the first enabled instance if no active instance is set.
|
|
var activeInstance: Instance? {
|
|
if let id = activeInstanceID,
|
|
let instance = enabledInstances.first(where: { $0.id == id }) {
|
|
return instance
|
|
}
|
|
return enabledInstances.first
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func loadInstances() {
|
|
// Only load from local defaults - never automatically pull from iCloud
|
|
// User must explicitly enable iCloud sync to get iCloud data
|
|
if let data = localDefaults.data(forKey: instancesKey),
|
|
let decoded = try? JSONDecoder().decode([Instance].self, from: data) {
|
|
instances = decoded
|
|
}
|
|
}
|
|
|
|
private func saveInstances() {
|
|
guard let data = try? JSONEncoder().encode(instances) else { return }
|
|
|
|
// Always write to local storage
|
|
localDefaults.set(data, forKey: instancesKey)
|
|
|
|
// Only write to iCloud if instance sync is enabled
|
|
if instanceSyncEnabled {
|
|
ubiquitousStore.set(data, forKey: instancesKey)
|
|
settingsManager?.updateLastSyncTime()
|
|
}
|
|
|
|
NotificationCenter.default.post(name: .instancesDidChange, object: nil)
|
|
}
|
|
|
|
private func loadActiveInstance() {
|
|
if let idString = localDefaults.string(forKey: activeInstanceKey),
|
|
let uuid = UUID(uuidString: idString) {
|
|
activeInstanceID = uuid
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
extension Notification.Name {
|
|
static let instancesDidChange = Notification.Name("stream.yattee.instancesDidChange")
|
|
}
|