SlskdProcessManager.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import Foundation
  2. import os.log
  3. // MARK: - Slskd Process Manager
  4. /// Manages the slskd binary as a subprocess: download, configure, launch, monitor, stop.
  5. /// In managed mode, MixBoard downloads slskd on first run and controls it automatically.
  6. @MainActor
  7. @Observable
  8. final class SlskdProcessManager {
  9. static let shared = SlskdProcessManager()
  10. // MARK: - State
  11. enum State: Equatable {
  12. case stopped
  13. case downloading(progress: Double)
  14. case starting
  15. case running
  16. case failed(String)
  17. static func == (lhs: State, rhs: State) -> Bool {
  18. switch (lhs, rhs) {
  19. case (.stopped, .stopped), (.starting, .starting), (.running, .running):
  20. return true
  21. case (.downloading(let a), .downloading(let b)):
  22. return a == b
  23. case (.failed(let a), .failed(let b)):
  24. return a == b
  25. default:
  26. return false
  27. }
  28. }
  29. }
  30. private(set) var state: State = .stopped
  31. // MARK: - Configuration
  32. static let slskdVersion = "0.24.5"
  33. static let port = 5030
  34. static let downloadURL = URL(string: "https://github.com/slskd/slskd/releases/download/\(slskdVersion)/slskd-\(slskdVersion)-osx-arm64.zip")!
  35. private let logger = Logger(subsystem: "com.mixboard.slskd-daemon", category: "ProcessManager")
  36. // MARK: - Paths
  37. /// ~/Library/Application Support/MixBoard/slskd/
  38. var baseDir: URL {
  39. let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
  40. return appSupport.appendingPathComponent("MixBoard/slskd", isDirectory: true)
  41. }
  42. var binDir: URL { baseDir.appendingPathComponent("bin", isDirectory: true) }
  43. var binaryPath: URL { binDir.appendingPathComponent("slskd") }
  44. var configDir: URL { baseDir.appendingPathComponent("config", isDirectory: true) }
  45. var configPath: URL { configDir.appendingPathComponent("slskd.yml") }
  46. var dataDir: URL { baseDir.appendingPathComponent("data", isDirectory: true) }
  47. var downloadsDir: URL { baseDir.appendingPathComponent("downloads", isDirectory: true) }
  48. var incompleteDir: URL { baseDir.appendingPathComponent("incomplete", isDirectory: true) }
  49. /// Whether the slskd binary exists on disk.
  50. var isBinaryInstalled: Bool {
  51. FileManager.default.fileExists(atPath: binaryPath.path)
  52. }
  53. // MARK: - Process
  54. private var process: Process?
  55. private var stdoutPipe: Pipe?
  56. private var stderrPipe: Pipe?
  57. // MARK: - Lifecycle
  58. /// Full startup sequence: ensure binary → generate config → launch → wait for ready.
  59. func start() async throws {
  60. guard state != .running && state != .starting else {
  61. logger.info("Already running or starting, skipping start()")
  62. return
  63. }
  64. do {
  65. // Step 1: Ensure binary
  66. if !isBinaryInstalled {
  67. state = .downloading(progress: 0)
  68. try await downloadBinary()
  69. }
  70. // Step 2: Generate config
  71. state = .starting
  72. try generateConfig()
  73. // Step 3: Launch process
  74. try launchProcess()
  75. // Step 4: Wait for API ready
  76. try await waitForReady()
  77. state = .running
  78. logger.info("slskd started successfully on port \(Self.port)")
  79. } catch {
  80. state = .failed(error.localizedDescription)
  81. logger.error("Failed to start slskd: \(error.localizedDescription)")
  82. throw error
  83. }
  84. }
  85. /// Stop the managed slskd process.
  86. func stop() {
  87. guard let process = process, process.isRunning else {
  88. state = .stopped
  89. return
  90. }
  91. logger.info("Stopping slskd (SIGTERM)...")
  92. process.terminate() // SIGTERM
  93. // Give it 5 seconds, then SIGKILL
  94. DispatchQueue.global().asyncAfter(deadline: .now() + 5) { [weak self] in
  95. if process.isRunning {
  96. self?.logger.warning("slskd did not stop after 5s, sending SIGKILL")
  97. kill(process.processIdentifier, SIGKILL)
  98. }
  99. }
  100. process.waitUntilExit()
  101. self.process = nil
  102. state = .stopped
  103. logger.info("slskd stopped")
  104. }
  105. /// Kill any orphaned slskd processes from previous runs.
  106. func cleanupOrphans() {
  107. let findProcess = Process()
  108. findProcess.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
  109. findProcess.arguments = ["-f", binaryPath.path]
  110. let pipe = Pipe()
  111. findProcess.standardOutput = pipe
  112. do {
  113. try findProcess.run()
  114. findProcess.waitUntilExit()
  115. let data = pipe.fileHandleForReading.readDataToEndOfFile()
  116. if let output = String(data: data, encoding: .utf8) {
  117. let pids = output.split(separator: "\n").compactMap { Int32($0.trimmingCharacters(in: .whitespaces)) }
  118. for pid in pids {
  119. logger.info("Killing orphaned slskd process: \(pid)")
  120. kill(pid, SIGTERM)
  121. }
  122. }
  123. } catch {
  124. logger.warning("Failed to check for orphaned processes: \(error.localizedDescription)")
  125. }
  126. }
  127. // MARK: - Binary Download
  128. private func downloadBinary() async throws {
  129. let fm = FileManager.default
  130. try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
  131. logger.info("Downloading slskd \(Self.slskdVersion)...")
  132. let (tempURL, response) = try await URLSession.shared.download(from: Self.downloadURL)
  133. guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
  134. throw SlskdProcessError.downloadFailed("HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)")
  135. }
  136. state = .downloading(progress: 1.0)
  137. // Unzip
  138. let unzipDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
  139. try fm.createDirectory(at: unzipDir, withIntermediateDirectories: true)
  140. let unzipProcess = Process()
  141. unzipProcess.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
  142. unzipProcess.arguments = ["-o", tempURL.path, "-d", unzipDir.path]
  143. unzipProcess.standardOutput = FileHandle.nullDevice
  144. unzipProcess.standardError = FileHandle.nullDevice
  145. try unzipProcess.run()
  146. unzipProcess.waitUntilExit()
  147. guard unzipProcess.terminationStatus == 0 else {
  148. throw SlskdProcessError.downloadFailed("Unzip failed with status \(unzipProcess.terminationStatus)")
  149. }
  150. // Find the slskd binary in extracted contents
  151. let extractedBinary = try findBinary(in: unzipDir)
  152. // Move to final location
  153. if fm.fileExists(atPath: binaryPath.path) {
  154. try fm.removeItem(at: binaryPath)
  155. }
  156. try fm.moveItem(at: extractedBinary, to: binaryPath)
  157. // Make executable
  158. try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryPath.path)
  159. // Remove quarantine
  160. let xattrProcess = Process()
  161. xattrProcess.executableURL = URL(fileURLWithPath: "/usr/bin/xattr")
  162. xattrProcess.arguments = ["-rd", "com.apple.quarantine", binaryPath.path]
  163. xattrProcess.standardOutput = FileHandle.nullDevice
  164. xattrProcess.standardError = FileHandle.nullDevice
  165. try? xattrProcess.run()
  166. xattrProcess.waitUntilExit()
  167. // Cleanup temp
  168. try? fm.removeItem(at: unzipDir)
  169. try? fm.removeItem(at: tempURL)
  170. logger.info("slskd binary installed at \(self.binaryPath.path)")
  171. }
  172. /// Locate the slskd binary in extracted zip contents.
  173. private func findBinary(in directory: URL) throws -> URL {
  174. let fm = FileManager.default
  175. // Direct: directory/slskd
  176. let direct = directory.appendingPathComponent("slskd")
  177. if fm.fileExists(atPath: direct.path) { return direct }
  178. // One level deep (e.g., slskd-0.24.5-osx-arm64/slskd)
  179. if let contents = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) {
  180. for item in contents {
  181. var isDir: ObjCBool = false
  182. if fm.fileExists(atPath: item.path, isDirectory: &isDir), isDir.boolValue {
  183. let nested = item.appendingPathComponent("slskd")
  184. if fm.fileExists(atPath: nested.path) { return nested }
  185. }
  186. }
  187. }
  188. throw SlskdProcessError.downloadFailed("Could not find slskd binary in extracted archive")
  189. }
  190. // MARK: - Config Generation
  191. /// Generate slskd.yml with managed credentials and localhost binding.
  192. private func generateConfig() throws {
  193. let fm = FileManager.default
  194. try fm.createDirectory(at: configDir, withIntermediateDirectories: true)
  195. try fm.createDirectory(at: dataDir, withIntermediateDirectories: true)
  196. try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
  197. try fm.createDirectory(at: incompleteDir, withIntermediateDirectories: true)
  198. let creds = try ManagedSlskdCredentials.shared.ensureCredentials()
  199. // Soulseek P2P credentials (user-provided, stored in original keychain)
  200. let soulseekUser = SlskdCredentials.shared.username ?? ""
  201. let soulseekPass = SlskdCredentials.shared.password ?? ""
  202. let config = """
  203. # Auto-generated by MixBoard. Do not edit manually.
  204. instance_name: mixboard
  205. web:
  206. port: \(Self.port)
  207. authentication:
  208. disabled: false
  209. username: \(creds.username)
  210. password: \(creds.password)
  211. soulseek:
  212. username: \(soulseekUser)
  213. password: \(soulseekPass)
  214. listen_port: 50300
  215. directories:
  216. incomplete: \(incompleteDir.path)
  217. downloads: \(downloadsDir.path)
  218. flags:
  219. no_logo: true
  220. volatile: false
  221. """
  222. try config.write(to: configPath, atomically: true, encoding: .utf8)
  223. logger.info("Config written to \(self.configPath.path)")
  224. }
  225. // MARK: - Process Launch
  226. private func launchProcess() throws {
  227. cleanupOrphans()
  228. let proc = Process()
  229. proc.executableURL = binaryPath
  230. proc.arguments = ["--config", configPath.path, "--app-dir", dataDir.path]
  231. // Capture output for logging
  232. let stdout = Pipe()
  233. let stderr = Pipe()
  234. proc.standardOutput = stdout
  235. proc.standardError = stderr
  236. self.stdoutPipe = stdout
  237. self.stderrPipe = stderr
  238. // Log stdout
  239. stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in
  240. let data = handle.availableData
  241. guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
  242. self?.logger.debug("slskd: \(line.trimmingCharacters(in: .whitespacesAndNewlines))")
  243. }
  244. // Log stderr
  245. stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in
  246. let data = handle.availableData
  247. guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
  248. self?.logger.warning("slskd stderr: \(line.trimmingCharacters(in: .whitespacesAndNewlines))")
  249. }
  250. // Handle unexpected termination
  251. proc.terminationHandler = { [weak self] process in
  252. Task { @MainActor in
  253. guard let self = self else { return }
  254. if self.state == .running {
  255. self.state = .failed("slskd exited unexpectedly (code \(process.terminationStatus))")
  256. self.logger.error("slskd terminated unexpectedly with status \(process.terminationStatus)")
  257. }
  258. self.process = nil
  259. }
  260. }
  261. try proc.run()
  262. self.process = proc
  263. logger.info("slskd launched (PID \(proc.processIdentifier))")
  264. }
  265. // MARK: - Health Check
  266. /// Poll the slskd API until it responds, or timeout after 15 seconds.
  267. private func waitForReady() async throws {
  268. let deadline = Date().addingTimeInterval(15)
  269. let url = URL(string: "http://localhost:\(Self.port)/api/v0/server")!
  270. var request = URLRequest(url: url)
  271. request.timeoutInterval = 2
  272. while Date() < deadline {
  273. try Task.checkCancellation()
  274. do {
  275. let (_, response) = try await URLSession.shared.data(for: request)
  276. if let http = response as? HTTPURLResponse, (200...499).contains(http.statusCode) {
  277. // Any HTTP response means slskd is up (even 401 is fine — means auth works)
  278. return
  279. }
  280. } catch {
  281. // Connection refused, keep polling
  282. }
  283. try await Task.sleep(for: .milliseconds(500))
  284. }
  285. throw SlskdProcessError.startupTimeout
  286. }
  287. }
  288. // MARK: - Errors
  289. enum SlskdProcessError: LocalizedError {
  290. case downloadFailed(String)
  291. case startupTimeout
  292. case binaryNotFound
  293. var errorDescription: String? {
  294. switch self {
  295. case .downloadFailed(let reason): return "Failed to download slskd: \(reason)"
  296. case .startupTimeout: return "slskd did not respond within 15 seconds"
  297. case .binaryNotFound: return "slskd binary not found"
  298. }
  299. }
  300. }