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