diff --git a/Music/Music.entitlements b/Music/Music.entitlements
index ff87a79..123156d 100644
--- a/Music/Music.entitlements
+++ b/Music/Music.entitlements
@@ -12,6 +12,8 @@
com.apple.security.network.client
+ com.apple.security.network.server
+
com.apple.security.device.audio-input
diff --git a/Music/Remote/HostServer.swift b/Music/Remote/HostServer.swift
new file mode 100644
index 0000000..acf8801
--- /dev/null
+++ b/Music/Remote/HostServer.swift
@@ -0,0 +1,363 @@
+import Foundation
+import Network
+import os
+
+@MainActor
+@Observable
+final class HostServer {
+ var isHosting = false
+ var connectedRemoteName: String?
+ private(set) var actualPort: UInt16?
+
+ private let dbPath: String
+ private var listener: NWListener?
+ private var commandTransport: NDJSONTransport?
+ private var commandConnection: NWConnection?
+ private var player: PlayerViewModel?
+ private var db: DatabaseService?
+ private var stateTimer: Timer?
+
+ private let logger = RemoteLogger.host
+
+ init(dbPath: String) {
+ self.dbPath = dbPath
+ }
+
+ /// Configure the server with a player and database for command dispatch.
+ /// Pass `nil` for either if not needed (e.g. DB-only serving without a player).
+ func configure(player: PlayerViewModel?, db: DatabaseService?) {
+ self.player = player
+ self.db = db
+ }
+
+ /// Start the Bonjour listener on a random TCP port.
+ func start() throws {
+ let params = NWParameters.tcp
+ let listener = try NWListener(using: params)
+ listener.service = NWListener.Service(type: "_musicremote._tcp")
+
+ listener.stateUpdateHandler = { [weak self] state in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ switch state {
+ case .ready:
+ if let port = listener.port?.rawValue {
+ self.actualPort = port
+ self.logger.info("Listener ready on port \(port)")
+ }
+ self.isHosting = true
+ case .failed(let error):
+ self.logger.error("Listener failed: \(error.localizedDescription)")
+ self.isHosting = false
+ case .cancelled:
+ self.isHosting = false
+ default:
+ break
+ }
+ }
+ }
+
+ listener.newConnectionHandler = { [weak self] connection in
+ Task { @MainActor [weak self] in
+ self?.handleNewConnection(connection)
+ }
+ }
+
+ listener.start(queue: .main)
+ self.listener = listener
+
+ // Start periodic playback-state timer
+ stateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.sendPlaybackState()
+ }
+ }
+ }
+
+ /// Stop the server, close all connections, and remove the Bonjour advertisement.
+ func stop() {
+ stateTimer?.invalidate()
+ stateTimer = nil
+ commandTransport?.close()
+ commandTransport = nil
+ commandConnection?.cancel()
+ commandConnection = nil
+ connectedRemoteName = nil
+ listener?.cancel()
+ listener = nil
+ actualPort = nil
+ isHosting = false
+ }
+
+ // MARK: - Connection Handling
+
+ /// Receive the first chunk of data from a new connection to determine the HTTP route.
+ private func handleNewConnection(_ connection: NWConnection) {
+ connection.stateUpdateHandler = { [weak self] state in
+ Task { @MainActor [weak self] in
+ if case .failed(let error) = state {
+ self?.logger.error("Connection failed: \(error.localizedDescription)")
+ }
+ }
+ }
+
+ connection.start(queue: .main)
+
+ // Read the initial HTTP request line to determine the route
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, _, error in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ if let error {
+ self.logger.error("Failed to read request: \(error.localizedDescription)")
+ connection.cancel()
+ return
+ }
+
+ guard let data, let request = String(data: data, encoding: .utf8) else {
+ connection.cancel()
+ return
+ }
+
+ self.routeRequest(request, on: connection)
+ }
+ }
+ }
+
+ /// Parse the HTTP request line and dispatch to the appropriate handler.
+ private func routeRequest(_ request: String, on connection: NWConnection) {
+ let firstLine = request.split(separator: "\r\n").first.map(String.init) ?? request
+ logger.info("Request: \(firstLine)")
+
+ if firstLine.hasPrefix("GET /db") {
+ handleDBRequest(on: connection)
+ } else if firstLine.hasPrefix("GET /cmd") {
+ handleCommandRequest(on: connection)
+ } else {
+ sendHTTP(status: "404 Not Found", body: Data("Not Found".utf8), on: connection, close: true)
+ }
+ }
+
+ // MARK: - GET /db
+
+ /// Serve the SQLite database as an HTTP response.
+ /// Uses SQLite's backup API to produce a self-contained copy that includes
+ /// all WAL data, avoiding races with concurrent writers.
+ private func handleDBRequest(on connection: NWConnection) {
+ do {
+ let data: Data
+ if let db {
+ // Create a temporary copy via the backup API so the served file
+ // is self-contained (no WAL/SHM dependency) and consistent.
+ let tempURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString + ".sqlite")
+ defer { try? FileManager.default.removeItem(at: tempURL) }
+ try db.backup(to: tempURL.path)
+ data = try Data(contentsOf: tempURL)
+ } else {
+ // Fallback: serve the raw file when no DatabaseService is configured
+ data = try Data(contentsOf: URL(fileURLWithPath: dbPath))
+ }
+ logger.info("Serving database (\(data.count) bytes)")
+ sendHTTP(
+ status: "200 OK",
+ body: data,
+ contentType: "application/octet-stream",
+ on: connection,
+ close: true
+ )
+ } catch {
+ logger.error("Failed to read database: \(error.localizedDescription)")
+ sendHTTP(
+ status: "500 Internal Server Error",
+ body: Data("Failed to read database".utf8),
+ on: connection,
+ close: true
+ )
+ }
+ }
+
+ // MARK: - GET /cmd
+
+ /// Upgrade the connection to an NDJSON command channel.
+ private func handleCommandRequest(on connection: NWConnection) {
+ // Only one command channel at a time
+ if commandTransport != nil {
+ logger.warning("Rejecting second command channel")
+ sendHTTP(
+ status: "409 Conflict",
+ body: Data("Command channel already connected".utf8),
+ on: connection,
+ close: true
+ )
+ return
+ }
+
+ // Send the HTTP 200 response header, then upgrade to NDJSON streaming
+ let header = "HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n"
+ connection.send(content: Data(header.utf8), completion: .contentProcessed { [weak self] error in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ if let error {
+ self.logger.error("Failed to send cmd header: \(error.localizedDescription)")
+ connection.cancel()
+ return
+ }
+
+ self.setupCommandTransport(on: connection)
+ }
+ })
+ }
+
+ /// Wire up the NDJSON transport for reading commands and sending events.
+ private func setupCommandTransport(on connection: NWConnection) {
+ let transport = NDJSONTransport(connection: connection, logger: logger)
+ self.commandTransport = transport
+ self.commandConnection = connection
+
+ transport.onLine = { [weak self] line in
+ Task { @MainActor [weak self] in
+ self?.handleIncomingLine(line)
+ }
+ }
+
+ transport.onClose = { [weak self] in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ self.logger.info("Command channel closed")
+ self.commandTransport = nil
+ self.commandConnection = nil
+ self.connectedRemoteName = nil
+ }
+ }
+
+ transport.startReceiving()
+ logger.info("Command channel established")
+
+ // Send initial playback state
+ sendPlaybackState()
+ }
+
+ // MARK: - Command Dispatch
+
+ /// Process an incoming NDJSON line — try handshake first, then remote command.
+ private func handleIncomingLine(_ line: String) {
+ guard let data = line.data(using: .utf8) else { return }
+ let decoder = JSONDecoder()
+
+ // Try parsing as a handshake message first
+ if let handshake = try? decoder.decode(HandshakeMessage.self, from: data) {
+ logger.info("Handshake received: v\(handshake.protocolVersion), app \(handshake.appVersion)")
+ connectedRemoteName = handshake.appVersion
+ sendPlaybackState()
+ return
+ }
+
+ // Parse as a remote command
+ do {
+ let command = try decoder.decode(RemoteCommand.self, from: data)
+ logger.info("Received command: \(String(describing: command))")
+ dispatchCommand(command)
+ } catch {
+ logger.error("Failed to decode command: \(error.localizedDescription)")
+ commandTransport?.send(HostEvent.error(message: "Invalid command"))
+ }
+ }
+
+ /// Execute the remote command against the player.
+ private func dispatchCommand(_ command: RemoteCommand) {
+ guard let player else {
+ logger.warning("No player configured, ignoring command")
+ return
+ }
+
+ switch command {
+ case .play(let trackId, let queueIds):
+ handlePlayCommand(trackId: trackId, queueIds: queueIds, player: player)
+ case .pause:
+ player.pause()
+ case .resume:
+ player.resume()
+ case .next:
+ player.next()
+ case .previous:
+ player.previous()
+ case .seek(let position):
+ player.seek(to: position)
+ case .setVolume(let level):
+ player.setVolume(level)
+ case .toggleShuffle:
+ player.toggleShuffle()
+ case .refreshDB:
+ commandTransport?.send(HostEvent.dbReady)
+ return // Don't send playback state for refreshDB, just the dbReady event
+ }
+
+ // After each command, send current playback state
+ sendPlaybackState()
+ }
+
+ /// Handle the play command by fetching tracks and setting up the queue.
+ private func handlePlayCommand(trackId: Int64, queueIds: [Int64], player: PlayerViewModel) {
+ guard let db else {
+ logger.warning("No database configured, cannot handle play command")
+ commandTransport?.send(HostEvent.error(message: "No database available"))
+ return
+ }
+
+ do {
+ let tracks = try db.fetchTracksByIds(queueIds)
+ guard let track = tracks.first(where: { $0.id == trackId }) else {
+ logger.warning("Track \(trackId) not found in database")
+ commandTransport?.send(HostEvent.error(message: "Track not found"))
+ return
+ }
+ player.setQueue(tracks)
+ player.play(track)
+ } catch {
+ logger.error("Failed to fetch tracks: \(error.localizedDescription)")
+ commandTransport?.send(HostEvent.error(message: "Database error"))
+ }
+ }
+
+ // MARK: - State Updates
+
+ /// Build and send the current playback state to the connected remote.
+ private func sendPlaybackState() {
+ guard let transport = commandTransport else { return }
+
+ let payload = PlaybackStatePayload(
+ trackId: player?.currentTrack?.id,
+ isPlaying: player?.isPlaying ?? false,
+ currentTime: player?.currentTime ?? 0,
+ duration: player?.duration ?? 0,
+ volume: player?.volume ?? 0.65,
+ isShuffled: player?.isShuffled ?? false
+ )
+
+ transport.send(HostEvent.playbackState(payload))
+ }
+
+ // MARK: - HTTP Helper
+
+ /// Send an HTTP response with the given status, body, and content type.
+ private func sendHTTP(
+ status: String,
+ body: Data?,
+ contentType: String = "text/plain",
+ on connection: NWConnection,
+ close: Bool
+ ) {
+ let bodyData = body ?? Data()
+ let header = "HTTP/1.1 \(status)\r\nContent-Type: \(contentType)\r\nContent-Length: \(bodyData.count)\r\nConnection: \(close ? "close" : "keep-alive")\r\n\r\n"
+ var responseData = Data(header.utf8)
+ responseData.append(bodyData)
+
+ connection.send(content: responseData, completion: .contentProcessed { _ in
+ if close {
+ connection.cancel()
+ }
+ })
+ }
+}
diff --git a/Music/Services/DatabaseService.swift b/Music/Services/DatabaseService.swift
index 59d25dc..a83c22f 100644
--- a/Music/Services/DatabaseService.swift
+++ b/Music/Services/DatabaseService.swift
@@ -113,6 +113,15 @@ nonisolated final class DatabaseService: Sendable {
try migrator.migrate(db)
}
+ // MARK: - Maintenance
+
+ /// Create a self-contained copy of the database at the given path using
+ /// SQLite's online backup API. The copy includes all WAL data and is safe
+ /// to serve or transfer without additional files.
+ func backup(to destinationPath: String) throws {
+ try dbPool.backup(to: DatabaseQueue(path: destinationPath))
+ }
+
// MARK: - Write
func insert(_ track: inout Track) throws {
diff --git a/MusicTests/HostServerIntegrationTests.swift b/MusicTests/HostServerIntegrationTests.swift
new file mode 100644
index 0000000..83e2a06
--- /dev/null
+++ b/MusicTests/HostServerIntegrationTests.swift
@@ -0,0 +1,164 @@
+import Testing
+import Foundation
+import Network
+@testable import Music
+
+@MainActor
+struct HostServerIntegrationTests {
+
+ // Starts a HostServer, connects via TCP, sends GET /db,
+ // verifies the response contains valid SQLite data.
+ @Test(.timeLimit(.minutes(1)))
+ func dbDownloadReturnsValidSQLite() async throws {
+ // 1. Create a temp directory and a SQLite database with one track
+ let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let dbPath = tempDir.appendingPathComponent("db.sqlite").path
+ let db = try DatabaseService(path: dbPath)
+ var track = Track.fixture(fileURL: "/test.mp3")
+ try db.insert(&track)
+
+ // 2. Start the HostServer (configured with db for WAL checkpoint) and wait for the listener to be ready
+ let server = HostServer(dbPath: dbPath)
+ server.configure(player: nil, db: db)
+ try server.start()
+ try await Task.sleep(for: .milliseconds(200))
+ let port = server.actualPort!
+
+ // 3. Perform an HTTP GET /db request to download the database
+ let responseData = try await httpGet(host: "127.0.0.1", port: port, path: "/db")
+
+ // 4. Verify the response starts with the SQLite magic header
+ let header = String(data: responseData.prefix(16), encoding: .utf8) ?? ""
+ #expect(header.hasPrefix("SQLite format 3"))
+
+ // 5. Write the downloaded data to disk and verify it contains the inserted track
+ let downloadedPath = tempDir.appendingPathComponent("downloaded.sqlite").path
+ try responseData.write(to: URL(fileURLWithPath: downloadedPath))
+ let downloadedDb = try DatabaseService(path: downloadedPath)
+ #expect(try downloadedDb.trackCount() == 1)
+
+ // 6. Clean up
+ server.stop()
+ try? FileManager.default.removeItem(at: tempDir)
+ }
+
+ // Connects to /cmd, sends a pause command, verifies a playbackState event comes back.
+ @Test(.timeLimit(.minutes(1)))
+ func commandChannelRoundTrip() async throws {
+ // 1. Create a temp directory and an empty SQLite database
+ let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let dbPath = tempDir.appendingPathComponent("db.sqlite").path
+ _ = try DatabaseService(path: dbPath)
+
+ // 2. Set up the player and server with command dispatch configured
+ let audio = AudioService()
+ let player = PlayerViewModel(audio: audio, db: nil)
+ let server = HostServer(dbPath: dbPath)
+ server.configure(player: player, db: nil)
+ try server.start()
+ try await Task.sleep(for: .milliseconds(200))
+ let port = server.actualPort!
+
+ // 3. Open a command channel connection via GET /cmd
+ let connection = try await connectCommandChannel(host: "127.0.0.1", port: port)
+
+ // 4. Send a pause command as NDJSON
+ let pauseCmd = try JSONEncoder().encode(RemoteCommand.pause)
+ var lineData = pauseCmd
+ lineData.append(contentsOf: "\n".utf8)
+ connection.send(content: lineData, completion: .contentProcessed { _ in })
+
+ // 5. Wait for and decode the playbackState response event
+ let responseLine = try await receiveOneLine(on: connection)
+ let event = try JSONDecoder().decode(HostEvent.self, from: Data(responseLine.utf8))
+
+ // 6. Verify it is a playbackState event with isPlaying == false
+ if case .playbackState(let payload) = event {
+ #expect(payload.isPlaying == false)
+ } else {
+ Issue.record("Expected playbackState, got \(event)")
+ }
+
+ // 7. Clean up
+ connection.cancel()
+ server.stop()
+ try? FileManager.default.removeItem(at: tempDir)
+ }
+
+ // MARK: - Helpers
+
+ /// Performs a simple HTTP GET using NWConnection and returns the response body.
+ private func httpGet(host: String, port: UInt16, path: String) async throws -> Data {
+ try await withCheckedThrowingContinuation { continuation in
+ let connection = NWConnection(
+ host: NWEndpoint.Host(host),
+ port: NWEndpoint.Port(rawValue: port)!,
+ using: .tcp
+ )
+ connection.stateUpdateHandler = { state in
+ if case .ready = state {
+ // Send the HTTP request
+ let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n"
+ connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in })
+ // Receive the full response (Connection: close ensures we get everything)
+ connection.receiveMessage { data, _, _, error in
+ if let error {
+ continuation.resume(throwing: error)
+ } else if let data, let range = data.range(of: Data("\r\n\r\n".utf8)) {
+ // Strip HTTP headers, return just the body
+ continuation.resume(returning: Data(data[range.upperBound...]))
+ } else {
+ continuation.resume(returning: data ?? Data())
+ }
+ connection.cancel()
+ }
+ } else if case .failed(let error) = state {
+ continuation.resume(throwing: error)
+ }
+ }
+ connection.start(queue: .main)
+ }
+ }
+
+ /// Opens a TCP connection to the /cmd endpoint and waits for the HTTP response header.
+ private func connectCommandChannel(host: String, port: UInt16) async throws -> NWConnection {
+ try await withCheckedThrowingContinuation { continuation in
+ let connection = NWConnection(
+ host: NWEndpoint.Host(host),
+ port: NWEndpoint.Port(rawValue: port)!,
+ using: .tcp
+ )
+ connection.stateUpdateHandler = { state in
+ if case .ready = state {
+ // Send the HTTP upgrade request for the command channel
+ let request = "GET /cmd HTTP/1.1\r\nHost: \(host)\r\nConnection: keep-alive\r\n\r\n"
+ connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in })
+ // Consume the HTTP 200 response header
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in
+ continuation.resume(returning: connection)
+ }
+ } else if case .failed(let error) = state {
+ continuation.resume(throwing: error)
+ }
+ }
+ connection.start(queue: .main)
+ }
+ }
+
+ /// Reads one newline-delimited line from a connection.
+ private func receiveOneLine(on connection: NWConnection) async throws -> String {
+ try await withCheckedThrowingContinuation { continuation in
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in
+ if let error {
+ continuation.resume(throwing: error)
+ } else {
+ let text = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
+ // Extract the first complete line from the received data
+ continuation.resume(returning: text.split(separator: "\n").first.map(String.init) ?? text)
+ }
+ }
+ }
+ }
+}