SlskdAPIClient.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  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 username and password stored in 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. searchTimeout: timeout,
  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. return try await get("api/v0/searches/\(id)")
  243. }
  244. /// Delete a search to clean up server-side resources.
  245. func deleteSearch(id: String) async throws {
  246. try await ensureAuthenticated()
  247. let url = try buildURL("api/v0/searches/\(id)")
  248. var request = URLRequest(url: url)
  249. request.httpMethod = "DELETE"
  250. try applyAuth(&request)
  251. let (_, httpResponse) = try await performRequest(request)
  252. // 200 or 404 are both fine (already deleted)
  253. if httpResponse.statusCode != 200 && httpResponse.statusCode != 404 {
  254. throw SlskdError.httpError(httpResponse.statusCode)
  255. }
  256. }
  257. // MARK: - Downloads
  258. /// Enqueue files for download from a specific user.
  259. func enqueueDownloads(username: String, files: [SlskdFile]) async throws {
  260. try await ensureAuthenticated()
  261. let url = try buildURL("api/v0/transfers/downloads/\(username)")
  262. var request = URLRequest(url: url)
  263. request.httpMethod = "POST"
  264. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  265. try applyAuth(&request)
  266. let downloadReqs = files.map { SlskdDownloadRequest(filename: $0.filename, size: $0.size) }
  267. request.httpBody = try JSONEncoder().encode(downloadReqs)
  268. let (data, httpResponse) = try await performRequest(request)
  269. try checkHTTPStatus(httpResponse, data: data)
  270. }
  271. /// Get all current download transfers.
  272. func getDownloads() async throws -> [SlskdTransferGroup] {
  273. try await ensureAuthenticated()
  274. return try await get("api/v0/transfers/downloads")
  275. }
  276. // MARK: - Server
  277. /// Check server state/connectivity.
  278. func getServerState() async throws -> SlskdServerState {
  279. try await ensureAuthenticated()
  280. return try await get("api/v0/server")
  281. }
  282. /// Quick connectivity test. Returns nil on success, error on failure.
  283. func testConnection() async -> SlskdError? {
  284. do {
  285. try await authenticate()
  286. _ = try await getServerState()
  287. return nil
  288. } catch let error as SlskdError {
  289. return error
  290. } catch {
  291. return .networkError(error)
  292. }
  293. }
  294. // MARK: - Private Helpers
  295. private func buildURL(_ path: String) throws -> URL {
  296. let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  297. guard !trimmed.isEmpty else { throw SlskdError.notConfigured }
  298. let base = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
  299. guard let url = URL(string: base + path) else {
  300. throw SlskdError.notConfigured
  301. }
  302. // A-8: Only allow http/https to prevent file:// or other scheme abuse.
  303. guard let scheme = url.scheme?.lowercased(),
  304. scheme == "http" || scheme == "https" else {
  305. throw SlskdError.notConfigured
  306. }
  307. return url
  308. }
  309. private func applyAuth(_ request: inout URLRequest) throws {
  310. guard let data = jwtTokenData, let token = String(data: data, encoding: .utf8) else {
  311. throw SlskdError.unauthorized
  312. }
  313. request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  314. }
  315. private func performRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
  316. // A-2: Retry idempotent GET requests on transient network failures.
  317. // POST/DELETE are not retried to avoid side-effect duplication.
  318. let maxRetries = (request.httpMethod == "GET") ? 2 : 0
  319. var lastError: Error?
  320. for attempt in 0...maxRetries {
  321. if attempt > 0 {
  322. // Exponential backoff: 1s, 2s
  323. let backoff = UInt64(pow(2.0, Double(attempt - 1))) * 1_000_000_000
  324. try? await Task.sleep(nanoseconds: backoff)
  325. try Task.checkCancellation()
  326. }
  327. do {
  328. let (data, response) = try await session.data(for: request)
  329. guard let httpResponse = response as? HTTPURLResponse else {
  330. throw SlskdError.invalidResponse
  331. }
  332. return (data, httpResponse)
  333. } catch is CancellationError {
  334. throw CancellationError()
  335. } catch let error as SlskdError {
  336. throw error // Our own errors — don't retry
  337. } catch {
  338. lastError = error
  339. continue // Transient URLError — retry
  340. }
  341. }
  342. throw SlskdError.networkError(lastError ?? URLError(.unknown))
  343. }
  344. private func checkHTTPStatus(_ response: HTTPURLResponse, data: Data) throws {
  345. switch response.statusCode {
  346. case 200..<300:
  347. return
  348. case 401:
  349. clearToken()
  350. tokenExpiry = nil
  351. throw SlskdError.unauthorized
  352. case 429:
  353. throw SlskdError.rateLimited
  354. default:
  355. throw SlskdError.httpError(response.statusCode)
  356. }
  357. }
  358. /// A-1: Zero-out the JWT token data before releasing it.
  359. private func clearToken() {
  360. if var data = jwtTokenData {
  361. let count = data.count
  362. data.withUnsafeMutableBytes { ptr in
  363. if let base = ptr.baseAddress {
  364. memset(base, 0, count)
  365. }
  366. }
  367. }
  368. jwtTokenData = nil
  369. }
  370. private func get<T: Decodable>(_ path: String) async throws -> T {
  371. let url = try buildURL(path)
  372. var request = URLRequest(url: url)
  373. request.httpMethod = "GET"
  374. try applyAuth(&request)
  375. let (data, httpResponse) = try await performRequest(request)
  376. try checkHTTPStatus(httpResponse, data: data)
  377. do {
  378. return try decoder.decode(T.self, from: data)
  379. } catch {
  380. throw SlskdError.decodingFailed(error)
  381. }
  382. }
  383. }
  384. // MARK: - SlskdError
  385. enum SlskdError: LocalizedError {
  386. case notConfigured
  387. case unauthorized
  388. case authCircuitOpen(failures: Int)
  389. case rateLimited
  390. case httpError(Int)
  391. case invalidResponse
  392. case decodingFailed(Error)
  393. case networkError(Error)
  394. case searchTimeout
  395. case noResults
  396. case noQualityMatch
  397. case downloadFailed(String)
  398. case importTimeout
  399. var errorDescription: String? {
  400. switch self {
  401. case .notConfigured:
  402. "Soulseek not configured. Set the server URL and credentials in Settings."
  403. case .unauthorized:
  404. "Soulseek authentication failed (invalid credentials)."
  405. case .authCircuitOpen(let failures):
  406. "Soulseek authentication suspended after \(failures) consecutive failures. Check credentials in Settings."
  407. case .rateLimited:
  408. "Soulseek rate limited — too many requests. Try again shortly."
  409. case .httpError(let code):
  410. "Soulseek server returned HTTP \(code)."
  411. case .invalidResponse:
  412. "Invalid response from Soulseek server."
  413. case .decodingFailed(let error):
  414. "Failed to decode Soulseek response: \(error.localizedDescription)"
  415. case .networkError(let error):
  416. "Network error connecting to Soulseek: \(error.localizedDescription)"
  417. case .searchTimeout:
  418. "Soulseek search timed out after 30 seconds."
  419. case .noResults:
  420. "No Soulseek results found for this album."
  421. case .noQualityMatch:
  422. "No Soulseek source met the quality threshold (need score >= 80)."
  423. case .downloadFailed(let detail):
  424. "Soulseek download failed: \(detail)"
  425. case .importTimeout:
  426. "Import timed out — album may not be in library. Files are downloaded; try rescanning manually."
  427. }
  428. }
  429. }