mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 05:02:53 +00:00
Residual round after the macOS fix verification, five closures:
A connection refusal or a failed DNS lookup arrived as APIError.unknown,
which no classifier reads — so an account pointed at a downed instance
never went unreachable, never fell back to local data, never armed a
retry. New APIError.connectionFailed carries the system's own sentence
and lands as unreachable/offline in AccountsManager, InstancesManager
and the credential probe; a refused host now classifies in seconds
instead of never.
Subscriptions' periodic retry armed only for networkError, while the
local fallback's yatteeServerRequired — shown exactly when the account's
server could not be reached — waited on a status expiry nothing was
watching. FeedLoadState.warrantsPeriodicRetry gates on the context's
unavailability, so that screen heals on its own too, and an account that
genuinely has no Yattee Server still never loops.
The switcher dropped every context of a switched-off source, leaving the
toolbar badge pointing at a menu its own selection had vanished from.
listedContexts keeps the rows with their status in the title ("— Source
switched off"), isSwitchable refuses the pick, and the current context
keeps its checkmark.
The sidebar footer's switcher row overflowed the 140 pt column minimum:
the width floor now sits on the menu itself and ViewThatFits drops the
caption — the part the icon already says — before the account name loses
characters.
The channel menu exposed a raw SF Symbol name as its accessibility
title; the label is now a real Label rendered iconOnly, so the AX bridge
hears "Subscription Options".
433 lines
16 KiB
Swift
433 lines
16 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 is offline or unreachable.
|
|
case offline
|
|
/// 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) }
|
|
}
|
|
}
|
|
|
|
/// Alias for add method to maintain consistency.
|
|
func addInstance(_ instance: Instance) {
|
|
add(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()
|
|
}
|
|
|
|
/// Sets the given instance as the primary (first) instance.
|
|
func setPrimary(_ instance: Instance) {
|
|
LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general)
|
|
LoggingService.shared.debug("[InstancesManager] Current instances: \(instances.map { $0.displayName })", category: .general)
|
|
|
|
guard let index = instances.firstIndex(where: { $0.id == instance.id }) else {
|
|
LoggingService.shared.debug("[InstancesManager] Instance not found in list", category: .general)
|
|
return
|
|
}
|
|
|
|
if index == 0 {
|
|
LoggingService.shared.debug("[InstancesManager] Instance already at index 0, skipping", category: .general)
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.debug("[InstancesManager] Moving instance from index \(index) to 0", category: .general)
|
|
// Move to front
|
|
let removed = instances.remove(at: index)
|
|
instances.insert(removed, at: 0)
|
|
saveInstances()
|
|
LoggingService.shared.debug("[InstancesManager] After move: \(instances.map { $0.displayName })", category: .general)
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var enabledInstances: [Instance] {
|
|
instances.filter(\.isEnabled)
|
|
}
|
|
|
|
var youtubeInstances: [Instance] {
|
|
instances.filter(\.isYouTubeInstance)
|
|
}
|
|
|
|
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 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, fall back to other YouTube-capable instances
|
|
return enabledYatteeServerInstances.first
|
|
?? 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
|
|
}
|
|
|
|
/// Updates the status of an instance.
|
|
func updateStatus(_ status: InstanceStatus, for instance: Instance) {
|
|
instanceStatuses[instance.id] = status
|
|
}
|
|
|
|
/// Updates status based on an API error.
|
|
func updateStatusFromError(_ error: Error, for instance: Instance) {
|
|
if let apiError = error as? APIError {
|
|
switch apiError {
|
|
case .unauthorized:
|
|
updateStatus(.authFailed, for: instance)
|
|
case .noConnection, .timeout, .connectionFailed:
|
|
updateStatus(.offline, for: instance)
|
|
default:
|
|
// Don't change status for other errors
|
|
break
|
|
}
|
|
} else {
|
|
// Generic network errors
|
|
let nsError = error as NSError
|
|
if nsError.domain == NSURLErrorDomain {
|
|
updateStatus(.offline, for: instance)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clears the status of an instance (resets to online).
|
|
func clearStatus(for instance: Instance) {
|
|
instanceStatuses.removeValue(forKey: instance.id)
|
|
}
|
|
|
|
/// Instances that have auth issues (need attention).
|
|
var instancesWithAuthIssues: [Instance] {
|
|
instances.filter { instanceStatuses[$0.id] == .authFailed || instanceStatuses[$0.id] == .authRequired }
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Sets the given instance as the active instance for browsing.
|
|
func setActive(_ instance: Instance) {
|
|
guard enabledInstances.contains(where: { $0.id == instance.id }) else { return }
|
|
activeInstanceID = instance.id
|
|
saveActiveInstance()
|
|
NotificationCenter.default.post(name: .activeInstanceDidChange, object: nil)
|
|
}
|
|
|
|
/// Clears the active instance, falling back to the first enabled instance.
|
|
func clearActiveInstance() {
|
|
activeInstanceID = nil
|
|
localDefaults.removeObject(forKey: activeInstanceKey)
|
|
NotificationCenter.default.post(name: .activeInstanceDidChange, object: nil)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
private func saveActiveInstance() {
|
|
if let id = activeInstanceID {
|
|
localDefaults.set(id.uuidString, forKey: activeInstanceKey)
|
|
} else {
|
|
localDefaults.removeObject(forKey: activeInstanceKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
extension Notification.Name {
|
|
static let instancesDidChange = Notification.Name("stream.yattee.instancesDidChange")
|
|
static let activeInstanceDidChange = Notification.Name("stream.yattee.activeInstanceDidChange")
|
|
}
|