| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- 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<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."
- }
- }
- }
|