NavidromeApp/Shared/Storage/ServerManager.swift
Dallas Groot 0730fa11f8 bug fixes
2026-04-11 15:09:06 -07:00

268 lines
9.3 KiB
Swift

import Foundation
import Combine
/// Manages multiple Navidrome server connections with automatic failover.
/// When one server is unreachable (e.g. public domain on local WiFi),
/// it automatically tries the next server with the same credentials.
class ServerManager: ObservableObject {
static let shared = ServerManager()
@Published var servers: [ServerConfig] = []
@Published var activeServer: ServerConfig?
@Published var connectionState: ConnectionState = .disconnected
@Published var lastFailoverMessage: String?
/// Set to true the first time a server ping succeeds. Used by LibraryCache.isServerAvailable
/// to avoid greying out songs on first launch while the initial connection is in-flight.
private(set) var hasEverConnected: Bool = false
private let storageKey = "navidrome_servers"
private let activeServerKey = "navidrome_active_server"
private var failoverTask: Task<Void, Never>?
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case error(String)
}
let client = SubsonicClient()
private init() {
loadServers()
}
// MARK: - Server CRUD
func addServer(_ server: ServerConfig) {
var srv = server
srv.url = srv.url.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
KeychainHelper.savePassword(srv.password, for: srv.id)
servers.append(srv)
saveServers()
}
func updateServer(_ server: ServerConfig) {
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
KeychainHelper.savePassword(server.password, for: server.id)
servers[idx] = server
saveServers()
if activeServer?.id == server.id {
activeServer = server
client.currentServer = server
}
}
}
func removeServer(at offsets: IndexSet) {
offsets.forEach { KeychainHelper.deletePassword(for: servers[$0].id) }
let removedIds = offsets.map { servers[$0].id }
servers.remove(atOffsets: offsets)
if let active = activeServer, removedIds.contains(active.id) {
activeServer = servers.first
client.currentServer = activeServer
}
saveServers()
}
func removeServer(_ server: ServerConfig) {
KeychainHelper.deletePassword(for: server.id)
servers.removeAll { $0.id == server.id }
if activeServer?.id == server.id {
activeServer = servers.first
client.currentServer = activeServer
}
saveServers()
}
// MARK: - Connection with Auto-Failover
/// Connects to a specific server. On failure, tries other servers
/// with the same username (likely the same Navidrome instance).
func connect(to server: ServerConfig) async -> Bool {
await MainActor.run {
connectionState = .connecting
lastFailoverMessage = nil
}
// Try the requested server first
if await tryConnect(server) {
return true
}
// Failed try other servers with same username (same Navidrome instance)
let alternatives = servers.filter { $0.id != server.id && $0.username == server.username }
for alt in alternatives {
await MainActor.run {
lastFailoverMessage = "Trying \(alt.name)..."
}
if await tryConnect(alt) {
await MainActor.run {
lastFailoverMessage = "Connected via \(alt.name)"
}
return true
}
}
// Also try servers with different usernames as last resort
let others = servers.filter { $0.id != server.id && $0.username != server.username }
for other in others {
await MainActor.run {
lastFailoverMessage = "Trying \(other.name)..."
}
if await tryConnect(other) {
await MainActor.run {
lastFailoverMessage = "Connected via \(other.name)"
}
return true
}
}
// All failed
await MainActor.run {
connectionState = .error("All servers unreachable")
lastFailoverMessage = nil
}
return false
}
/// Low-level connect attempt with short timeout
private func tryConnect(_ server: ServerConfig) async -> Bool {
client.currentServer = server
do {
let ok = try await withTimeout(seconds: 5) {
try await self.client.ping(server: server)
}
if ok {
await MainActor.run {
self.activeServer = server
self.connectionState = .connected
self.hasEverConnected = true
self.client.isAuthenticated = true
self.saveActiveServer()
}
return true
}
} catch {
// Connection failed will try next
}
return false
}
/// Timeout wrapper for network calls
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw URLError(.timedOut)
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
func connectToActive() async {
guard let server = activeServer else {
// No active server try all
if let first = servers.first {
_ = await connect(to: first)
}
return
}
_ = await connect(to: server)
}
/// Called when an API request fails triggers background failover
func handleConnectionFailure() {
guard failoverTask == nil else { return }
failoverTask = Task {
if let active = activeServer {
_ = await connect(to: active)
}
failoverTask = nil
}
}
func switchServer(_ server: ServerConfig) async -> Bool {
return await connect(to: server)
}
func disconnect() {
connectionState = .disconnected
client.isAuthenticated = false
}
// MARK: - Persistence
private func saveServers() {
// Save passwords to Keychain before encoding the JSON in UserDefaults
// will contain an empty string for password, keeping it out of backups.
for server in servers {
KeychainHelper.savePassword(server.password, for: server.id)
}
// Encode with passwords blanked JSON is visible in backups/snapshots
let sanitised = servers.map { s -> ServerConfig in
var copy = s; copy.password = ""; return copy
}
if let data = try? JSONEncoder().encode(sanitised) {
UserDefaults.standard.set(data, forKey: storageKey)
}
syncToWatch()
}
private func saveActiveServer() {
if let active = activeServer {
KeychainHelper.savePassword(active.password, for: active.id)
}
var sanitised = activeServer
sanitised?.password = ""
if let data = try? JSONEncoder().encode(sanitised) {
UserDefaults.standard.set(data, forKey: activeServerKey)
}
}
private func loadServers() {
if let data = UserDefaults.standard.data(forKey: storageKey),
var decoded = try? JSONDecoder().decode([ServerConfig].self, from: data) {
// Re-inject passwords from Keychain.
// If Keychain returns nil for a server, fall back to the UserDefaults
// value (migration path for existing installs that still store the
// password in UserDefaults).
for i in decoded.indices {
if let kp = KeychainHelper.loadPassword(for: decoded[i].id), !kp.isEmpty {
decoded[i].password = kp
}
// If password is still empty here (first launch after migration),
// the user will be prompted to re-enter on next server edit.
}
servers = decoded
}
if let data = UserDefaults.standard.data(forKey: activeServerKey),
var decoded = try? JSONDecoder().decode(ServerConfig.self, from: data) {
if let kp = KeychainHelper.loadPassword(for: decoded.id), !kp.isEmpty {
decoded.password = kp
}
activeServer = decoded
client.currentServer = decoded
} else {
activeServer = servers.first
client.currentServer = servers.first
}
}
private func syncToWatch() {
#if os(iOS)
// Sends full ServerConfig including password over WCSession acceptable
// because WCSession uses Bluetooth/WiFi with AES-256 encryption.
WatchConnectivityManager.shared.sendServersToWatch(servers)
#endif
}
}