import Foundation import Security // MARK: - Slskd Keychain Storage /// Keychain wrapper for slskd credentials (username + password). /// Separate service from ChadMusic to avoid collision. enum SlskdKeychainService { private static let service = "com.mixboard.slskd" static func save(account: String, value: String) throws { // A-10: Throw on encoding failure instead of silently returning, // which could leave credentials in a half-saved state. guard let data = value.data(using: .utf8) else { throw KeychainService.KeychainError.saveFailed(errSecParam) } let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] SecItemDelete(deleteQuery as CFDictionary) let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, ] let status = SecItemAdd(addQuery as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainService.KeychainError.saveFailed(status) } } static func load(account: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } static func delete(account: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] SecItemDelete(query as CFDictionary) } static func deleteAll() { delete(account: "username") delete(account: "password") } } // MARK: - Slskd Credentials /// Manages slskd Soulseek P2P credentials via Keychain. @MainActor final class SlskdCredentials { static let shared = SlskdCredentials() var username: String? { get { SlskdKeychainService.load(account: "username") } } var password: String? { get { SlskdKeychainService.load(account: "password") } } var hasCredentials: Bool { guard let u = username, !u.isEmpty, let p = password, !p.isEmpty else { return false } return true } func save(username: String, password: String) throws { try SlskdKeychainService.save(account: "username", value: username) try SlskdKeychainService.save(account: "password", value: password) } func delete() { SlskdKeychainService.deleteAll() } } // MARK: - Slskd API Client /// Server mode: managed subprocess or external server. enum SlskdServerMode: String { case managed case external } /// HTTP client for the slskd REST API (v0). /// Handles JWT authentication, search, download management, and server health. @MainActor @Observable final class SlskdAPIClient { static let shared = SlskdAPIClient() // MARK: - Configuration /// Whether slskd is managed by MixBoard or connected to an external server. var serverMode: SlskdServerMode { get { let raw = UserDefaults.standard.string(forKey: "slskd.serverMode") ?? "managed" return SlskdServerMode(rawValue: raw) ?? .managed } set { UserDefaults.standard.set(newValue.rawValue, forKey: "slskd.serverMode") } } /// Server base URL. Managed mode uses localhost; external mode uses user-configured URL. var serverURL: String { get { switch serverMode { case .managed: return "http://localhost:\(SlskdProcessManager.port)" case .external: return UserDefaults.standard.string(forKey: "slskd.serverURL") ?? "" } } set { // Only meaningful in external mode UserDefaults.standard.set(newValue, forKey: "slskd.serverURL") } } /// Whether the client has URL + credentials configured. var isConfigured: Bool { switch serverMode { case .managed: return SlskdProcessManager.shared.state == .running case .external: let url = UserDefaults.standard.string(forKey: "slskd.serverURL") ?? "" return !url.isEmpty && SlskdCredentials.shared.hasCredentials } } // MARK: - Private State private let session: URLSession private let decoder: JSONDecoder /// A-1: Store JWT as Data for explicit zeroing on invalidation. private var jwtTokenData: Data? private var tokenExpiry: Date? // Circuit breaker for auth failures (C-1) private var consecutiveAuthFailures = 0 private var authBackoffUntil: Date? /// Maximum consecutive auth failures before the circuit breaker opens. static let maxAuthRetries = 3 init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 config.timeoutIntervalForResource = 120 self.session = URLSession(configuration: config) self.decoder = JSONDecoder() } // MARK: - Authentication /// Authenticate with slskd and cache the JWT token. func authenticate() async throws { // C-1: Circuit breaker — stop retrying after maxAuthRetries consecutive failures if consecutiveAuthFailures >= Self.maxAuthRetries { throw SlskdError.authCircuitOpen(failures: consecutiveAuthFailures) } // C-1: Exponential backoff — wait before retrying if let backoffUntil = authBackoffUntil, Date() < backoffUntil { throw SlskdError.authCircuitOpen(failures: consecutiveAuthFailures) } let username: String let password: String switch serverMode { case .managed: guard let u = ManagedSlskdCredentials.shared.username, let p = ManagedSlskdCredentials.shared.password else { throw SlskdError.notConfigured } username = u password = p case .external: guard let u = SlskdCredentials.shared.username, let p = SlskdCredentials.shared.password else { throw SlskdError.notConfigured } username = u password = p } let loginReq = SlskdLoginRequest(username: username, password: password) let data = try JSONEncoder().encode(loginReq) let url = try buildURL("api/v0/session") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = data let (responseData, httpResponse) = try await performRequest(request) guard httpResponse.statusCode == 200 else { if httpResponse.statusCode == 401 { // C-1: Track failure and set exponential backoff consecutiveAuthFailures += 1 let backoffSeconds = pow(2.0, Double(consecutiveAuthFailures)) authBackoffUntil = Date().addingTimeInterval(backoffSeconds) throw SlskdError.unauthorized } throw SlskdError.httpError(httpResponse.statusCode) } // C-1: Reset circuit breaker on success consecutiveAuthFailures = 0 authBackoffUntil = nil let loginResp = try decoder.decode(SlskdLoginResponse.self, from: responseData) jwtTokenData = Data(loginResp.token.utf8) // slskd tokens last ~24h. Refresh after 20h to be safe. tokenExpiry = Date().addingTimeInterval(20 * 3600) } /// Ensure we have a valid JWT, refreshing if needed. private func ensureAuthenticated() async throws { if let expiry = tokenExpiry, Date() < expiry, jwtTokenData != nil { return } try await authenticate() } /// Reset the auth circuit breaker (e.g., after user updates credentials). func resetAuthCircuitBreaker() { consecutiveAuthFailures = 0 authBackoffUntil = nil clearToken() tokenExpiry = nil } // MARK: - Search /// Start a search on the Soulseek network. /// Returns the search ID for polling. func startSearch(query: String, responseLimit: Int = 100, timeout: Int = 15) async throws -> String { try await ensureAuthenticated() let searchReq = SlskdSearchRequest( searchText: query, responseLimit: responseLimit, timeout: timeout * 1000, // slskd 0.25+ expects milliseconds filterResponses: true, minimumResponseFileCount: 1 ) let body = try JSONEncoder().encode(searchReq) let url = try buildURL("api/v0/searches") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") try applyAuth(&request) request.httpBody = body let (data, httpResponse) = try await performRequest(request) try checkHTTPStatus(httpResponse, data: data) let search = try decoder.decode(SlskdSearch.self, from: data) return search.id } /// Poll a search by ID. Returns the search state including responses. func getSearch(id: String) async throws -> SlskdSearch { try await ensureAuthenticated() let safeId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id return try await get("api/v0/searches/\(safeId)?includeResponses=true") } /// Delete a search to clean up server-side resources. func deleteSearch(id: String) async throws { try await ensureAuthenticated() let safeId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id let url = try buildURL("api/v0/searches/\(safeId)") var request = URLRequest(url: url) request.httpMethod = "DELETE" try applyAuth(&request) let (_, httpResponse) = try await performRequest(request) // 200 or 404 are both fine (already deleted) if httpResponse.statusCode != 200 && httpResponse.statusCode != 404 { throw SlskdError.httpError(httpResponse.statusCode) } } // MARK: - Downloads /// Enqueue files for download from a specific user. func enqueueDownloads(username: String, files: [SlskdFile]) async throws { try await ensureAuthenticated() let url = try buildURL("api/v0/transfers/downloads/\(username)") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") try applyAuth(&request) let downloadReqs = files.map { SlskdDownloadRequest(filename: $0.filename, size: $0.size) } request.httpBody = try JSONEncoder().encode(downloadReqs) let (data, httpResponse) = try await performRequest(request) try checkHTTPStatus(httpResponse, data: data) } /// Get all current download transfers. func getDownloads() async throws -> [SlskdTransferGroup] { try await ensureAuthenticated() return try await get("api/v0/transfers/downloads") } // MARK: - Server /// Check server state/connectivity. func getServerState() async throws -> SlskdServerState { try await ensureAuthenticated() return try await get("api/v0/server") } /// Tell slskd to connect to the Soulseek network. func connectToNetwork() async throws { try await ensureAuthenticated() let url = try buildURL("api/v0/server") var request = URLRequest(url: url) request.httpMethod = "PUT" try applyAuth(&request) request.timeoutInterval = 5 let (_, response) = try await performRequest(request) if response.statusCode != 200 { throw SlskdError.httpError(response.statusCode) } } /// Quick connectivity test. Returns nil on success, error on failure. func testConnection() async -> SlskdError? { do { try await authenticate() _ = try await getServerState() return nil } catch let error as SlskdError { return error } catch { return .networkError(error) } } // MARK: - Private Helpers private func buildURL(_ path: String) throws -> URL { let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw SlskdError.notConfigured } let base = trimmed.hasSuffix("/") ? trimmed : trimmed + "/" guard let url = URL(string: base + path) else { throw SlskdError.notConfigured } // A-8: Only allow http/https to prevent file:// or other scheme abuse. guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { throw SlskdError.notConfigured } return url } private func applyAuth(_ request: inout URLRequest) throws { guard let data = jwtTokenData, let token = String(data: data, encoding: .utf8) else { throw SlskdError.unauthorized } request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } private func performRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { // A-2: Retry idempotent GET requests on transient network failures. // POST/DELETE are not retried to avoid side-effect duplication. let maxRetries = (request.httpMethod == "GET") ? 2 : 0 var lastError: Error? for attempt in 0...maxRetries { if attempt > 0 { // Exponential backoff: 1s, 2s let backoff = UInt64(pow(2.0, Double(attempt - 1))) * 1_000_000_000 try? await Task.sleep(nanoseconds: backoff) try Task.checkCancellation() } do { let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw SlskdError.invalidResponse } return (data, httpResponse) } catch is CancellationError { throw CancellationError() } catch let error as SlskdError { throw error // Our own errors — don't retry } catch { lastError = error continue // Transient URLError — retry } } throw SlskdError.networkError(lastError ?? URLError(.unknown)) } private func checkHTTPStatus(_ response: HTTPURLResponse, data: Data) throws { switch response.statusCode { case 200..<300: return case 401: clearToken() tokenExpiry = nil throw SlskdError.unauthorized case 429: throw SlskdError.rateLimited default: throw SlskdError.httpError(response.statusCode) } } /// A-1: Zero-out the JWT token data before releasing it. private func clearToken() { if var data = jwtTokenData { let count = data.count data.withUnsafeMutableBytes { ptr in if let base = ptr.baseAddress { memset(base, 0, count) } } } jwtTokenData = nil } private func get(_ path: String) async throws -> T { let url = try buildURL(path) var request = URLRequest(url: url) request.httpMethod = "GET" try applyAuth(&request) let (data, httpResponse) = try await performRequest(request) try checkHTTPStatus(httpResponse, data: data) do { return try decoder.decode(T.self, from: data) } catch { throw SlskdError.decodingFailed(error) } } } // MARK: - SlskdError enum SlskdError: LocalizedError { case notConfigured case unauthorized case authCircuitOpen(failures: Int) case rateLimited case httpError(Int) case invalidResponse case decodingFailed(Error) case networkError(Error) case searchTimeout case noResults case noQualityMatch case downloadFailed(String) case importTimeout var errorDescription: String? { switch self { case .notConfigured: "Soulseek not configured. Set the server URL and credentials in Settings." case .unauthorized: "Soulseek authentication failed (invalid credentials)." case .authCircuitOpen(let failures): "Soulseek authentication suspended after \(failures) consecutive failures. Check credentials in Settings." case .rateLimited: "Soulseek rate limited — too many requests. Try again shortly." case .httpError(let code): "Soulseek server returned HTTP \(code)." case .invalidResponse: "Invalid response from Soulseek server." case .decodingFailed(let error): "Failed to decode Soulseek response: \(error.localizedDescription)" case .networkError(let error): "Network error connecting to Soulseek: \(error.localizedDescription)" case .searchTimeout: "Soulseek search timed out after 30 seconds." case .noResults: "No Soulseek results found for this album." case .noQualityMatch: "No Soulseek source met the quality threshold (need score >= 80)." case .downloadFailed(let detail): "Soulseek download failed: \(detail)" case .importTimeout: "Import timed out — album may not be in library. Files are downloaded; try rescanning manually." } } }