SlskdAPIClient.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import Foundation
  2. import Security
  3. // MARK: - Slskd Keychain Storage
  4. /// Keychain wrapper for slskd credentials (username + password).
  5. /// Separate service from ChadMusic to avoid collision.
  6. enum SlskdKeychainService {
  7. private static let service = "com.mixboard.slskd"
  8. static func save(account: String, value: String) throws {
  9. // A-10: Throw on encoding failure instead of silently returning,
  10. // which could leave credentials in a half-saved state.
  11. guard let data = value.data(using: .utf8) else {
  12. throw KeychainService.KeychainError.saveFailed(errSecParam)
  13. }
  14. let deleteQuery: [String: Any] = [
  15. kSecClass as String: kSecClassGenericPassword,
  16. kSecAttrService as String: service,
  17. kSecAttrAccount as String: account,
  18. ]
  19. SecItemDelete(deleteQuery as CFDictionary)
  20. let addQuery: [String: Any] = [
  21. kSecClass as String: kSecClassGenericPassword,
  22. kSecAttrService as String: service,
  23. kSecAttrAccount as String: account,
  24. kSecValueData as String: data,
  25. kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
  26. ]
  27. let status = SecItemAdd(addQuery as CFDictionary, nil)
  28. guard status == errSecSuccess else {
  29. throw KeychainService.KeychainError.saveFailed(status)
  30. }
  31. }
  32. static func load(account: String) -> String? {
  33. let query: [String: Any] = [
  34. kSecClass as String: kSecClassGenericPassword,
  35. kSecAttrService as String: service,
  36. kSecAttrAccount as String: account,
  37. kSecReturnData as String: true,
  38. kSecMatchLimit as String: kSecMatchLimitOne,
  39. ]
  40. var result: AnyObject?
  41. let status = SecItemCopyMatching(query as CFDictionary, &result)
  42. guard status == errSecSuccess, let data = result as? Data else { return nil }
  43. return String(data: data, encoding: .utf8)
  44. }
  45. static func delete(account: String) {
  46. let query: [String: Any] = [
  47. kSecClass as String: kSecClassGenericPassword,
  48. kSecAttrService as String: service,
  49. kSecAttrAccount as String: account,
  50. ]
  51. SecItemDelete(query as CFDictionary)
  52. }
  53. static func deleteAll() {
  54. delete(account: "username")
  55. delete(account: "password")
  56. }
  57. }
  58. // MARK: - Slskd Credentials
  59. /// Manages slskd Soulseek P2P credentials via Keychain.
  60. @MainActor
  61. final class SlskdCredentials {
  62. static let shared = SlskdCredentials()
  63. var username: String? {
  64. get { SlskdKeychainService.load(account: "username") }
  65. }
  66. var password: String? {
  67. get { SlskdKeychainService.load(account: "password") }
  68. }
  69. var hasCredentials: Bool {
  70. guard let u = username, !u.isEmpty,
  71. let p = password, !p.isEmpty else { return false }
  72. return true
  73. }
  74. func save(username: String, password: String) throws {
  75. try SlskdKeychainService.save(account: "username", value: username)
  76. try SlskdKeychainService.save(account: "password", value: password)
  77. }
  78. func delete() {
  79. SlskdKeychainService.deleteAll()
  80. }
  81. }
  82. // MARK: - Slskd API Client
  83. /// Server mode: managed subprocess or external server.
  84. enum SlskdServerMode: String {
  85. case managed
  86. case external
  87. }
  88. /// HTTP client for the slskd REST API (v0).
  89. /// Handles JWT authentication, search, download management, and server health.
  90. @MainActor
  91. @Observable
  92. final class SlskdAPIClient {
  93. static let shared = SlskdAPIClient()
  94. // MARK: - Configuration
  95. /// Whether slskd is managed by MixBoard or connected to an external server.
  96. var serverMode: SlskdServerMode {
  97. get {
  98. let raw = UserDefaults.standard.string(forKey: "slskd.serverMode") ?? "managed"
  99. return SlskdServerMode(rawValue: raw) ?? .managed
  100. }
  101. set { UserDefaults.standard.set(newValue.rawValue, forKey: "slskd.serverMode") }
  102. }
  103. /// Server base URL. Managed mode uses localhost; external mode uses user-configured URL.
  104. var serverURL: String {
  105. get {
  106. switch serverMode {
  107. case .managed:
  108. return "http://localhost:\(SlskdProcessManager.port)"
  109. case .external:
  110. return UserDefaults.standard.string(forKey: "slskd.serverURL") ?? ""
  111. }
  112. }
  113. set {
  114. // Only meaningful in external mode
  115. UserDefaults.standard.set(newValue, forKey: "slskd.serverURL")
  116. }
  117. }
  118. /// Whether the client has URL + credentials configured.
  119. var isConfigured: Bool {
  120. switch serverMode {
  121. case .managed:
  122. return SlskdProcessManager.shared.state == .running
  123. case .external:
  124. let url = UserDefaults.standard.string(forKey: "slskd.serverURL") ?? ""
  125. return !url.isEmpty && SlskdCredentials.shared.hasCredentials
  126. }
  127. }
  128. // MARK: - Private State
  129. private let session: URLSession
  130. private let decoder: JSONDecoder
  131. /// A-1: Store JWT as Data for explicit zeroing on invalidation.
  132. private var jwtTokenData: Data?
  133. private var tokenExpiry: Date?
  134. // Circuit breaker for auth failures (C-1)
  135. private var consecutiveAuthFailures = 0
  136. private var authBackoffUntil: Date?
  137. /// Maximum consecutive auth failures before the circuit breaker opens.
  138. static let maxAuthRetries = 3
  139. init() {
  140. let config = URLSessionConfiguration.default
  141. config.timeoutIntervalForRequest = 30
  142. config.timeoutIntervalForResource = 120
  143. self.session = URLSession(configuration: config)
  144. self.decoder = JSONDecoder()
  145. }
  146. // MARK: - Authentication
  147. /// Authenticate with slskd and cache the JWT token.
  148. func authenticate() async throws {
  149. // C-1: Circuit breaker — stop retrying after maxAuthRetries consecutive failures
  150. if consecutiveAuthFailures >= Self.maxAuthRetries {
  151. throw SlskdError.authCircuitOpen(failures: consecutiveAuthFailures)
  152. }
  153. // C-1: Exponential backoff — wait before retrying
  154. if let backoffUntil = authBackoffUntil, Date() < backoffUntil {
  155. throw SlskdError.authCircuitOpen(failures: consecutiveAuthFailures)
  156. }
  157. let username: String
  158. let password: String
  159. switch serverMode {
  160. case .managed:
  161. guard let u = ManagedSlskdCredentials.shared.username,
  162. let p = ManagedSlskdCredentials.shared.password else {
  163. throw SlskdError.notConfigured
  164. }
  165. username = u
  166. password = p
  167. case .external:
  168. guard let u = SlskdCredentials.shared.username,
  169. let p = SlskdCredentials.shared.password else {
  170. throw SlskdError.notConfigured
  171. }
  172. username = u
  173. password = p
  174. }
  175. let loginReq = SlskdLoginRequest(username: username, password: password)
  176. let data = try JSONEncoder().encode(loginReq)
  177. let url = try buildURL("api/v0/session")
  178. var request = URLRequest(url: url)
  179. request.httpMethod = "POST"
  180. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  181. request.httpBody = data
  182. let (responseData, httpResponse) = try await performRequest(request)
  183. guard httpResponse.statusCode == 200 else {
  184. if httpResponse.statusCode == 401 {
  185. // C-1: Track failure and set exponential backoff
  186. consecutiveAuthFailures += 1
  187. let backoffSeconds = pow(2.0, Double(consecutiveAuthFailures))
  188. authBackoffUntil = Date().addingTimeInterval(backoffSeconds)
  189. throw SlskdError.unauthorized
  190. }
  191. throw SlskdError.httpError(httpResponse.statusCode)
  192. }
  193. // C-1: Reset circuit breaker on success
  194. consecutiveAuthFailures = 0
  195. authBackoffUntil = nil
  196. let loginResp = try decoder.decode(SlskdLoginResponse.self, from: responseData)
  197. jwtTokenData = Data(loginResp.token.utf8)
  198. // slskd tokens last ~24h. Refresh after 20h to be safe.
  199. tokenExpiry = Date().addingTimeInterval(20 * 3600)
  200. }
  201. /// Ensure we have a valid JWT, refreshing if needed.
  202. private func ensureAuthenticated() async throws {
  203. if let expiry = tokenExpiry, Date() < expiry, jwtTokenData != nil {
  204. return
  205. }
  206. try await authenticate()
  207. }
  208. /// Reset the auth circuit breaker (e.g., after user updates credentials).
  209. func resetAuthCircuitBreaker() {
  210. consecutiveAuthFailures = 0
  211. authBackoffUntil = nil
  212. clearToken()
  213. tokenExpiry = nil
  214. }
  215. // MARK: - Search
  216. /// Start a search on the Soulseek network.
  217. /// Returns the search ID for polling.
  218. func startSearch(query: String, responseLimit: Int = 100, timeout: Int = 15) async throws -> String {
  219. try await ensureAuthenticated()
  220. let searchReq = SlskdSearchRequest(
  221. searchText: query,
  222. responseLimit: responseLimit,
  223. timeout: timeout * 1000, // slskd 0.25+ expects milliseconds
  224. filterResponses: true,
  225. minimumResponseFileCount: 1
  226. )
  227. let body = try JSONEncoder().encode(searchReq)
  228. let url = try buildURL("api/v0/searches")
  229. var request = URLRequest(url: url)
  230. request.httpMethod = "POST"
  231. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  232. try applyAuth(&request)
  233. request.httpBody = body
  234. let (data, httpResponse) = try await performRequest(request)
  235. try checkHTTPStatus(httpResponse, data: data)
  236. let search = try decoder.decode(SlskdSearch.self, from: data)
  237. return search.id
  238. }
  239. /// Poll a search by ID. Returns the search state including responses.
  240. func getSearch(id: String) async throws -> SlskdSearch {
  241. try await ensureAuthenticated()
  242. let safeId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
  243. return try await get("api/v0/searches/\(safeId)?includeResponses=true")
  244. }
  245. /// Delete a search to clean up server-side resources.
  246. func deleteSearch(id: String) async throws {
  247. try await ensureAuthenticated()
  248. let safeId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
  249. let url = try buildURL("api/v0/searches/\(safeId)")
  250. var request = URLRequest(url: url)
  251. request.httpMethod = "DELETE"
  252. try applyAuth(&request)
  253. let (_, httpResponse) = try await performRequest(request)
  254. // 200 or 404 are both fine (already deleted)
  255. if httpResponse.statusCode != 200 && httpResponse.statusCode != 404 {
  256. throw SlskdError.httpError(httpResponse.statusCode)
  257. }
  258. }
  259. // MARK: - Downloads
  260. /// Enqueue files for download from a specific user.
  261. func enqueueDownloads(username: String, files: [SlskdFile]) async throws {
  262. try await ensureAuthenticated()
  263. let url = try buildURL("api/v0/transfers/downloads/\(username)")
  264. var request = URLRequest(url: url)
  265. request.httpMethod = "POST"
  266. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  267. try applyAuth(&request)
  268. let downloadReqs = files.map { SlskdDownloadRequest(filename: $0.filename, size: $0.size) }
  269. request.httpBody = try JSONEncoder().encode(downloadReqs)
  270. let (data, httpResponse) = try await performRequest(request)
  271. try checkHTTPStatus(httpResponse, data: data)
  272. }
  273. /// Get all current download transfers.
  274. func getDownloads() async throws -> [SlskdTransferGroup] {
  275. try await ensureAuthenticated()
  276. return try await get("api/v0/transfers/downloads")
  277. }
  278. // MARK: - Server
  279. /// Check server state/connectivity.
  280. func getServerState() async throws -> SlskdServerState {
  281. try await ensureAuthenticated()
  282. return try await get("api/v0/server")
  283. }
  284. /// Tell slskd to connect to the Soulseek network.
  285. func connectToNetwork() async throws {
  286. try await ensureAuthenticated()
  287. let url = try buildURL("api/v0/server")
  288. var request = URLRequest(url: url)
  289. request.httpMethod = "PUT"
  290. try applyAuth(&request)
  291. request.timeoutInterval = 5
  292. let (_, response) = try await performRequest(request)
  293. if response.statusCode != 200 {
  294. throw SlskdError.httpError(response.statusCode)
  295. }
  296. }
  297. /// Quick connectivity test. Returns nil on success, error on failure.
  298. func testConnection() async -> SlskdError? {
  299. do {
  300. try await authenticate()
  301. _ = try await getServerState()
  302. return nil
  303. } catch let error as SlskdError {
  304. return error
  305. } catch {
  306. return .networkError(error)
  307. }
  308. }
  309. // MARK: - Private Helpers
  310. private func buildURL(_ path: String) throws -> URL {
  311. let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  312. guard !trimmed.isEmpty else { throw SlskdError.notConfigured }
  313. let base = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
  314. guard let url = URL(string: base + path) else {
  315. throw SlskdError.notConfigured
  316. }
  317. // A-8: Only allow http/https to prevent file:// or other scheme abuse.
  318. guard let scheme = url.scheme?.lowercased(),
  319. scheme == "http" || scheme == "https" else {
  320. throw SlskdError.notConfigured
  321. }
  322. return url
  323. }
  324. private func applyAuth(_ request: inout URLRequest) throws {
  325. guard let data = jwtTokenData, let token = String(data: data, encoding: .utf8) else {
  326. throw SlskdError.unauthorized
  327. }
  328. request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  329. }
  330. private func performRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
  331. // A-2: Retry idempotent GET requests on transient network failures.
  332. // POST/DELETE are not retried to avoid side-effect duplication.
  333. let maxRetries = (request.httpMethod == "GET") ? 2 : 0
  334. var lastError: Error?
  335. for attempt in 0...maxRetries {
  336. if attempt > 0 {
  337. // Exponential backoff: 1s, 2s
  338. let backoff = UInt64(pow(2.0, Double(attempt - 1))) * 1_000_000_000
  339. try? await Task.sleep(nanoseconds: backoff)
  340. try Task.checkCancellation()
  341. }
  342. do {
  343. let (data, response) = try await session.data(for: request)
  344. guard let httpResponse = response as? HTTPURLResponse else {
  345. throw SlskdError.invalidResponse
  346. }
  347. return (data, httpResponse)
  348. } catch is CancellationError {
  349. throw CancellationError()
  350. } catch let error as SlskdError {
  351. throw error // Our own errors — don't retry
  352. } catch {
  353. lastError = error
  354. continue // Transient URLError — retry
  355. }
  356. }
  357. throw SlskdError.networkError(lastError ?? URLError(.unknown))
  358. }
  359. private func checkHTTPStatus(_ response: HTTPURLResponse, data: Data) throws {
  360. switch response.statusCode {
  361. case 200..<300:
  362. return
  363. case 401:
  364. clearToken()
  365. tokenExpiry = nil
  366. throw SlskdError.unauthorized
  367. case 429:
  368. throw SlskdError.rateLimited
  369. default:
  370. throw SlskdError.httpError(response.statusCode)
  371. }
  372. }
  373. /// A-1: Zero-out the JWT token data before releasing it.
  374. private func clearToken() {
  375. if var data = jwtTokenData {
  376. let count = data.count
  377. data.withUnsafeMutableBytes { ptr in
  378. if let base = ptr.baseAddress {
  379. memset(base, 0, count)
  380. }
  381. }
  382. }
  383. jwtTokenData = nil
  384. }
  385. private func get<T: Decodable>(_ path: String) async throws -> T {
  386. let url = try buildURL(path)
  387. var request = URLRequest(url: url)
  388. request.httpMethod = "GET"
  389. try applyAuth(&request)
  390. let (data, httpResponse) = try await performRequest(request)
  391. try checkHTTPStatus(httpResponse, data: data)
  392. do {
  393. return try decoder.decode(T.self, from: data)
  394. } catch {
  395. throw SlskdError.decodingFailed(error)
  396. }
  397. }
  398. }
  399. // MARK: - SlskdError
  400. enum SlskdError: LocalizedError {
  401. case notConfigured
  402. case unauthorized
  403. case authCircuitOpen(failures: Int)
  404. case rateLimited
  405. case httpError(Int)
  406. case invalidResponse
  407. case decodingFailed(Error)
  408. case networkError(Error)
  409. case searchTimeout
  410. case noResults
  411. case noQualityMatch
  412. case downloadFailed(String)
  413. case importTimeout
  414. var errorDescription: String? {
  415. switch self {
  416. case .notConfigured:
  417. "Soulseek not configured. Set the server URL and credentials in Settings."
  418. case .unauthorized:
  419. "Soulseek authentication failed (invalid credentials)."
  420. case .authCircuitOpen(let failures):
  421. "Soulseek authentication suspended after \(failures) consecutive failures. Check credentials in Settings."
  422. case .rateLimited:
  423. "Soulseek rate limited — too many requests. Try again shortly."
  424. case .httpError(let code):
  425. "Soulseek server returned HTTP \(code)."
  426. case .invalidResponse:
  427. "Invalid response from Soulseek server."
  428. case .decodingFailed(let error):
  429. "Failed to decode Soulseek response: \(error.localizedDescription)"
  430. case .networkError(let error):
  431. "Network error connecting to Soulseek: \(error.localizedDescription)"
  432. case .searchTimeout:
  433. "Soulseek search timed out after 30 seconds."
  434. case .noResults:
  435. "No Soulseek results found for this album."
  436. case .noQualityMatch:
  437. "No Soulseek source met the quality threshold (need score >= 80)."
  438. case .downloadFailed(let detail):
  439. "Soulseek download failed: \(detail)"
  440. case .importTimeout:
  441. "Import timed out — album may not be in library. Files are downloaded; try rescanning manually."
  442. }
  443. }
  444. }