|
|
@@ -0,0 +1,502 @@
|
|
|
+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 username and password stored in 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,
|
|
|
+ searchTimeout: timeout,
|
|
|
+ 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()
|
|
|
+ return try await get("api/v0/searches/\(id)")
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Delete a search to clean up server-side resources.
|
|
|
+ func deleteSearch(id: String) async throws {
|
|
|
+ try await ensureAuthenticated()
|
|
|
+
|
|
|
+ let url = try buildURL("api/v0/searches/\(id)")
|
|
|
+ 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")
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 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<T: Decodable>(_ 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."
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|