| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- import Foundation
- import os.log
- // MARK: - Slskd Process Manager
- /// Manages the slskd binary as a subprocess: download, configure, launch, monitor, stop.
- /// In managed mode, MixBoard downloads slskd on first run and controls it automatically.
- @MainActor
- @Observable
- final class SlskdProcessManager {
- static let shared = SlskdProcessManager()
- // MARK: - State
- enum State: Equatable {
- case stopped
- case downloading(progress: Double)
- case starting
- case running
- case failed(String)
- static func == (lhs: State, rhs: State) -> Bool {
- switch (lhs, rhs) {
- case (.stopped, .stopped), (.starting, .starting), (.running, .running):
- return true
- case (.downloading(let a), .downloading(let b)):
- return a == b
- case (.failed(let a), .failed(let b)):
- return a == b
- default:
- return false
- }
- }
- }
- private(set) var state: State = .stopped
- // MARK: - Configuration
- static let slskdVersion = "0.24.5"
- static let port = 5030
- static let downloadURL = URL(string: "https://github.com/slskd/slskd/releases/download/\(slskdVersion)/slskd-\(slskdVersion)-osx-arm64.zip")!
- private let logger = Logger(subsystem: "com.mixboard.slskd-daemon", category: "ProcessManager")
- // MARK: - Paths
- /// ~/Library/Application Support/MixBoard/slskd/
- var baseDir: URL {
- let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
- return appSupport.appendingPathComponent("MixBoard/slskd", isDirectory: true)
- }
- var binDir: URL { baseDir.appendingPathComponent("bin", isDirectory: true) }
- var binaryPath: URL { binDir.appendingPathComponent("slskd") }
- var configDir: URL { baseDir.appendingPathComponent("config", isDirectory: true) }
- var configPath: URL { configDir.appendingPathComponent("slskd.yml") }
- var dataDir: URL { baseDir.appendingPathComponent("data", isDirectory: true) }
- var downloadsDir: URL { baseDir.appendingPathComponent("downloads", isDirectory: true) }
- var incompleteDir: URL { baseDir.appendingPathComponent("incomplete", isDirectory: true) }
- /// Whether the slskd binary exists on disk.
- var isBinaryInstalled: Bool {
- FileManager.default.fileExists(atPath: binaryPath.path)
- }
- // MARK: - Process
- private var process: Process?
- private var stdoutPipe: Pipe?
- private var stderrPipe: Pipe?
- // MARK: - Lifecycle
- /// Full startup sequence: ensure binary → generate config → launch → wait for ready.
- func start() async throws {
- guard state != .running && state != .starting else {
- logger.info("Already running or starting, skipping start()")
- return
- }
- do {
- // Step 1: Ensure binary
- if !isBinaryInstalled {
- state = .downloading(progress: 0)
- try await downloadBinary()
- }
- // Step 2: Generate config
- state = .starting
- try generateConfig()
- // Step 3: Launch process
- try launchProcess()
- // Step 4: Wait for API ready
- try await waitForReady()
- state = .running
- logger.info("slskd started successfully on port \(Self.port)")
- } catch {
- state = .failed(error.localizedDescription)
- logger.error("Failed to start slskd: \(error.localizedDescription)")
- throw error
- }
- }
- /// Stop the managed slskd process.
- func stop() {
- guard let process = process, process.isRunning else {
- state = .stopped
- return
- }
- logger.info("Stopping slskd (SIGTERM)...")
- process.terminate() // SIGTERM
- // Give it 5 seconds, then SIGKILL
- DispatchQueue.global().asyncAfter(deadline: .now() + 5) { [weak self] in
- if process.isRunning {
- self?.logger.warning("slskd did not stop after 5s, sending SIGKILL")
- kill(process.processIdentifier, SIGKILL)
- }
- }
- process.waitUntilExit()
- self.process = nil
- state = .stopped
- logger.info("slskd stopped")
- }
- /// Kill any orphaned slskd processes from previous runs.
- func cleanupOrphans() {
- let findProcess = Process()
- findProcess.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
- findProcess.arguments = ["-f", binaryPath.path]
- let pipe = Pipe()
- findProcess.standardOutput = pipe
- do {
- try findProcess.run()
- findProcess.waitUntilExit()
- let data = pipe.fileHandleForReading.readDataToEndOfFile()
- if let output = String(data: data, encoding: .utf8) {
- let pids = output.split(separator: "\n").compactMap { Int32($0.trimmingCharacters(in: .whitespaces)) }
- for pid in pids {
- logger.info("Killing orphaned slskd process: \(pid)")
- kill(pid, SIGTERM)
- }
- }
- } catch {
- logger.warning("Failed to check for orphaned processes: \(error.localizedDescription)")
- }
- }
- // MARK: - Binary Download
- private func downloadBinary() async throws {
- let fm = FileManager.default
- try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
- logger.info("Downloading slskd \(Self.slskdVersion)...")
- let (tempURL, response) = try await URLSession.shared.download(from: Self.downloadURL)
- guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
- throw SlskdProcessError.downloadFailed("HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)")
- }
- state = .downloading(progress: 1.0)
- // Unzip
- let unzipDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
- try fm.createDirectory(at: unzipDir, withIntermediateDirectories: true)
- let unzipProcess = Process()
- unzipProcess.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
- unzipProcess.arguments = ["-o", tempURL.path, "-d", unzipDir.path]
- unzipProcess.standardOutput = FileHandle.nullDevice
- unzipProcess.standardError = FileHandle.nullDevice
- try unzipProcess.run()
- unzipProcess.waitUntilExit()
- guard unzipProcess.terminationStatus == 0 else {
- throw SlskdProcessError.downloadFailed("Unzip failed with status \(unzipProcess.terminationStatus)")
- }
- // Find the slskd binary in extracted contents
- let extractedBinary = try findBinary(in: unzipDir)
- // Move to final location
- if fm.fileExists(atPath: binaryPath.path) {
- try fm.removeItem(at: binaryPath)
- }
- try fm.moveItem(at: extractedBinary, to: binaryPath)
- // Make executable
- try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryPath.path)
- // Remove quarantine
- let xattrProcess = Process()
- xattrProcess.executableURL = URL(fileURLWithPath: "/usr/bin/xattr")
- xattrProcess.arguments = ["-rd", "com.apple.quarantine", binaryPath.path]
- xattrProcess.standardOutput = FileHandle.nullDevice
- xattrProcess.standardError = FileHandle.nullDevice
- try? xattrProcess.run()
- xattrProcess.waitUntilExit()
- // Cleanup temp
- try? fm.removeItem(at: unzipDir)
- try? fm.removeItem(at: tempURL)
- logger.info("slskd binary installed at \(self.binaryPath.path)")
- }
- /// Locate the slskd binary in extracted zip contents.
- private func findBinary(in directory: URL) throws -> URL {
- let fm = FileManager.default
- // Direct: directory/slskd
- let direct = directory.appendingPathComponent("slskd")
- if fm.fileExists(atPath: direct.path) { return direct }
- // One level deep (e.g., slskd-0.24.5-osx-arm64/slskd)
- if let contents = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) {
- for item in contents {
- var isDir: ObjCBool = false
- if fm.fileExists(atPath: item.path, isDirectory: &isDir), isDir.boolValue {
- let nested = item.appendingPathComponent("slskd")
- if fm.fileExists(atPath: nested.path) { return nested }
- }
- }
- }
- throw SlskdProcessError.downloadFailed("Could not find slskd binary in extracted archive")
- }
- // MARK: - Config Generation
- /// Generate slskd.yml with managed credentials and localhost binding.
- private func generateConfig() throws {
- let fm = FileManager.default
- try fm.createDirectory(at: configDir, withIntermediateDirectories: true)
- try fm.createDirectory(at: dataDir, withIntermediateDirectories: true)
- try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
- try fm.createDirectory(at: incompleteDir, withIntermediateDirectories: true)
- let creds = try ManagedSlskdCredentials.shared.ensureCredentials()
- // Soulseek P2P credentials (user-provided, stored in original keychain)
- let soulseekUser = SlskdCredentials.shared.username ?? ""
- let soulseekPass = SlskdCredentials.shared.password ?? ""
- let config = """
- # Auto-generated by MixBoard. Do not edit manually.
- instance_name: mixboard
- web:
- port: \(Self.port)
- authentication:
- disabled: false
- username: \(creds.username)
- password: \(creds.password)
- soulseek:
- username: \(soulseekUser)
- password: \(soulseekPass)
- listen_port: 50300
- directories:
- incomplete: \(incompleteDir.path)
- downloads: \(downloadsDir.path)
- flags:
- no_logo: true
- volatile: false
- """
- try config.write(to: configPath, atomically: true, encoding: .utf8)
- logger.info("Config written to \(self.configPath.path)")
- }
- // MARK: - Process Launch
- private func launchProcess() throws {
- cleanupOrphans()
- let proc = Process()
- proc.executableURL = binaryPath
- proc.arguments = ["--config", configPath.path, "--app-dir", dataDir.path]
- // Capture output for logging
- let stdout = Pipe()
- let stderr = Pipe()
- proc.standardOutput = stdout
- proc.standardError = stderr
- self.stdoutPipe = stdout
- self.stderrPipe = stderr
- // Log stdout
- stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in
- let data = handle.availableData
- guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
- self?.logger.debug("slskd: \(line.trimmingCharacters(in: .whitespacesAndNewlines))")
- }
- // Log stderr
- stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in
- let data = handle.availableData
- guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
- self?.logger.warning("slskd stderr: \(line.trimmingCharacters(in: .whitespacesAndNewlines))")
- }
- // Handle unexpected termination
- proc.terminationHandler = { [weak self] process in
- Task { @MainActor in
- guard let self = self else { return }
- if self.state == .running {
- self.state = .failed("slskd exited unexpectedly (code \(process.terminationStatus))")
- self.logger.error("slskd terminated unexpectedly with status \(process.terminationStatus)")
- }
- self.process = nil
- }
- }
- try proc.run()
- self.process = proc
- logger.info("slskd launched (PID \(proc.processIdentifier))")
- }
- // MARK: - Health Check
- /// Poll the slskd API until it responds, or timeout after 15 seconds.
- private func waitForReady() async throws {
- let deadline = Date().addingTimeInterval(15)
- let url = URL(string: "http://localhost:\(Self.port)/api/v0/server")!
- var request = URLRequest(url: url)
- request.timeoutInterval = 2
- while Date() < deadline {
- try Task.checkCancellation()
- do {
- let (_, response) = try await URLSession.shared.data(for: request)
- if let http = response as? HTTPURLResponse, (200...499).contains(http.statusCode) {
- // Any HTTP response means slskd is up (even 401 is fine — means auth works)
- return
- }
- } catch {
- // Connection refused, keep polling
- }
- try await Task.sleep(for: .milliseconds(500))
- }
- throw SlskdProcessError.startupTimeout
- }
- }
- // MARK: - Errors
- enum SlskdProcessError: LocalizedError {
- case downloadFailed(String)
- case startupTimeout
- case binaryNotFound
- var errorDescription: String? {
- switch self {
- case .downloadFailed(let reason): return "Failed to download slskd: \(reason)"
- case .startupTimeout: return "slskd did not respond within 15 seconds"
- case .binaryNotFound: return "slskd binary not found"
- }
- }
- }
|