Selaa lähdekoodia

feat: bundle slskd as managed subprocess for zero-config Soulseek

Eliminates the need to manually run slskd externally. MixBoard now
downloads the slskd binary on first launch, auto-generates config and
API credentials, and manages the process lifecycle (start on app
launch, stop on quit, orphan cleanup).

New: SlskdProcessManager, ManagedSlskdCredentials
Changed: SlskdAPIClient (managed/external mode), MixBoardApp (AppDelegate
lifecycle), SettingsView (mode picker + status indicator)

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
aldiss 2 kuukautta sitten
vanhempi
commit
a383a577ed

+ 8 - 0
MixBoard.xcodeproj/project.pbxproj

@@ -48,6 +48,7 @@
 		691A0746845CBD34C766E634 /* PlaylistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39B366B5B7D28F1310EE4C4 /* PlaylistViewModel.swift */; };
 		6B9B61C578BF56C923C2B4E3 /* QueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB4D92D99DAB7F01E39A0C5 /* QueueView.swift */; };
 		6C71B39EA00C5E9579EF6C7C /* NowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C91BFDC4EF6125CE0A92C365 /* NowPlayingView.swift */; };
+		6CE1660EB2326325AD3BCDAA /* SlskdProcessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2492DF5B6EB59FBF9FF62D /* SlskdProcessManager.swift */; };
 		6E8E6342167F74728BB11860 /* DAWExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7043BDA9D01825F1EF0F92D2 /* DAWExporter.swift */; };
 		6F07724BA21094C476EB0660 /* QueueEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650860D291BDC75B9B814C29 /* QueueEntry.swift */; };
 		735062052406557AD5EA269A /* MediaKeyHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CB9510A25DFF0B3E7AA99E /* MediaKeyHandler.swift */; };
@@ -71,6 +72,7 @@
 		B071D5E1F39AA70316FA4FDF /* BPMDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83791DE60BF73B44B44CF598 /* BPMDetector.swift */; };
 		B1168E099BF810B143F9CECD /* E2EWorkflowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1CD85068EDFB342EF0A571 /* E2EWorkflowTests.swift */; };
 		B19F5B2E4587252976BE904E /* SyncImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3051FEE675462F2B77A356FC /* SyncImporter.swift */; };
+		B2EAE0075293664E8E250DFF /* ManagedSlskdCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 548313799995EA4B17EE70B7 /* ManagedSlskdCredentials.swift */; };
 		BA52D57A925349BFDA049016 /* PlaylistDownloadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46FC27EACD460EB3137577FA /* PlaylistDownloadButton.swift */; };
 		BBDBF015E5A87A280717955F /* DJComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0942017A8C2411E4EC0EEF8 /* DJComponents.swift */; };
 		BC4B737A991DACEEE6075B68 /* AlbumDownloadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0150B5D9D8819CC2CC9D7FD /* AlbumDownloadButton.swift */; };
@@ -150,6 +152,7 @@
 		43EB89D9BE52F78353EF5094 /* MixBoardUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixBoardUITests.swift; sourceTree = "<group>"; };
 		46FC27EACD460EB3137577FA /* PlaylistDownloadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistDownloadButton.swift; sourceTree = "<group>"; };
 		4E30AA6107E4CCFDBA53EF0F /* DownloadIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadIndicator.swift; sourceTree = "<group>"; };
+		548313799995EA4B17EE70B7 /* ManagedSlskdCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedSlskdCredentials.swift; sourceTree = "<group>"; };
 		586499B8088E26103E29799F /* StreamingPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingPlayer.swift; sourceTree = "<group>"; };
 		5A1CD85068EDFB342EF0A571 /* E2EWorkflowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = E2EWorkflowTests.swift; sourceTree = "<group>"; };
 		650860D291BDC75B9B814C29 /* QueueEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueEntry.swift; sourceTree = "<group>"; };
@@ -186,6 +189,7 @@
 		C60DD8D66C431F8FACC440AB /* SidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSection.swift; sourceTree = "<group>"; };
 		C7D0D080C52F79B6A9C8F7E8 /* UnifiedSearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedSearchResultsView.swift; sourceTree = "<group>"; };
 		C91BFDC4EF6125CE0A92C365 /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = "<group>"; };
+		CC2492DF5B6EB59FBF9FF62D /* SlskdProcessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlskdProcessManager.swift; sourceTree = "<group>"; };
 		D0775318FF25759713C3063D /* AppIconConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconConfig.swift; sourceTree = "<group>"; };
 		D29A1F4EF5FB5ACA4CCA4BBF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		D46AA7D36F9C279C726D8DC8 /* ChadMusicCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChadMusicCredentials.swift; sourceTree = "<group>"; };
@@ -285,11 +289,13 @@
 				B942F3DDAA7611C76AA6287B /* LibraryManager.swift */,
 				2330A5CD9FEB6CF1200D4E8A /* LRCLIBService.swift */,
 				24ADE9A538A9797BE2D7862B /* LyricsParser.swift */,
+				548313799995EA4B17EE70B7 /* ManagedSlskdCredentials.swift */,
 				D5CB9510A25DFF0B3E7AA99E /* MediaKeyHandler.swift */,
 				C186E4E5E5FE2F3C87A1B03C /* MetadataService.swift */,
 				BEA3BE9F559194BD6A8DBFC1 /* OGGDecoder.swift */,
 				02703297C897DF62E82BCFD9 /* ProgressDownloader.swift */,
 				FAC3FB0F3E03999E21E95E25 /* SlskdAPIClient.swift */,
+				CC2492DF5B6EB59FBF9FF62D /* SlskdProcessManager.swift */,
 				6C91A932B3430E3B6C07A88E /* SoulseekOrchestrator.swift */,
 				586499B8088E26103E29799F /* StreamingPlayer.swift */,
 				3051FEE675462F2B77A356FC /* SyncImporter.swift */,
@@ -575,6 +581,7 @@
 				8CEE003726D0A7A94B0F2A62 /* LibraryManager.swift in Sources */,
 				58718BAD0FD35D0D999F7C43 /* LyricsParser.swift in Sources */,
 				1F5879AF2B534B9D146D4AEC /* M3UExporter.swift in Sources */,
+				B2EAE0075293664E8E250DFF /* ManagedSlskdCredentials.swift in Sources */,
 				735062052406557AD5EA269A /* MediaKeyHandler.swift in Sources */,
 				DD8CAE7B23CD799AF8D4934F /* MetadataService.swift in Sources */,
 				289A2312A2E8CAC34308F7FB /* MixBoardApp.swift in Sources */,
@@ -597,6 +604,7 @@
 				57994E3E18195FD31CBDC82B /* SidebarView.swift in Sources */,
 				625302B6373DEFBB19CDA5B3 /* SlskdAPIClient.swift in Sources */,
 				14287785755BAB2B7AC1FA8B /* SlskdModels.swift in Sources */,
+				6CE1660EB2326325AD3BCDAA /* SlskdProcessManager.swift in Sources */,
 				838BFA9D25D1D9FD7729FF8D /* SoulseekOrchestrator.swift in Sources */,
 				88BFFA594A1BB6BFF3D0AA82 /* StreamingPlayer.swift in Sources */,
 				B19F5B2E4587252976BE904E /* SyncImporter.swift in Sources */,

+ 18 - 0
Sources/MixBoardApp.swift

@@ -4,6 +4,7 @@ import SwiftUI
 /// MixBoard — A macOS music player and mix preparation tool with DAW export.
 @main
 struct MixBoardApp: App {
+    @NSApplicationDelegateAdaptor(MixBoardAppDelegate.self) var appDelegate
     @State private var playerVM = PlayerViewModel()
     @State private var playlistVM = PlaylistViewModel()
     @StateObject private var libraryManager = LibraryManager()
@@ -27,6 +28,13 @@ struct MixBoardApp: App {
                     syncWatcher.createSyncFolders()
                     syncWatcher.startWatching()
                     AppIconConfig.shared.applyIcon()
+
+                    // Start managed slskd if configured
+                    if SlskdAPIClient.shared.serverMode == .managed {
+                        Task {
+                            try? await SlskdProcessManager.shared.start()
+                        }
+                    }
                 }
         }
         .modelContainer(for: [Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self])
@@ -225,3 +233,13 @@ extension Notification.Name {
     static let closeInlineNowPlaying = Notification.Name("closeInlineNowPlaying")
     static let doubleClickPlayTrack = Notification.Name("doubleClickPlayTrack")
 }
+
+// MARK: - App Delegate
+
+/// Handles app lifecycle events that SwiftUI doesn't expose (e.g., termination).
+class MixBoardAppDelegate: NSObject, NSApplicationDelegate {
+    func applicationWillTerminate(_ notification: Notification) {
+        // Stop managed slskd on app quit to avoid orphaned processes
+        SlskdProcessManager.shared.stop()
+    }
+}

+ 76 - 0
Sources/Services/ManagedSlskdCredentials.swift

@@ -0,0 +1,76 @@
+import Foundation
+import Security
+
+// MARK: - Managed slskd API Credentials
+
+/// Auto-generated credentials for the managed slskd instance.
+/// Separate Keychain service from SlskdCredentials (which stores Soulseek P2P login).
+enum ManagedSlskdKeychainService {
+    private static let service = "com.mixboard.slskd-managed"
+
+    static func save(account: String, value: String) throws {
+        guard let data = value.data(using: .utf8) else {
+            throw KeychainService.KeychainError.saveFailed(errSecParam)
+        }
+
+        let deleteQuery: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account,
+        ]
+        SecItemDelete(deleteQuery as CFDictionary)
+
+        let addQuery: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account,
+            kSecValueData as String: data,
+            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
+        ]
+        let status = SecItemAdd(addQuery as CFDictionary, nil)
+        guard status == errSecSuccess else {
+            throw KeychainService.KeychainError.saveFailed(status)
+        }
+    }
+
+    static func load(account: String) -> String? {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecReturnData as String: true,
+            kSecMatchLimit as String: kSecMatchLimitOne,
+            kSecAttrAccount as String: account,
+        ]
+        var result: AnyObject?
+        let status = SecItemCopyMatching(query as CFDictionary, &result)
+        guard status == errSecSuccess, let data = result as? Data else { return nil }
+        return String(data: data, encoding: .utf8)
+    }
+}
+
+/// Manages auto-generated API credentials for the managed slskd subprocess.
+/// On first access, generates random credentials and persists them in Keychain.
+@MainActor
+final class ManagedSlskdCredentials {
+    static let shared = ManagedSlskdCredentials()
+
+    /// Returns existing credentials or generates new ones.
+    func ensureCredentials() throws -> (username: String, password: String) {
+        if let u = ManagedSlskdKeychainService.load(account: "username"),
+           let p = ManagedSlskdKeychainService.load(account: "password"),
+           !u.isEmpty, !p.isEmpty {
+            return (u, p)
+        }
+
+        let username = "mixboard"
+        let password = UUID().uuidString
+
+        try ManagedSlskdKeychainService.save(account: "username", value: username)
+        try ManagedSlskdKeychainService.save(account: "password", value: password)
+
+        return (username, password)
+    }
+
+    var username: String? { ManagedSlskdKeychainService.load(account: "username") }
+    var password: String? { ManagedSlskdKeychainService.load(account: "password") }
+}

+ 502 - 0
Sources/Services/SlskdAPIClient.swift

@@ -0,0 +1,502 @@
+import Foundation
+import Security
+
+// MARK: - Slskd Keychain Storage
+
+/// Keychain wrapper for slskd credentials (username + password).
+/// Separate service from ChadMusic to avoid collision.
+enum SlskdKeychainService {
+    private static let service = "com.mixboard.slskd"
+
+    static func save(account: String, value: String) throws {
+        // A-10: Throw on encoding failure instead of silently returning,
+        // which could leave credentials in a half-saved state.
+        guard let data = value.data(using: .utf8) else {
+            throw KeychainService.KeychainError.saveFailed(errSecParam)
+        }
+
+        let deleteQuery: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account,
+        ]
+        SecItemDelete(deleteQuery as CFDictionary)
+
+        let addQuery: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account,
+            kSecValueData as String: data,
+            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
+        ]
+        let status = SecItemAdd(addQuery as CFDictionary, nil)
+        guard status == errSecSuccess else {
+            throw KeychainService.KeychainError.saveFailed(status)
+        }
+    }
+
+    static func load(account: String) -> String? {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account,
+            kSecReturnData as String: true,
+            kSecMatchLimit as String: kSecMatchLimitOne,
+        ]
+        var result: AnyObject?
+        let status = SecItemCopyMatching(query as CFDictionary, &result)
+        guard status == errSecSuccess, let data = result as? Data else { return nil }
+        return String(data: data, encoding: .utf8)
+    }
+
+    static func delete(account: String) {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account,
+        ]
+        SecItemDelete(query as CFDictionary)
+    }
+
+    static func deleteAll() {
+        delete(account: "username")
+        delete(account: "password")
+    }
+}
+
+// MARK: - Slskd Credentials
+
+/// Manages slskd username and password stored in Keychain.
+@MainActor
+final class SlskdCredentials {
+    static let shared = SlskdCredentials()
+
+    var username: String? {
+        get { SlskdKeychainService.load(account: "username") }
+    }
+
+    var password: String? {
+        get { SlskdKeychainService.load(account: "password") }
+    }
+
+    var hasCredentials: Bool {
+        guard let u = username, !u.isEmpty,
+              let p = password, !p.isEmpty else { return false }
+        return true
+    }
+
+    func save(username: String, password: String) throws {
+        try SlskdKeychainService.save(account: "username", value: username)
+        try SlskdKeychainService.save(account: "password", value: password)
+    }
+
+    func delete() {
+        SlskdKeychainService.deleteAll()
+    }
+}
+
+// MARK: - Slskd API Client
+
+/// Server mode: managed subprocess or external server.
+enum SlskdServerMode: String {
+    case managed
+    case external
+}
+
+/// HTTP client for the slskd REST API (v0).
+/// Handles JWT authentication, search, download management, and server health.
+@MainActor
+@Observable
+final class SlskdAPIClient {
+    static let shared = SlskdAPIClient()
+
+    // MARK: - Configuration
+
+    /// Whether slskd is managed by MixBoard or connected to an external server.
+    var serverMode: SlskdServerMode {
+        get {
+            let raw = UserDefaults.standard.string(forKey: "slskd.serverMode") ?? "managed"
+            return SlskdServerMode(rawValue: raw) ?? .managed
+        }
+        set { UserDefaults.standard.set(newValue.rawValue, forKey: "slskd.serverMode") }
+    }
+
+    /// Server base URL. Managed mode uses localhost; external mode uses user-configured URL.
+    var serverURL: String {
+        get {
+            switch serverMode {
+            case .managed:
+                return "http://localhost:\(SlskdProcessManager.port)"
+            case .external:
+                return UserDefaults.standard.string(forKey: "slskd.serverURL") ?? ""
+            }
+        }
+        set {
+            // Only meaningful in external mode
+            UserDefaults.standard.set(newValue, forKey: "slskd.serverURL")
+        }
+    }
+
+    /// Whether the client has URL + credentials configured.
+    var isConfigured: Bool {
+        switch serverMode {
+        case .managed:
+            return SlskdProcessManager.shared.state == .running
+        case .external:
+            let url = UserDefaults.standard.string(forKey: "slskd.serverURL") ?? ""
+            return !url.isEmpty && SlskdCredentials.shared.hasCredentials
+        }
+    }
+
+    // MARK: - Private State
+
+    private let session: URLSession
+    private let decoder: JSONDecoder
+    /// A-1: Store JWT as Data for explicit zeroing on invalidation.
+    private var jwtTokenData: Data?
+    private var tokenExpiry: Date?
+
+    // Circuit breaker for auth failures (C-1)
+    private var consecutiveAuthFailures = 0
+    private var authBackoffUntil: Date?
+    /// Maximum consecutive auth failures before the circuit breaker opens.
+    static let maxAuthRetries = 3
+
+    init() {
+        let config = URLSessionConfiguration.default
+        config.timeoutIntervalForRequest = 30
+        config.timeoutIntervalForResource = 120
+        self.session = URLSession(configuration: config)
+        self.decoder = JSONDecoder()
+    }
+
+    // MARK: - Authentication
+
+    /// Authenticate with slskd and cache the JWT token.
+    func authenticate() async throws {
+        // C-1: Circuit breaker — stop retrying after maxAuthRetries consecutive failures
+        if consecutiveAuthFailures >= Self.maxAuthRetries {
+            throw SlskdError.authCircuitOpen(failures: consecutiveAuthFailures)
+        }
+
+        // C-1: Exponential backoff — wait before retrying
+        if let backoffUntil = authBackoffUntil, Date() < backoffUntil {
+            throw SlskdError.authCircuitOpen(failures: consecutiveAuthFailures)
+        }
+
+        let username: String
+        let password: String
+
+        switch serverMode {
+        case .managed:
+            guard let u = ManagedSlskdCredentials.shared.username,
+                  let p = ManagedSlskdCredentials.shared.password else {
+                throw SlskdError.notConfigured
+            }
+            username = u
+            password = p
+        case .external:
+            guard let u = SlskdCredentials.shared.username,
+                  let p = SlskdCredentials.shared.password else {
+                throw SlskdError.notConfigured
+            }
+            username = u
+            password = p
+        }
+
+        let loginReq = SlskdLoginRequest(username: username, password: password)
+        let data = try JSONEncoder().encode(loginReq)
+
+        let url = try buildURL("api/v0/session")
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.httpBody = data
+
+        let (responseData, httpResponse) = try await performRequest(request)
+
+        guard httpResponse.statusCode == 200 else {
+            if httpResponse.statusCode == 401 {
+                // C-1: Track failure and set exponential backoff
+                consecutiveAuthFailures += 1
+                let backoffSeconds = pow(2.0, Double(consecutiveAuthFailures))
+                authBackoffUntil = Date().addingTimeInterval(backoffSeconds)
+                throw SlskdError.unauthorized
+            }
+            throw SlskdError.httpError(httpResponse.statusCode)
+        }
+
+        // C-1: Reset circuit breaker on success
+        consecutiveAuthFailures = 0
+        authBackoffUntil = nil
+
+        let loginResp = try decoder.decode(SlskdLoginResponse.self, from: responseData)
+        jwtTokenData = Data(loginResp.token.utf8)
+        // slskd tokens last ~24h. Refresh after 20h to be safe.
+        tokenExpiry = Date().addingTimeInterval(20 * 3600)
+    }
+
+    /// Ensure we have a valid JWT, refreshing if needed.
+    private func ensureAuthenticated() async throws {
+        if let expiry = tokenExpiry, Date() < expiry, jwtTokenData != nil {
+            return
+        }
+        try await authenticate()
+    }
+
+    /// Reset the auth circuit breaker (e.g., after user updates credentials).
+    func resetAuthCircuitBreaker() {
+        consecutiveAuthFailures = 0
+        authBackoffUntil = nil
+        clearToken()
+        tokenExpiry = nil
+    }
+
+    // MARK: - Search
+
+    /// Start a search on the Soulseek network.
+    /// Returns the search ID for polling.
+    func startSearch(query: String, responseLimit: Int = 100, timeout: Int = 15) async throws -> String {
+        try await ensureAuthenticated()
+
+        let searchReq = SlskdSearchRequest(
+            searchText: query,
+            responseLimit: responseLimit,
+            searchTimeout: timeout,
+            filterResponses: true,
+            minimumResponseFileCount: 1
+        )
+        let body = try JSONEncoder().encode(searchReq)
+
+        let url = try buildURL("api/v0/searches")
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        try applyAuth(&request)
+        request.httpBody = body
+
+        let (data, httpResponse) = try await performRequest(request)
+        try checkHTTPStatus(httpResponse, data: data)
+
+        let search = try decoder.decode(SlskdSearch.self, from: data)
+        return search.id
+    }
+
+    /// Poll a search by ID. Returns the search state including responses.
+    func getSearch(id: String) async throws -> SlskdSearch {
+        try await ensureAuthenticated()
+        return try await get("api/v0/searches/\(id)")
+    }
+
+    /// Delete a search to clean up server-side resources.
+    func deleteSearch(id: String) async throws {
+        try await ensureAuthenticated()
+
+        let url = try buildURL("api/v0/searches/\(id)")
+        var request = URLRequest(url: url)
+        request.httpMethod = "DELETE"
+        try applyAuth(&request)
+
+        let (_, httpResponse) = try await performRequest(request)
+        // 200 or 404 are both fine (already deleted)
+        if httpResponse.statusCode != 200 && httpResponse.statusCode != 404 {
+            throw SlskdError.httpError(httpResponse.statusCode)
+        }
+    }
+
+    // MARK: - Downloads
+
+    /// Enqueue files for download from a specific user.
+    func enqueueDownloads(username: String, files: [SlskdFile]) async throws {
+        try await ensureAuthenticated()
+
+        let url = try buildURL("api/v0/transfers/downloads/\(username)")
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        try applyAuth(&request)
+
+        let downloadReqs = files.map { SlskdDownloadRequest(filename: $0.filename, size: $0.size) }
+        request.httpBody = try JSONEncoder().encode(downloadReqs)
+
+        let (data, httpResponse) = try await performRequest(request)
+        try checkHTTPStatus(httpResponse, data: data)
+    }
+
+    /// Get all current download transfers.
+    func getDownloads() async throws -> [SlskdTransferGroup] {
+        try await ensureAuthenticated()
+        return try await get("api/v0/transfers/downloads")
+    }
+
+    // MARK: - Server
+
+    /// Check server state/connectivity.
+    func getServerState() async throws -> SlskdServerState {
+        try await ensureAuthenticated()
+        return try await get("api/v0/server")
+    }
+
+    /// Quick connectivity test. Returns nil on success, error on failure.
+    func testConnection() async -> SlskdError? {
+        do {
+            try await authenticate()
+            _ = try await getServerState()
+            return nil
+        } catch let error as SlskdError {
+            return error
+        } catch {
+            return .networkError(error)
+        }
+    }
+
+    // MARK: - Private Helpers
+
+    private func buildURL(_ path: String) throws -> URL {
+        let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { throw SlskdError.notConfigured }
+        let base = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
+        guard let url = URL(string: base + path) else {
+            throw SlskdError.notConfigured
+        }
+        // A-8: Only allow http/https to prevent file:// or other scheme abuse.
+        guard let scheme = url.scheme?.lowercased(),
+              scheme == "http" || scheme == "https" else {
+            throw SlskdError.notConfigured
+        }
+        return url
+    }
+
+    private func applyAuth(_ request: inout URLRequest) throws {
+        guard let data = jwtTokenData, let token = String(data: data, encoding: .utf8) else {
+            throw SlskdError.unauthorized
+        }
+        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+    }
+
+    private func performRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
+        // A-2: Retry idempotent GET requests on transient network failures.
+        // POST/DELETE are not retried to avoid side-effect duplication.
+        let maxRetries = (request.httpMethod == "GET") ? 2 : 0
+        var lastError: Error?
+
+        for attempt in 0...maxRetries {
+            if attempt > 0 {
+                // Exponential backoff: 1s, 2s
+                let backoff = UInt64(pow(2.0, Double(attempt - 1))) * 1_000_000_000
+                try? await Task.sleep(nanoseconds: backoff)
+                try Task.checkCancellation()
+            }
+
+            do {
+                let (data, response) = try await session.data(for: request)
+                guard let httpResponse = response as? HTTPURLResponse else {
+                    throw SlskdError.invalidResponse
+                }
+                return (data, httpResponse)
+            } catch is CancellationError {
+                throw CancellationError()
+            } catch let error as SlskdError {
+                throw error  // Our own errors — don't retry
+            } catch {
+                lastError = error
+                continue  // Transient URLError — retry
+            }
+        }
+
+        throw SlskdError.networkError(lastError ?? URLError(.unknown))
+    }
+
+    private func checkHTTPStatus(_ response: HTTPURLResponse, data: Data) throws {
+        switch response.statusCode {
+        case 200..<300:
+            return
+        case 401:
+            clearToken()
+            tokenExpiry = nil
+            throw SlskdError.unauthorized
+        case 429:
+            throw SlskdError.rateLimited
+        default:
+            throw SlskdError.httpError(response.statusCode)
+        }
+    }
+
+    /// A-1: Zero-out the JWT token data before releasing it.
+    private func clearToken() {
+        if var data = jwtTokenData {
+            let count = data.count
+            data.withUnsafeMutableBytes { ptr in
+                if let base = ptr.baseAddress {
+                    memset(base, 0, count)
+                }
+            }
+        }
+        jwtTokenData = nil
+    }
+
+    private func get<T: Decodable>(_ path: String) async throws -> T {
+        let url = try buildURL(path)
+        var request = URLRequest(url: url)
+        request.httpMethod = "GET"
+        try applyAuth(&request)
+
+        let (data, httpResponse) = try await performRequest(request)
+        try checkHTTPStatus(httpResponse, data: data)
+
+        do {
+            return try decoder.decode(T.self, from: data)
+        } catch {
+            throw SlskdError.decodingFailed(error)
+        }
+    }
+}
+
+// MARK: - SlskdError
+
+enum SlskdError: LocalizedError {
+    case notConfigured
+    case unauthorized
+    case authCircuitOpen(failures: Int)
+    case rateLimited
+    case httpError(Int)
+    case invalidResponse
+    case decodingFailed(Error)
+    case networkError(Error)
+    case searchTimeout
+    case noResults
+    case noQualityMatch
+    case downloadFailed(String)
+    case importTimeout
+
+    var errorDescription: String? {
+        switch self {
+        case .notConfigured:
+            "Soulseek not configured. Set the server URL and credentials in Settings."
+        case .unauthorized:
+            "Soulseek authentication failed (invalid credentials)."
+        case .authCircuitOpen(let failures):
+            "Soulseek authentication suspended after \(failures) consecutive failures. Check credentials in Settings."
+        case .rateLimited:
+            "Soulseek rate limited — too many requests. Try again shortly."
+        case .httpError(let code):
+            "Soulseek server returned HTTP \(code)."
+        case .invalidResponse:
+            "Invalid response from Soulseek server."
+        case .decodingFailed(let error):
+            "Failed to decode Soulseek response: \(error.localizedDescription)"
+        case .networkError(let error):
+            "Network error connecting to Soulseek: \(error.localizedDescription)"
+        case .searchTimeout:
+            "Soulseek search timed out after 30 seconds."
+        case .noResults:
+            "No Soulseek results found for this album."
+        case .noQualityMatch:
+            "No Soulseek source met the quality threshold (need score >= 80)."
+        case .downloadFailed(let detail):
+            "Soulseek download failed: \(detail)"
+        case .importTimeout:
+            "Import timed out — album may not be in library. Files are downloaded; try rescanning manually."
+        }
+    }
+}

+ 372 - 0
Sources/Services/SlskdProcessManager.swift

@@ -0,0 +1,372 @@
+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"
+        }
+    }
+}

+ 284 - 1
Sources/Views/SettingsView.swift

@@ -16,6 +16,11 @@ struct SettingsView: View {
                     Label("Chad Music", systemImage: "cloud.fill")
                 }
 
+            SlskdSettings()
+                .tabItem {
+                    Label("Soulseek", systemImage: "arrow.down.circle.fill")
+                }
+
             AppearanceSettings()
                 .tabItem {
                     Label("Appearance", systemImage: "paintbrush")
@@ -133,7 +138,7 @@ private struct AppearanceSettings: View {
     @EnvironmentObject private var theme: AppTheme
     @ObservedObject private var iconConfig = AppIconConfig.shared
 
-    private let modernSkins: [AppTheme.Skin] = [.dark, .midnight, .forest, .ocean, .warm, .light]
+    private let modernSkins: [AppTheme.Skin] = [.dark, .midnight, .forest, .ocean, .warm, .light, .djBoard]
     private let retroSkins: [AppTheme.Skin] = [.winampClassic, .winampModern, .foobarDark, .foobarLight, .win95, .win98, .xpLuna, .macOSClassic]
 
     var body: some View {
@@ -677,6 +682,283 @@ private struct ChadMusicSettings: View {
     }
 }
 
+// MARK: - Soulseek Settings
+
+private struct SlskdSettings: View {
+    @AppStorage("slskd.serverMode") private var serverModeRaw: String = "managed"
+    @AppStorage("slskd.serverURL") private var serverURL: String = ""
+    @State private var username: String = SlskdCredentials.shared.username ?? ""
+    @State private var password: String = SlskdCredentials.shared.password ?? ""
+    @State private var soulseekUsername: String = SlskdCredentials.shared.username ?? ""
+    @State private var soulseekPassword: String = SlskdCredentials.shared.password ?? ""
+    @State private var connectionStatus: SlskdConnectionStatus = .unknown
+    @State private var isTesting = false
+    /// H-6: Track whether credentials have unsaved changes.
+    @State private var hasUnsavedCredentials = false
+    /// H-6: Debounce timer to avoid saving on every keystroke.
+    @State private var saveDebounceTask: Task<Void, Never>?
+
+    private var serverMode: SlskdServerMode {
+        SlskdServerMode(rawValue: serverModeRaw) ?? .managed
+    }
+
+    private enum SlskdConnectionStatus {
+        case unknown, testing, success, failed(String)
+    }
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 20) {
+            Text("Soulseek")
+                .font(.title3.bold())
+
+            Text("Search and download music from the Soulseek network. MixBoard can manage slskd automatically, or you can connect to your own server.")
+                .font(.callout)
+                .foregroundStyle(.secondary)
+
+            // Mode picker
+            Picker("Server Mode", selection: $serverModeRaw) {
+                Text("Managed").tag("managed")
+                Text("External").tag("external")
+            }
+            .pickerStyle(.segmented)
+            .frame(maxWidth: 300)
+            .onChange(of: serverModeRaw) { _, newValue in
+                connectionStatus = .unknown
+                SlskdAPIClient.shared.serverMode = SlskdServerMode(rawValue: newValue) ?? .managed
+            }
+
+            if serverMode == .managed {
+                managedModeSection
+            } else {
+                externalModeSection
+            }
+
+            Divider()
+
+            // Connection test (both modes)
+            HStack(spacing: 12) {
+                Button("Test Connection") {
+                    testConnection()
+                }
+                .disabled(isTesting || (serverMode == .external && (serverURL.isEmpty || username.isEmpty || password.isEmpty)))
+
+                switch connectionStatus {
+                case .unknown:
+                    EmptyView()
+                case .testing:
+                    ProgressView()
+                        .controlSize(.small)
+                    Text("Connecting...")
+                        .font(.callout)
+                        .foregroundStyle(.secondary)
+                case .success:
+                    Image(systemName: "checkmark.circle.fill")
+                        .foregroundStyle(.green)
+                    Text("Connected to slskd")
+                        .font(.callout)
+                        .foregroundStyle(.secondary)
+                case .failed(let message):
+                    Image(systemName: "xmark.circle.fill")
+                        .foregroundStyle(.red)
+                    Text(message)
+                        .font(.callout)
+                        .foregroundStyle(.red)
+                }
+            }
+
+            Spacer()
+        }
+        .padding(24)
+        .onDisappear {
+            if hasUnsavedCredentials {
+                saveCredentials()
+            }
+            saveDebounceTask?.cancel()
+        }
+    }
+
+    // MARK: - Managed Mode
+
+    @ViewBuilder
+    private var managedModeSection: some View {
+        // Status indicator
+        HStack(spacing: 8) {
+            switch SlskdProcessManager.shared.state {
+            case .stopped:
+                Image(systemName: "circle")
+                    .foregroundStyle(.secondary)
+                Text("Stopped")
+                    .foregroundStyle(.secondary)
+            case .downloading(let progress):
+                ProgressView()
+                    .controlSize(.small)
+                Text("Downloading slskd... \(Int(progress * 100))%")
+                    .foregroundStyle(.secondary)
+            case .starting:
+                ProgressView()
+                    .controlSize(.small)
+                Text("Starting...")
+                    .foregroundStyle(.secondary)
+            case .running:
+                Image(systemName: "circle.fill")
+                    .foregroundStyle(.green)
+                    .font(.system(size: 8))
+                Text("Running on localhost:\(SlskdProcessManager.port)")
+                    .foregroundStyle(.secondary)
+            case .failed(let message):
+                Image(systemName: "exclamationmark.circle.fill")
+                    .foregroundStyle(.red)
+                Text(message)
+                    .foregroundStyle(.red)
+            }
+        }
+        .font(.callout)
+
+        // Start / Stop
+        HStack(spacing: 12) {
+            if SlskdProcessManager.shared.state == .running {
+                Button("Stop") {
+                    SlskdProcessManager.shared.stop()
+                }
+            } else if case .downloading = SlskdProcessManager.shared.state {
+                // Can't stop during download
+            } else if SlskdProcessManager.shared.state == .starting {
+                // Can't stop during startup
+            } else {
+                Button("Start") {
+                    Task { try? await SlskdProcessManager.shared.start() }
+                }
+            }
+        }
+
+        // Soulseek P2P credentials (needed for the network)
+        VStack(alignment: .leading, spacing: 6) {
+            Text("Soulseek Account")
+                .font(.headline)
+            Text("Your Soulseek network credentials (not the slskd API).")
+                .font(.caption)
+                .foregroundStyle(.secondary)
+
+            TextField("Soulseek username", text: $soulseekUsername)
+                .textFieldStyle(.roundedBorder)
+                .onChange(of: soulseekUsername) { _, _ in
+                    scheduleDebouncedSave()
+                }
+
+            SecureField("Soulseek password", text: $soulseekPassword)
+                .textFieldStyle(.roundedBorder)
+                .onChange(of: soulseekPassword) { _, _ in
+                    scheduleDebouncedSave()
+                }
+        }
+
+        if hasUnsavedCredentials {
+            unsavedCredentialsIndicator
+        }
+    }
+
+    // MARK: - External Mode
+
+    @ViewBuilder
+    private var externalModeSection: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            Text("Server URL")
+                .font(.headline)
+            TextField("http://100.x.x.x:5030", text: $serverURL)
+                .textFieldStyle(.roundedBorder)
+                .onChange(of: serverURL) { _, _ in
+                    connectionStatus = .unknown
+                }
+        }
+
+        VStack(alignment: .leading, spacing: 6) {
+            Text("Username")
+                .font(.headline)
+            TextField("slskd username", text: $username)
+                .textFieldStyle(.roundedBorder)
+                .onChange(of: username) { _, _ in
+                    connectionStatus = .unknown
+                    scheduleDebouncedSave()
+                }
+        }
+
+        VStack(alignment: .leading, spacing: 6) {
+            Text("Password")
+                .font(.headline)
+            SecureField("slskd password", text: $password)
+                .textFieldStyle(.roundedBorder)
+                .onChange(of: password) { _, _ in
+                    connectionStatus = .unknown
+                    scheduleDebouncedSave()
+                }
+        }
+
+        if hasUnsavedCredentials {
+            unsavedCredentialsIndicator
+        }
+    }
+
+    // MARK: - Shared Components
+
+    private var unsavedCredentialsIndicator: some View {
+        HStack(spacing: 6) {
+            Image(systemName: "exclamationmark.circle.fill")
+                .foregroundStyle(.orange)
+                .font(.system(size: 12))
+            Text("Unsaved changes")
+                .font(.caption)
+                .foregroundStyle(.orange)
+            Spacer()
+            Button("Save Credentials") {
+                saveCredentials()
+            }
+            .controlSize(.small)
+        }
+    }
+
+    // MARK: - Actions
+
+    /// H-6: Schedule a debounced save (1.5s after last keystroke).
+    private func scheduleDebouncedSave() {
+        hasUnsavedCredentials = true
+        saveDebounceTask?.cancel()
+        saveDebounceTask = Task { @MainActor in
+            try? await Task.sleep(for: .seconds(1.5))
+            guard !Task.isCancelled else { return }
+            saveCredentials()
+        }
+    }
+
+    private func saveCredentials() {
+        if serverMode == .managed {
+            let u = soulseekUsername.trimmingCharacters(in: .whitespacesAndNewlines)
+            let p = soulseekPassword.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !u.isEmpty, !p.isEmpty else { return }
+            try? SlskdCredentials.shared.save(username: u, password: p)
+        } else {
+            let u = username.trimmingCharacters(in: .whitespacesAndNewlines)
+            let p = password.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !u.isEmpty, !p.isEmpty else { return }
+            try? SlskdCredentials.shared.save(username: u, password: p)
+        }
+        hasUnsavedCredentials = false
+    }
+
+    private func testConnection() {
+        connectionStatus = .testing
+        isTesting = true
+        Task {
+            let error = await SlskdAPIClient.shared.testConnection()
+            if let error {
+                connectionStatus = .failed(error.localizedDescription)
+            } else {
+                connectionStatus = .success
+            }
+            isTesting = false
+        }
+    }
+}
+
 // MARK: - Skin Preview Color
 
 extension AppTheme.Skin {
@@ -689,6 +971,7 @@ extension AppTheme.Skin {
         case .ocean:         return Color(red: 0.1, green: 0.15, blue: 0.2)
         case .warm:          return Color(red: 0.2, green: 0.15, blue: 0.1)
         case .light:         return Color(red: 0.95, green: 0.95, blue: 0.96)
+        case .djBoard:       return Color(red: 0.04, green: 0.04, blue: 0.06)
         case .winampClassic: return Color(red: 0.12, green: 0.12, blue: 0.14)
         case .winampModern:  return Color(red: 0.13, green: 0.14, blue: 0.18)
         case .foobarDark:    return Color(red: 0.14, green: 0.14, blue: 0.14)